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.
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.