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.
Peeker fires signed JSON events whenever async work happens — 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 Peeker-Signature header before trusting any payload, and make your handler idempotent — concurrent provisioning means events can arrive out of order.
Register a URL and secret
Open the Partner portal → Webhooks. Set:
- URL — your HTTPS endpoint that returns
2xx within 10 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 — no per-environment routing. Use the event.data.api_key.environment field if you want to branch on live vs sandbox.
| 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.
{
"id": "evt_01HZX0EV1A2B3C4D5E6F7G8H",
"type": "order.in_progress",
"created_at": "2026-05-08T12:00:00Z",
"data": {
"order": {
"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. data.pending_actions[] lists each open action. |
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.
#!/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:
# 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 with exponential backoff. After the 5th failure the event is marked failed in the portal and stays in your audit log. Respond with any 2xx within 10 seconds to acknowledge.
The portal shows every attempt with the response status and timestamp.
Make your handler idempotent
Concurrent provisioning means events for the same resource can arrive out of order — you might see domain.connected before order.in_progress if a worker races, or get the same event twice if our retry catches a slow 2xx.
Two rules:
- Dedupe on
Peeker-Event-Id. Store seen IDs for at least 24 hours.
- 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.
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.