1. Test in sandbox first
pk_test_… runs the full API surface against a sandbox database. Every shape matches live, every webhook fires, no provisioning runs against real domain registrars or mail providers, and no charges are created.
Test these before going live:
- Your
POST /ordersbody shape (run a few real ones; watch the order move throughin_progress → completed). - Your webhook signature verification - sandbox events are signed with the sandbox secret you configure separately.
- Your error-recovery branches - sandbox returns the same error envelope as live for invalid bodies.
pk_live_… only after you’ve completed at least one full order in sandbox and verified the order/webhook flow end-to-end.
2. Always log Peeker-Request-Id
Every response - success or error - carries a Peeker-Request-Id header.
3. Reading errors
Every error response - 4xx and 5xx - uses the same envelope. Branch onerror.code. Don’t pattern-match error.message; the wording can change.
error.code:
- 4xx with
field→ fix the request body. Don’t retry. - 401 → check the bearer header / key.
- 403 → key lacks permission. Use a
fullkey. - 429 → wait
details.retry_after_seconds, then retry. - 5xx → retry with backoff. Open a ticket if it persists; include the
Peeker-Request-Id.
| Code | Status | What it means | What to do |
|---|---|---|---|
invalid_request | 400 | A field is missing, the wrong type, or invalid. error.field points to the offender. | Fix the body, resend. |
unauthorized | 401 | Key missing, malformed, or revoked. | Re-check Authorization header. |
forbidden | 403 | Key valid but doesn’t permit this action. | Use a full preset key. |
not_found | 404 | No matching record in your account. | Don’t retry on a different key - 404 is account-scoped. |
rate_limited | 429 | 600/min cap exceeded, or live synchronous import capacity is busy. | Wait details.retry_after_seconds, retry. For bulk imports, use /domains/import/jobs. |
order_shape_conflict | 400 | POST /orders body mixed bundle_id with custom license/density fields. | Pick one - bundle OR custom sizing. |
domain_count_mismatch | 400 | Submitted domain count doesn’t match the order plan. | Match the resolved bundle plan, or your custom license/density fields. |
domain_already_in_active_order | 400 | One or more domains are on another in-progress order or one scheduled to cancel. | Cancel the other order or pick different domains. |
domain_not_imported | 400 | Domain is registered with another registrar and hasn’t been imported for this customer. | POST /domains/import first; wait for domain_import.completed. |
premium_domain_not_supported | 400 | Premium domain submitted on the standard order flow. | Pick a non-premium domain or contact support for manual procurement. |
domain_not_usable_for_provider | 400 | Domain can’t host the requested provider (often Google vs Microsoft DNS conflict). | Switch the domain’s provider or pick a different domain. |
internal_error | 500 | Unexpected server error. | Retry with exponential backoff. Persisting? File a ticket with the request ID. |
4. Paging through lists
Every list endpoint returns the same envelope:links.next until it’s null. Default page size is 25, max is 100. For backfills, request per_page=100; for UI lists, default 25 reads fastest first row.
page_token is signed and encodes the cursor and the filters from the original request. Don’t construct or decode it by hand. If you change filters mid-walk, restart from links.first - the existing token will return invalid_request with field: "page_token".
There are no totals (total, total_count, etc.) in v1 - page to the end and accumulate if you need an exact count.
5. Retrying job creation is safe
Resend the same body and you get the same order back. We hash the order shape server-side and dedupe for 24 hours. No header to set, no idempotency key to generate. The fingerprint hashes the normalized order:user, provider route, bundle/custom sizing, inboxes-per-domain, domains, personas, and cost basis. Two custom orders with the same license counts but different domain density do not dedupe together.
After the 24-hour window, the same body creates a new order. The original is unaffected - GET /orders/{id} still returns it.
Async import jobs also dedupe retries without a header, using the normalized sorted unique domain set. Resending the same domains returns the same import job.
6. Handling pending actions
A pending action is a blocker that needs partner or customer input. Until it is resolved, the resource staysaction_required. You see pending actions in three places:
- The status field on orders, domains, imports, and swaps -
status: "action_required". - The
GET /orders/pendingendpoint for order actions. - A
*.action_requiredwebhook event - one per resource family.
| Reason | What’s blocking | How to resolve |
|---|---|---|
update_nameservers | The customer hasn’t pointed nameservers at the registrar Peeker requested. | Send the nameserver_groups from GET /domains/import/{id} to the customer. |
replace_domain | The domain can’t be provisioned and needs swapping. | Submit a POST /swaps for that domain. |
profile_picture_not_accessible | A Google profile picture URL is invalid, private, local, or unreachable. | Re-host on a public CDN and place a corrected order. |
7. Webhook handler hygiene
Four rules. All four matter on day one of production traffic.- Verify the signature. Reject any request with a missing or invalid
Peeker-Signature(or stalePeeker-Timestamp, > 5 min skew). Code in Webhooks → Verify the signature. - Make the handler idempotent. Dedupe on
Peeker-Event-Idfor at least 24h. Concurrent provisioning means events arrive out of order or double up under retry. - Return 2xx fast. You have 10 seconds. Push slow work into a queue; respond first.
- Don’t trust event ordering. A
domain.connectedcan land before itsorder.in_progress. Readdata.statusto decide what to render.