Webhooks
Subscribe to delivery events, verify HMAC signatures, and handle retries and replays.
emitd POSTs an event to your endpoint at every stage of a message's life — each one HMAC-signed so you can trust it.
Subscribing
Create a webhook in the console or via POST /v1/webhooks with a URL and the
events you want. emitd returns a signing secret exactly once — store it.
Eleven event types are available:
| Group | Events |
|---|---|
| Delivery lifecycle | sent, delivery, delivery_delayed, failed |
| Reputation | bounce, complaint, suppressed |
| Engagement (when tracking is on) | open, click |
| Scheduled sends | scheduled, cancelled |
Full CRUD + replay: /v1/webhooks.
Event semantics: Webhook events.
Event payload
{
"type": "bounce",
"message_id": "msg_2h8Kd0Rk9Qa",
"ses_message_id": "0100018f...",
"recipients": ["ada@lovelace.io"],
"timestamp": 1783386190864
}Verifying the signature
Every delivery carries an X-Webhook-Signature: sha256=<hex> header — the
HMAC-SHA256 of the raw request body under your webhook secret. Compute the
same and compare in constant time before trusting the payload.
import crypto from "node:crypto";
export function verify(rawBody: Buffer, header: string, secret: string) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
// constant-time compare
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
// in your handler:
if (!verify(rawBody, req.headers["x-webhook-signature"], SECRET)) {
return new Response(null, { status: 401 });
}Sign the raw body
Verify against the exact bytes you received. If your framework re-parses and re-serialises the body first, the HMAC will never match.
Retries and replays
- Return a
2xxquickly (process asynchronously if your handler is slow). Non-2xx counts as a failure. - Failures retry with delays of 1 min, 5 min, 30 min, 2 hr, then every 8 hr — after 8 total attempts the delivery is marked failed.
- Every attempt is recorded;
POST /v1/webhooks/{id}/replayre-delivers an event on demand (useful after an outage on your side). - Endpoints can be limited per plan —
webhook_limit_reachedmeans you're at your cap.
Webhook creation requires an HTTPS URL (url_must_be_https).