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

EventWhen
email.sentThe email was handed to our sending server.
email.deliveredThe recipient's mail server accepted the email (see Regions & deliverability).
email.delivery_delayedA temporary failure or deferral; delivery is being retried.
email.bouncedThe recipient's server permanently rejected the email. The address is suppressed.
email.complainedThe recipient reported the email as spam. The address is suppressed.
email.failedThe email could not be sent (e.g. all recipients suppressed, sending error).
domain.createdA domain was added to the workspace.
domain.updatedA domain changed (verification status, return path, region).
domain.deletedA 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:

email.bounced
{
  "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]"
      ]
    }
  }
}
EventExtra field in data
email.bouncedbounce: { type, subType, message, recipients? }
email.complainedcomplaint: { type, recipients? }
email.delivery_delayeddelay: { type, message, dsn, recipients? }
email.failedfailed: { 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):

domain.updated
{
  "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:

HeaderValue
bytesms-signaturet=<unix seconds>,v1=<hex> — HMAC-SHA256 of <t>.<raw body>, keyed with the whole secret string.
svix-idDelivery id — identical on every retry of the same delivery. Use it to deduplicate.
svix-timestampUnix seconds.
svix-signatureSvix / Standard Webhooks signature: v1,<base64>.
webhook-id / webhook-timestamp / webhook-signatureThe same values under the Standard Webhooks names.
webhook-event-idId 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

Svix libraries expect standard base64 after 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 2xx within 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_at and deduplicate on svix-id.
  • The dashboard shows every delivery attempt with the response status and body, and lets you retry manually.
Request headers (example)
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