After the send

Webhooks

Delivery is a later fact than acceptance. This is how you learn it.

Creating an endpoint

POST /v1/webhooks with a name, an HTTPS URL and the events you want. Subscribe to * for everything. The signing secret is returned once, on creation — store it then, because it cannot be read back.

The URL must resolve to the public internet. We check at creation and again at delivery, and refuse anything pointing at a private network.

What arrives

A delivery eventPOST /your/endpoint
webhook-id: 9c3b1f8e-4d21-4a77-9f0e-2b6c5d4a3e10
webhook-timestamp: 1788412862
webhook-signature: v1=6f2a…

{
  "id": "9c3b1f8e-4d21-4a77-9f0e-2b6c5d4a3e10",
  "type": "delivered",
  "created_at": "2026-09-03T09:41:02.118Z",
  "data": {
    "email_id": "0f9f8e7d-6c5b-4a39-8271-1a2b3c4d5e6f",
    "delivery_id": "1a2b3c4d-5e6f-4a39-8271-0f9f8e7d6c5b",
    "recipient": "you@example.com"
  }
}

Verifying it came from us

The signature is an HMAC-SHA256 over {id}.{timestamp}.{body}, hex-encoded and prefixed v1=, keyed with your endpoint secret. Verify against the raw body — parse it after, never before, because re-serializing changes the bytes.

Nodeimport { createHmac, timingSafeEqual } from "node:crypto";

export function verify(request, rawBody, secret) {
  const id = request.headers["webhook-id"];
  const timestamp = Number(request.headers["webhook-timestamp"]);
  const signature = request.headers["webhook-signature"];

  // Reject anything older than five minutes: a valid signature is still a replay.
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;

  const expected = "v1=" + createHmac("sha256", secret)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

Events

FieldTypeNotes
submittedemailHanded to the sending infrastructure.
deliveredemailAccepted by the receiving server.
delivery_delayedemailDeferred; we are still trying.
bouncedemailRefused. A permanent bounce also adds a suppression.
complainedemailMarked as spam by the recipient. Also suppressed.
rejectedemailWe refused to send it.
rendering_failedemailThe template could not be rendered with those variables.
openedemailOpen tracking fired.
clickedemailA tracked link was followed.
sms.sentsmsHanded to the carrier.
sms.deliveredsmsConfirmed by the carrier.
sms.failedsmsThe carrier refused or gave up.
sms.receivedsmsAn inbound message arrived on one of your numbers.

Delivery behaviour

  • Each event is delivered once per endpoint, keyed by its id — but design for a repeat anyway and key on id.
  • We wait ten seconds for a response. Anything outside 2xx is a failure and is retried.
  • Redirects are not followed. Point us at the final URL.
  • An endpoint that keeps failing is disabled, and the dashboard says so.