Skip to Content
sesameSend

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}/events lists deliveries. Their status is pending, attempting, success or failed.
  • GET .../events/{event_id}/attempts shows each attempt's HTTP status and response.
  • POST .../events/{event_id}/replay sends it again once. A replay never schedules retries.