Webhooks

Webhooks

Receive signed notifications for supported delivery and unsubscribe events.

SNDR delivers signed webhook events for delivery outcomes and unsubscribes. Deliveries are retried; deduplicate processing by the event id.

Events

  • email.delivered
  • email.bounced
  • email.failed
  • email.unsubscribed

email.bounced indicates a confirmed permanent recipient-address failure. Infrastructure, policy, and signing failures emit email.failed; subscribe to both events to distinguish them.

Verifying signatures

Every payload is signed with HMAC-SHA256 using the secret of the endpoint that received it. The signature is included in the X-Sndr-Signature header.

The header is t=<unix-seconds>,v1=<hex-signature>. The signed input is the timestamp, a dot, and the exact raw request body. During rotation, additional v1s / v1s2 signatures allow verification with the staged secret. Do not use secret_prefix as the signing secret.

js
import { createHmac, timingSafeEqual } from 'node:crypto';

// Pass the exact raw request body, before JSON parsing.
function verifyWebhook(rawBody, header, secrets) {
  const parts = header.split(',').map((part) => {
    const i = part.indexOf('=');
    return [part.slice(0, i).trim(), part.slice(i + 1).trim()];
  });
  const timestamp = parts.find(([key]) => key === 't')?.[1];
  if (!timestamp || !/^[0-9]+$/.test(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  return secrets.filter(Boolean).some((secret) => {
    const expected = createHmac('sha256', secret)
      .update(timestamp + '.').update(rawBody).digest();
    return parts.some(([key, signature]) =>
      /^v1(?:s[0-9]*)?$/.test(key) && /^[a-f0-9]{64}$/i.test(signature) &&
      timingSafeEqual(expected, Buffer.from(signature, 'hex'))
    );
  });
}

const valid = verifyWebhook(
  rawRequestBody,
  request.headers.get('x-sndr-signature') ?? '',
  [process.env.SNDR_WEBHOOK_SECRET, process.env.SNDR_WEBHOOK_NEXT_SECRET],
);
if (!valid) return new Response('Invalid signature', { status: 401 });

Get the full secret

Creating an endpoint in Dashboard → Webhooks displays the full secret once, with a Copy secret button. POST /v1/webhooks returns it in the secret field. The list response exposes only secret_prefix. Save the full secret securely; it cannot be retrieved later.

Rotate without deleting the endpoint

  • Open Signing keys for the endpoint and choose Generate replacement secret.
  • Save the new full secret and update your receiver to accept both old and new secrets during staging. The existing primary keeps signing until promotion.
  • Choose Make primary only after your receiver is ready. The old primary stops signing new deliveries.
  • If you lost a staged secret, discard that staged key and generate another. No extra webhook endpoint or plan slot is needed.

API equivalents: GET /v1/webhooks/{id}/signing-keys; POST /v1/webhooks/{id}/signing-keys/stage; POST /v1/webhooks/{id}/signing-keys/{key_id}/promote; POST /v1/webhooks/{id}/signing-keys/{key_id}/retire. Read operations require read scope; mutations require send scope.