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.

Each workspace can have 2 active endpoints. Both receive their selected events independently; they are not a primary/fallback pair. Disable one to free a slot. Disabled endpoints retain their history.

Isolate staging and production. Endpoints receive all matching events for their workspace (both live sends and test/sandbox sends). Because suppression lists and quotas are also workspace-wide, staging and production should always be set up as separate workspaces. A hard bounce in a staging test will otherwise suppress that recipient in production.

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"
  }
}

The data.email_id field matches the id returned on the send receipt, while data.delivery_id identifies the per-recipient delivery record.

Every payload has delivery correlation: email_id, delivery_id, and recipient. bounced also carries bounce_type (Permanent or Transient), bounce_sub_type, and diagnostic_code. clicked carries url. Message tags passed at send time are forwarded to mail carrier tags and are not included in webhook payloads. One-click and browser unsubscribes dispatch unsubscribed (subscribe as unsubscribed or email.unsubscribed) and write a workspace suppression.

Sandbox sends generate real webhook POSTs with simulated event data. Address the send to bounce@example.com for a permanent bounce, transient@example.com (or ooto@) for a transient bounce that does not suppress, click@example.com for a delivered event followed by a click, complaint@example.com for a complaint, or unsubscribe@example.com for a delivered event followed by unsubscribed (no suppression is written). transactional@example.com is a delivered send for the transactional stream, which has no List-Unsubscribe headers. Both delivered and email.delivered subscription names match email delivery events. For compatibility, live email payloads use delivered and sandbox payloads use email.delivered; SMS types retain their sms. prefix.

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; delivery is being retried.
bouncedemailRefused by the receiving server. Includes bounce_type (Permanent / Transient), bounce_sub_type, and diagnostic_code. Permanent bounces automatically add a workspace 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. data.url is the destination.
unsubscribedemailThe recipient used one-click or the browser unsubscribe page. The address is suppressed for later sends.
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

  • Duplicate requests are possible, including after a timeout. Deduplicate 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.
  • After the initial attempt, failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, and 24 hours. These are delays between attempts, not times since the event. After seven failed attempts the delivery is marked dead and recorded for investigation. The endpoint is not automatically disabled.