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

# Webhooks

> Signed JSON events for every async job - orders, domains, imports, and swaps.

Peeker sends signed JSON events for async work: orders, domains, imports, and swaps. Register one URL per partner org in the portal. We deliver each event up to **5 times over 24 hours** with exponential backoff. Verify the signature and make your handler idempotent.

## Register a URL and secret

Open the [Partner portal → Webhooks](https://app.peeker.com/partner/webhooks). Set:

* **URL** - your HTTPS endpoint that returns `2xx` quickly. Peeker times out delivery attempts after 15 seconds.
* **Secret** - Peeker generates one when you save the URL. You'll see it once. Store it in your secrets manager and pass it to the verifier.

You get one URL per partner org.

## Headers we send

| Header             | What it carries                                                             |
| ------------------ | --------------------------------------------------------------------------- |
| `Peeker-Event-Id`  | Stable per logical event. Use to dedupe on your side. Format: `evt_01HZX…`. |
| `Peeker-Timestamp` | Unix timestamp (seconds) when Peeker signed the body.                       |
| `Peeker-Signature` | `v1=<hex hmac-sha256>`. Versioned so the algorithm can rotate.              |

## Body envelope

Every event uses the same envelope. Only `data` changes shape per event type.

```json theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
{
	"id": "evt_01HZX0EV1A2B3C4D5E6F7G8H",
	"type": "order.in_progress",
	"created_at": "2026-05-08T12:00:00Z",
	"data": {
		"id": "ord_01HZX0OR1A2B3C4D5E6F7G8H",
		"status": "in_progress"
	}
}
```

## Event catalog

All public event types you can receive.

### Orders

| Event                    | Fired when                                                                          |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `order.in_progress`      | Order accepted; provisioning has started. There is no separate `order.created`.     |
| `order.completed`        | Every domain and inbox in the order is live. Payload is the full provisioned order. |
| `order.failed`           | Provisioning hit an unrecoverable error.                                            |
| `order.action_required`  | Order needs partner attention. Fetch rows from `GET /orders/pending`.               |
| `order.cancel_scheduled` | Cancellation accepted; finalizes after a short grace window.                        |
| `order.cancelled`        | Cancellation finished. Domains released and free for a new order.                   |

### Domains

| Event                       | Fired when                                                                      |
| --------------------------- | ------------------------------------------------------------------------------- |
| `domain.connected`          | Domain fully provisioned with its mail provider.                                |
| `domain.action_required`    | Domain needs partner attention (e.g. nameservers not flipped).                  |
| `domain.forwarding_updated` | Forwarding URL change applied.                                                  |
| `domain.forwarding_failed`  | Forwarding URL change couldn't be applied (DNS lookup failure, invalid target). |

### Domain imports

| Event                           | Fired when                                  |
| ------------------------------- | ------------------------------------------- |
| `domain_import.completed`       | Every domain in the import job finished.    |
| `domain_import.failed`          | Import failed before any domains connected. |
| `domain_import.action_required` | Customer hasn't pointed nameservers yet.    |

### Swaps

| Event                  | Fired when                                                             |
| ---------------------- | ---------------------------------------------------------------------- |
| `swap.created`         | `POST /swaps` or `POST /swaps/user_names` accepted.                    |
| `swap.in_progress`     | Swap workers picked it up.                                             |
| `swap.completed`       | New domain is up; old one detached (or kept warm 14 days for premium). |
| `swap.failed`          | Swap hit an unrecoverable error.                                       |
| `swap.action_required` | Swap needs partner attention.                                          |

## Verify the signature

Build the string `<unix_seconds>.<event_id>.<json_body>` and HMAC-SHA-256 it with your listener's secret. Compare in constant time to the value after `v1=`. Reject any request whose `Peeker-Timestamp` is more than 5 minutes from your server's clock.

```bash theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
#!/usr/bin/env bash
# Verify a Peeker webhook signature. Pipe the request body to stdin.
#   ./verify.sh "$PEEKER_TIMESTAMP" "$PEEKER_EVENT_ID" "$PEEKER_SIGNATURE" "$PEEKER_SECRET" < body.json
set -euo pipefail
ts="$1"; event_id="$2"; sig_header="$3"; secret="$4"
body=$(cat)
provided="${sig_header#v1=}"

# Refuse stale timestamps (> 5 minutes skew).
now=$(date +%s)
if [ $((now - ts)) -gt 300 ] || [ $((ts - now)) -gt 300 ]; then
  echo "stale timestamp" >&2; exit 1
fi

expected=$(printf '%s.%s.%s' "$ts" "$event_id" "$body" \
  | openssl dgst -sha256 -hmac "$secret" -hex \
  | awk '{print $2}')

# Constant-time compare via length+xor in shell is fragile; use python for safety.
python3 -c "
import hmac, sys
provided, expected = sys.argv[1], sys.argv[2]
ok = hmac.compare_digest(provided, expected)
sys.exit(0 if ok else 1)
" "$provided" "$expected"
```

Same logic in Node.js for production handlers:

```bash theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
# Save as verify.js and require it from your webhook route.
node -e "
const crypto = require('crypto');
const ts = process.env.PEEKER_TIMESTAMP;
const id = process.env.PEEKER_EVENT_ID;
const sig = process.env.PEEKER_SIGNATURE;
const secret = process.env.PEEKER_SECRET;
const body = require('fs').readFileSync(0, 'utf8');

if (Math.abs(Date.now()/1000 - Number(ts)) > 300) { process.exit(1); }
const expected = crypto.createHmac('sha256', secret)
  .update(\`\${ts}.\${id}.\${body}\`).digest('hex');
const provided = sig.replace(/^v1=/, '');
process.exit(crypto.timingSafeEqual(
  Buffer.from(provided, 'hex'),
  Buffer.from(expected, 'hex')
) ? 0 : 1);
"
```

## Retries and deadletter

Failed deliveries (non-2xx response or timeout) retry up to **5 times over 24 hours**. After the 5th failure, the event is marked `failed` in the portal. Return any `2xx` within 15 seconds to acknowledge; faster is better so retries do not pile up behind a slow handler.

The portal shows every attempt with the response status and timestamp.

## Make your handler idempotent

Events for the same resource can arrive out of order, and retries can deliver the same event twice.

Two rules:

1. **Dedupe on `Peeker-Event-Id`**. Store seen IDs for at least 24 hours.
2. **Use the payload, not the order of arrival**. Read `data.status` to decide what to render - don't treat the event sequence as a state machine.

## The `action_required` family

Every resource family has an `action_required` event:

* `order.action_required` - most often a Google profile picture URL is private or invalid; call `GET /orders/pending?order_id=...` for rows.
* `domain.action_required` - nameservers aren't pointed yet, or the domain needs replacement.
* `domain_import.action_required` - same nameserver issue scoped to an import job.
* `swap.action_required` - swap is blocked on partner input.

When you receive one, surface the resource as "needs attention" in your UI. The full reason list and resolution steps live in [Best Practices → Handling pending actions](/best-practices#6-handling-pending-actions).
