Skip to Content
Webhooks

Webhooks

Webhooks push email events to your application as they happen — no polling. Create endpoints in the dashboard, choose which event types each one receives, and Retransmit POSTs a signed JSON payload to your URL.

Event types

EventFired when
email.sentThe email was handed to the upstream provider.
email.deliveredThe recipient’s mail server accepted it.
email.delivery_delayedDelivery was temporarily deferred.
email.openedThe recipient opened the email.
email.clickedThe recipient clicked a link.
email.bouncedThe recipient’s server permanently rejected it.
email.complainedThe recipient marked it as spam.
email.rejectedThe email was rejected before sending.
email.failedThe provider send failed.

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 — for example email.failed includes { "message": "..." } with the provider error.

Delivery behavior

  • Requests are POST with Content-Type: application/json.
  • Your endpoint should respond with a 2xx within 10 seconds; anything else is recorded as a failed delivery.
  • Failed deliveries are retried up to 8 times with exponential backoff (roughly 10s, 20s, 40s … capped at 1 hour). Each retry is re-signed with a fresh timestamp. After all retries the delivery is dropped (dead-lettered).
  • Because of retries, deliveries can arrive more than once and out of order — key on the payload id for idempotency and on type/created_at for ordering.
  • Respond quickly and process asynchronously — do the real work after you’ve returned 200.
  • Every attempt (including failures) is visible per-endpoint in the dashboard.

Verifying signatures

Every delivery is signed so you can confirm it came from Retransmit. Two headers accompany each request:

HeaderValue
retransmit-timestampUnix timestamp (seconds) when the delivery was signed.
retransmit-signaturev1=<hex> — HMAC-SHA256 of `${timestamp}.${body}` using your endpoint’s secret (whsec_...).

The secret is shown once when you create the endpoint. Verify like this:

verify-webhook.ts
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):

webhook-handler.ts
app.post("/webhooks/retransmit", async (c) => { const body = await c.req.text(); // raw body — don't 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 }); });
Important

Compute the HMAC over the raw request body. If your framework parses and re-serializes JSON before you sign, key order or whitespace changes will make verification fail.

Tip

Reject deliveries whose retransmit-timestamp is more than a few minutes old to defend against replay attacks.

Last updated on