> ## Documentation Index
> Fetch the complete documentation index at: https://docs.peeker.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Best practices

> Sandbox, request IDs, errors, paging, idempotent orders, pending actions, and webhooks.

Read this once before integrating; come back when you hit an error or pending action you do not recognize.

## 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 /orders` body shape (run a few real ones; watch the order move through `in_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.

Switch to `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.

```http theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
HTTP/1.1 200 OK
Peeker-Request-Id: req_01HZX0E5K7N9P2Q4R6S8T0U1V3
```

Log it with the request URL, status code, and error body. Support uses this ID to find the exact call.

## 3. Reading errors

Every error response - 4xx and 5xx - uses the same envelope. Branch on `error.code`. Don't pattern-match `error.message`; the wording can change.

```json theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
{
	"error": {
		"code": "domain_count_mismatch",
		"message": "Submitted domain count does not match the order plan",
		"field": "domains",
		"details": {
			"required_count": 4,
			"submitted_count": 2,
			"missing_count": 2
		}
	}
}
```

**Decision rule.** Look at the status, then `error.code`:

* **4xx with `field`** → fix the request body. Don't retry.
* **401** → check the bearer header / key.
* **403** → key lacks permission. Use a `full` key.
* **429** → wait `details.retry_after_seconds`, then retry.
* **5xx** → retry with backoff. Open a ticket if it persists; include the `Peeker-Request-Id`.

Common v1 codes:

| 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:

```json theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
{
	"data": [],
	"links": {
		"first": "https://api.peeker.ai/partner/v1/orders?per_page=25",
		"next": "https://api.peeker.ai/partner/v1/orders?per_page=25&page_token=eyJ..."
	},
	"meta": { "path": "...", "per_page": 25, "returned": 25 }
}
```

The pattern: keep calling `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.

```bash theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
url="https://api.peeker.ai/partner/v1/orders?per_page=100"
while [ -n "$url" ] && [ "$url" != "null" ]; do
  page=$(curl -sS -H "Authorization: Bearer $PEEKER_KEY" "$url")
  echo "$page" | jq -c '.data[]'
  url=$(echo "$page" | jq -r '.links.next')
done
```

`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 stays `action_required`. You see pending actions in three places:

* The status field on orders, domains, imports, and swaps - `status: "action_required"`.
* The `GET /orders/pending` endpoint for order actions.
* A `*.action_required` webhook event - one per resource family.

Public reasons:

| 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.

1. **Verify the signature.** Reject any request with a missing or invalid `Peeker-Signature` (or stale `Peeker-Timestamp`, > 5 min skew). Code in [Webhooks → Verify the signature](/webhooks#verify-the-signature).
2. **Make the handler idempotent.** Dedupe on `Peeker-Event-Id` for at least 24h. Concurrent provisioning means events arrive out of order or double up under retry.
3. **Return 2xx fast.** You have 10 seconds. Push slow work into a queue; respond first.
4. **Don't trust event ordering.** A `domain.connected` can land before its `order.in_progress`. Read `data.status` to decide what to render.
