Webhooks
Webhooks push email, SMS, and WhatsApp events to your application as they happen. Create an endpoint in the dashboard, pick the event types it receives, and Retransmit POSTs a signed JSON payload to your URL.
Event types
| Event | Fired when |
|---|---|
email.sent | The email was handed to the upstream provider. |
email.delivered | The recipient’s mail server accepted it. |
email.delivery_delayed | Delivery was temporarily deferred. |
email.opened | The recipient opened the email. |
email.clicked | The recipient clicked a link. |
email.bounced | The recipient’s server permanently rejected it. |
email.complained | The recipient marked it as spam. |
email.rejected | The email was rejected before sending. |
email.failed | The provider send failed. |
email.unsubscribed | The recipient unsubscribed from a marketing email. data.data.addresses lists the addresses. |
sms.sent | The SMS was handed to the upstream provider. |
sms.delivered | The carrier reported successful delivery. |
sms.undelivered | The carrier reported the SMS as undelivered, expired, or rejected. |
sms.failed | The provider send failed after retries, or no route was available. |
whatsapp.sent | WhatsApp accepted the message. |
whatsapp.delivered | The message reached the recipient’s device. |
whatsapp.read | The recipient opened the message. |
whatsapp.failed | The send failed after retries, or WhatsApp could not deliver it. |
whatsapp.received | A recipient sent you a message. See Receive messages. |
Payload
{
"id": "whd_xxxxxxxxxxxx",
"type": "email.delivered",
"created_at": "2026-09-01T12:00:03.000Z",
"data": {
"emailId": "em_xxxxxxxxxxxx",
"from": "Acme <hello@yourdomain.com>",
"to": ["user@example.com"],
"subject": "Your receipt",
"createdAt": "2026-09-01T12:00:00.000Z"
}
}Some events carry extra context in data.data. email.failed, sms.failed,
and whatsapp.failed include { "message": "..." } with the provider error.
WhatsApp events identify the message as data.messageId (wa_...).
Delivery behavior
- Requests are
POSTwithContent-Type: application/json. - Respond with a
2xxwithin 10 seconds. Anything else counts as a failed delivery. - Failed deliveries are retried up to 8 times with exponential backoff (10s, 20s, 40s, up to 1 hour). Each retry is re-signed with a fresh timestamp. After the last retry the delivery is dropped.
- Deliveries can arrive more than once and out of order. Use the payload
idfor idempotency andcreated_atfor ordering. - Return
200first, then do the work. - Every attempt, including failures, is visible per endpoint in the dashboard.
Verifying signatures
Every delivery is signed. Two headers accompany each request:
| Header | Value |
|---|---|
retransmit-timestamp | Unix timestamp (seconds) when the delivery was signed. |
retransmit-signature | v1=<hex>. HMAC-SHA256 of `${timestamp}.${body}` using your endpoint’s secret (whsec_...). |
The secret is shown once, when you create the endpoint or rotate its secret in the dashboard. Rotating signs every later delivery with the new secret.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhookSignature(
secret: string,
timestamp: string,
body: string, // the raw request body, exactly as received
signature: string, // the header value without the `v1=` prefix
): boolean {
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Example handler (Hono, but the shape is the same anywhere):
app.post("/webhooks/retransmit", async (c) => {
const body = await c.req.text(); // raw body, do not parse first
const timestamp = c.req.header("retransmit-timestamp") ?? "";
const signature = (c.req.header("retransmit-signature") ?? "").replace(/^v1=/, "");
if (!verifyWebhookSignature(process.env.RETRANSMIT_WEBHOOK_SECRET!, timestamp, body, signature)) {
return c.json({ error: "invalid signature" }, 401);
}
const event = JSON.parse(body);
// handle event.type / event.data ...
return c.json({ received: true });
});Compute the HMAC over the raw request body. Parsing and re-serializing JSON first can change key order or whitespace and break verification.
Reject deliveries whose retransmit-timestamp is more than a few minutes old
to prevent replays.