Webhooks
Receive delivery and domain events, verify webhook signatures, handle retries.
Set up an endpoint
Add endpoints in Dashboard → Webhooks: an https URL (plain http is accepted but not recommended), the event types to receive (none selected = all events) and an optional description. URLs that resolve to private or internal addresses are refused. The number of endpoints depends on your plan. Each endpoint has its own signing secret (whsec_…), visible to workspace owners and admins; use Send test event to try your handler.
Event types
| Event | When |
|---|---|
email.sent | The email was handed to our sending server. |
email.delivered | The recipient's mail server accepted the email (see Regions & deliverability). |
email.delivery_delayed | A temporary failure or deferral; delivery is being retried. |
email.bounced | The recipient's server permanently rejected the email. The address is suppressed. |
email.complained | The recipient reported the email as spam. The address is suppressed. |
email.failed | The email could not be sent (e.g. all recipients suppressed, sending error). |
domain.created | A domain was added to the workspace. |
domain.updated | A domain changed (verification status, return path, region). |
domain.deleted | A domain was removed. |
email.opened and email.clicked can be selected for Resend compatibility, but Bytesms does not track opens or clicks yet, so they are never sent.
Payloads
Each request is a POST with a JSON body { type, created_at, data }. For email events, data describes the email plus an event-specific object:
{
"type": "email.bounced",
"created_at": "2026-09-28T08:15:06.901Z",
"data": {
"email_id": "cmg4k2x7a0001l8v9h3q2d5rt",
"created_at": "2026-09-28T08:15:02.114Z",
"from": "Acme <[email protected]>",
"to": [
"[email protected]"
],
"subject": "Your order has shipped",
"tags": {
"category": "shipping"
},
"bounce": {
"type": "Permanent",
"subType": "5.1.1",
"message": "550 5.1.1 User unknown",
"recipients": [
"[email protected]"
]
}
}
}| Event | Extra field in data |
|---|---|
email.bounced | bounce: { type, subType, message, recipients? } |
email.complained | complaint: { type, recipients? } |
email.delivery_delayed | delay: { type, message, dsn, recipients? } |
email.failed | failed: { reason } |
Optional fields: broadcast_id (broadcast emails), cc/bcc (when set) and tags (as a { name: value } object). Domain events carry an event id and the full domain object (as returned by retrieve a domain, without object):
{
"type": "domain.updated",
"id": "evt_4f1c2a9b7e3d5c8a1b6f0e2d",
"created_at": "2026-09-28T08:20:00.000Z",
"data": {
"id": "cmg4jz1pb0000l8v9a6c3e2kf",
"name": "example.com",
"status": "verified",
"region": "ap-southeast-7",
"created_at": "2026-09-28T08:00:00.000Z",
"custom_return_path": "send",
"records": [
"…"
]
}
}Verify signatures
Every request carries these headers, all computed with the endpoint's secret:
| Header | Value |
|---|---|
bytesms-signature | t=<unix seconds>,v1=<hex> — HMAC-SHA256 of <t>.<raw body>, keyed with the whole secret string. |
svix-id | Delivery id — identical on every retry of the same delivery. Use it to deduplicate. |
svix-timestamp | Unix seconds. |
svix-signature | Svix / Standard Webhooks signature: v1,<base64>. |
webhook-id / webhook-timestamp / webhook-signature | The same values under the Standard Webhooks names. |
webhook-event-id | Id of the underlying event. |
Option 1 — bytesms-signature
Compute the HMAC over the raw request body (not re-serialized JSON), compare in constant time, and reject timestamps more than 5 minutes away from your clock:
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.BYTESMS_WEBHOOK_SECRET; // "whsec_…", used as-is
// Verify against the raw bytes — parse JSON only after the signature checks out.
app.post("/webhooks/bytesms", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("bytesms-signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send("stale or missing timestamp");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate on the svix-id header: it is the same on every retry of a delivery.
console.log(req.get("svix-id"), event.type, event.data);
res.sendStatus(200);
});
app.listen(3000);Option 2 — Svix / Resend libraries
The svix-* headers follow the Svix scheme, so the svix packages (and Resend SDK webhook helpers) verify Bytesms webhooks with the same whsec_ secret:
import { Webhook } from "svix";
const wh = new Webhook(process.env.BYTESMS_WEBHOOK_SECRET);
// Throws if the signature or timestamp is invalid.
const event = wh.verify(rawBody, {
"svix-id": req.headers["svix-id"],
"svix-timestamp": req.headers["svix-timestamp"],
"svix-signature": req.headers["svix-signature"],
});Older secrets
whsec_. If an older secret contains - or _, replace them with + and / before passing it to a Svix library (same key). The bytesms-signature check uses the secret unchanged.Delivery and retries
- Respond with any
2xxwithin 10 seconds. Redirects are not followed; other statuses and timeouts are failures. - Failed deliveries are retried with exponential backoff — the retries follow after about 10 s, 30 s, 2 min, 10 min, 30 min, 2 h and 6 h (plus jitter), 8 attempts in total. After that the delivery is marked dead.
- An endpoint that fails 20 times in a row is disabled automatically; re-enable it in the dashboard.
- Deliveries may arrive out of order or more than once: order by
created_atand deduplicate onsvix-id. - The dashboard shows every delivery attempt with the response status and body, and lets you retry manually.
POST /webhooks/bytesms HTTP/1.1 content-type: application/json bytesms-signature: t=1790583306,v1=5f8d0c… svix-id: cmg4r1a2b0009l8v9d4e6f8gh svix-timestamp: 1790583306 svix-signature: v1,K6yM3n… webhook-id: cmg4r1a2b0009l8v9d4e6f8gh webhook-timestamp: 1790583306 webhook-signature: v1,K6yM3n… webhook-event-id: evt_cmg4r0z9y0008l8v9s2t4u6vw
