Webhooks
Get an HTTPS request when a message is sent, delivered, bounces or is complained about.
- Create an endpoint in the dashboard or with POST /v1/webhooks. The signing secret is shown once.
- Verify the signature on every request (below).
- Respond with a
2xxquickly. Do slow work after responding.
Events
| Event | Meaning |
|---|---|
email.sent | The sending provider accepted the message. |
email.delivered | The recipient’s mail server accepted it. This is not inbox placement. |
email.bounced | The recipient’s server rejected it. Permanent bounces suppress the address. |
email.complained | The recipient marked it as spam. The address is suppressed. |
email.failed | We could not send it. |
email.opened | The recipient opened it. Only if open tracking is on for the domain. |
email.clicked | The recipient clicked a link. Only if click tracking is on for the domain. |
email.delivery_delayed | The recipient’s server is slow to accept it. The provider keeps trying; no action needed unless it turns into a bounce. |
email.scheduled | An email was scheduled to be sent later. |
email.suppressed | A recipient was skipped because the address is on your suppression list. |
email.received | Mail arrived at your receiving address. Existing endpoints must add this event to receive it. |
Test projects produce events too, with "simulated": true.
Payload
{
"id": "evt_...",
"object": "event",
"type": "email.bounced",
"created_at": "2026-10-06T15:04:07.000Z",
"simulated": false,
"data": {
"email_id": "msg_...",
"request_id": "req_...",
"environment": "live",
"from": "Acme <hello@mail.acme.com>",
"subject": "Welcome to Acme",
"tags": { "application_reference": "signup_123" },
"recipient": "a@example.org",
"details": { "bounce_type": "Permanent" }
}
}Message bodies and template variables are never included.
Verify the signature
Each request has SendSend-Signature: t=<unix>,v1=<hex>, plus SendSend-Event-Id and SendSend-Delivery-Attempt. The signature is HMAC-SHA256 of t.rawBody with your secret. Use the raw request body, not a re-serialized one. Reject requests with an old timestamp (we use five minutes) to stop replays.
const crypto = require("crypto");
// rawBody must be the exact request body string, before any JSON parsing.
function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(",").map((p) => p.trim().split("="));
const t = Number(parts.find((p) => p[0] === "t")?.[1]);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
);
return parts
.filter((p) => p[0] === "v1")
.some((p) => {
const got = Buffer.from(p[1] || "");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
// Express: app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyWebhook(req.body.toString(), req.get("SendSend-Signature"), process.env.SENDSEND_WEBHOOK_SECRET)) return res.sendStatus(400);
// const event = JSON.parse(req.body); // deduplicate on event.id
// res.sendStatus(200);
// });Retries and duplicates
- Any non-2xx response, a timeout (10 seconds) or a redirect counts as failed. Redirects are not followed.
- Failed deliveries retry with growing delays, up to 8 attempts.
- Delivery is at-least-once. Deduplicate on the event
id. - You can replay a delivery. It re-sends the same event, never a new one.
Rotating the secret
Rotate issues a new secret. The old one keeps signing for 24 hours, so requests carry two signatures and you can switch with no downtime.
Endpoint rules
Endpoints must be https and must not resolve to a private network address.