Webhooks and signing
Receive signed events, verify them, and understand retries and replay.
Create an endpoint
curl -X POST "$SESAMESEND_URL/webhooks" \
-H "Authorization: Bearer $OPENSEND_API_KEY" -H "Content-Type: application/json" \
-d '{"endpoint":"https://example.com/hooks/sesamesend","events":["email.delivered","email.bounced"]}'The endpoint must be an https URL. The response includes a signing_secret starting with whsec_. Store it. You can rotate it with POST /webhooks/{webhook_id}/signing-secret/rotate. Set status to disabled with PATCH to pause an endpoint.
Event types
- Email:
email.sent,email.scheduled,email.delivered,email.delivery_delayed,email.complained,email.bounced,email.opened,email.clicked,email.received,email.failed,email.suppressed - Contacts:
contact.created,contact.updated,contact.deleted,contact.topics.updated - Domains:
domain.created,domain.updated,domain.deleted - Suppressions:
suppression.added,suppression.removed - Topics:
topic.created,topic.updated,topic.deleted - Inboxes:
inbox.created,inbox.updated,inbox.deleted,inbox.email.received,inbox.email.sent,inbox.draft.created,inbox.draft.updated,inbox.draft.deleted,inbox.draft.sent,inbox.thread.created,inbox.thread.assigned,inbox.thread.unassigned,inbox.thread.labels.updated,inbox.thread.folder.updated
Verify the signature
Each delivery carries three headers in the Svix scheme: svix-id, svix-timestamp and svix-signature. The signature is v1, followed by the base64 of an HMAC-SHA256 over "<svix-id>.<svix-timestamp>.<raw body>". The key is the base64-decoded part of the secret after whsec_. The header can hold several space-separated signatures.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, headers: Headers, rawBody: string) {
const id = headers.get("svix-id")!, ts = headers.get("svix-timestamp")!;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // reject stale replays
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
return headers.get("svix-signature")!.split(" ").some((s) => {
const [, sig = ""] = s.split(",");
return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
}Verify against the raw body, before you parse it.
Retries
Return any 2xx to acknowledge. Any other result is a failed attempt. Retries wait 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, then 10 hours twice, for 8 attempts in total. The delivery worker runs from /internal/webhooks/deliver, so it only retries while your background jobs run.
Inspect and replay
GET /webhooks/{webhook_id}/eventslists deliveries. Their status ispending,attempting,successorfailed.GET .../events/{event_id}/attemptsshows each attempt's HTTP status and response.POST .../events/{event_id}/replaysends it again once. A replay never schedules retries.