Receive webhooks
Create an endpoint, verify a signature, and confirm it works — end to end in about ten minutes.
By the end of this you will have an endpoint receiving signed email events, and you will have proved the signature check works before any real mail depends on it.
You need an admin-scope API key. See Webhooks for
the full event list and delivery guarantees.
1. Write the handler first
Create it before registering the endpoint — registration is easier to verify against something that already answers.
// app/api/webhooks/sendandretain/route.ts
import { Webhook } from "standardwebhooks";
export async function POST(req: Request) {
// RAW body. `await req.json()` then re-stringifying changes the bytes and the
// signature will never match.
const body = await req.text();
let event;
try {
const wh = new Webhook(process.env.SENDANDRETAIN_WEBHOOK_SECRET!);
event = wh.verify(body, Object.fromEntries(req.headers)) as {
id: string;
type: string;
occurred_at: string;
data: { message: { id: string; to: string } | null };
};
} catch {
return new Response("bad signature", { status: 400 });
}
// Dedupe: `id` is stable across retries, and duplicates are expected.
if (await alreadyProcessed(event.id)) return new Response(null, { status: 204 });
// Return fast. Anything slower than 10s reads as a failure and is retried.
await enqueue(event);
await markProcessed(event.id);
return new Response(null, { status: 204 });
}Deploy it. For local development, expose it with a tunnel — private addresses are refused.
2. Register the endpoint
curl -X POST https://sendandretain.com/api/v1/webhooks \
-H "Authorization: Bearer $SENDANDRETAIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/api/webhooks/sendandretain",
"event_types": ["email.delivered", "email.bounced", "email.complained", "webhook.test"],
"description": "CRM sync"
}'{
"id": "whe_9f2a1c",
"url": "https://yourapp.com/api/webhooks/sendandretain",
"event_types": ["email.delivered", "email.bounced", "email.complained", "webhook.test"],
"enabled": true,
"secret": "whsec_MfKQ9r8sTn3xW1yZ4bC7dE0fG2hJ5kL8"
}Copy secret now. It appears in this response and nowhere else — not in a
GET, not in the list. Lost it? Rotate rather than hunt for it.
Set it as SENDANDRETAIN_WEBHOOK_SECRET and redeploy.
3. Prove it works
curl -X POST https://sendandretain.com/api/v1/webhooks/whe_9f2a1c/test \
-H "Authorization: Bearer $SENDANDRETAIN_API_KEY"That queues a synthetic webhook.test event through the real path — same
signing, same retries, same log. Then read the outcome:
curl "https://sendandretain.com/api/v1/webhooks/whe_9f2a1c/deliveries?limit=1" \
-H "Authorization: Bearer $SENDANDRETAIN_API_KEY"{
"data": [
{
"id": "whd_44b1e0",
"event_type": "webhook.test",
"status": "delivered",
"attempt": 0,
"last_status_code": 204,
"duration_ms": 84
}
],
"has_more": false
}status: "delivered" means your signature check passed. If it did not,
response_snippet carries your own error body — which is almost always the
answer:
| Symptom | Cause |
|---|---|
400 bad signature | Verifying re-serialised JSON instead of the raw body |
status: "skipped" | Not subscribed to webhook.test, or the endpoint is disabled |
last_status_code: null | We never got a response — timeout, DNS, or TLS |
302 recorded as a failure | Redirects are not followed; use the final URL |
4. Send a real email
curl -X POST https://sendandretain.com/api/v1/emails \
-H "Authorization: Bearer $SENDANDRETAIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to": "[email protected]", "template": "welcome", "props": {"name": "Sam"}}'You should see email.sent almost immediately, then email.delivered once the
receiving server accepts it. They may arrive in either order — sort on
occurred_at, never on arrival.
Rotating the secret
curl -X POST https://sendandretain.com/api/v1/webhooks/whe_9f2a1c/rotate-secret \
-H "Authorization: Bearer $SENDANDRETAIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"grace_minutes": 1440}'Both secrets sign every delivery until the window closes, so deploy the new one whenever suits you. Nothing breaks in between.
Retiring an endpoint
Return 410 Gone and we stop immediately, or DELETE /api/v1/webhooks/{id}.
To pause without losing the endpoint or its secret, PATCH it with
{"enabled": false}.