Webhooks

Get delivery, bounce, and complaint events pushed to your endpoint as signed HTTP callbacks, with automatic retries and dead-letter replay.

Mailfully POSTs a JSON event to your endpoint whenever a message you sent changes state. Webhooks are the push alternative to polling GET /v1/emails/:id.

Create an endpoint

Register a URL and the event types you want:

curl -X POST https://api.mailfully.com/v1/webhooks \
  -H "Authorization: Bearer <session token>" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.acme.com/hooks/mailfully",
    "events": ["email.delivered", "email.bounced", "email.complained"]
  }'
{
  "object": "webhook",
  "id": "whk_01J1X6NAB3V9GVJ8H4Q2W7R5ZD",
  "url": "https://api.acme.com/hooks/mailfully",
  "events": ["email.delivered", "email.bounced", "email.complained"],
  "status": "enabled",
  "created_at": "2026-07-08T16:30:00.000Z",
  "signing_secret": "whsec_9hK3xQ7vW2sD5tG8hJ4kL6nB1cR0aE9uT2wQ5iYp3mZ"
}

Webhook management is a dashboard-session operation. The manage:webhooks scope cannot be minted onto API keys today, so every mf_ key gets 403 insufficient_scope on /v1/webhooks endpoints. This includes the Node SDK's webhooks.* methods. Create and manage endpoints in the dashboard, or call the API with a session token as shown here.

Store signing_secret immediately — it is returned only in this 201 response. List and get never include it.

The url must be an http(s) URL, and events must contain at least one valid event type. See Create webhook for the full request schema.

Event types

Seven event types are delivered to webhook endpoints:

EventFires when
email.deliveredThe recipient's mail server accepted the message.
email.bouncedThe message permanently bounced (hard bounce).
email.complainedThe recipient marked the message as spam.
email.failedThe send failed — the message was rejected or rendering failed.
email.delivery_delayedDelivery was temporarily delayed.
email.openedThe recipient opened the message.
email.clickedThe recipient clicked a link in the message.

What this table implies but does not say outright:

  • Transient (soft) bounces emit no webhook. Only permanent bounces produce email.bounced.
  • No event fires for the initial send. For a healthy message, the first webhook you receive is email.delivered.
  • email.suppressed is never delivered as a webhook. The create filter accepts it as a subscribable type, but no code path fires it today. When a send is stopped because its recipients are suppressed, the suppressed event appears only in the message's event timeline via GET /v1/emails/:id/events.

The event envelope

Every delivery is one JSON object with three top-level fields:

FieldTypeNotes
typestringThe event type, e.g. email.bounced.
created_atstring (ISO 8601)When the event occurred.
dataobjectMessage fields, plus type-specific extras.

The envelope has no top-level event id — the event id travels in the webhook-id header (see Handle duplicates).

data carries the same base fields for all seven types: email_id (the message id, a bare ULID), from, to (array), subject (null when the message had none), created_at (when the message was created, distinct from the top-level event timestamp), and tags ([{name, value}], [] when none; the same shape as GET /v1/emails/:id).

A typical email.delivered payload:

{
  "type": "email.delivered",
  "created_at": "2026-07-08T16:32:10.000Z",
  "data": {
    "email_id": "01J1X6P9T3KQ7ZW2V5R8YBAGMD",
    "from": "orders@mail.acme.com",
    "to": ["ada@example.com"],
    "subject": "Your order shipped",
    "created_at": "2026-07-08T16:31:58.000Z",
    "tags": [{ "name": "category", "value": "order-shipped" }]
  }
}

email.bounced adds a bounce object (each field null when the provider omitted it):

{
  "type": "email.bounced",
  "created_at": "2026-07-08T16:32:41.000Z",
  "data": {
    "email_id": "01J1X6P9T3KQ7ZW2V5R8YBAGMD",
    "from": "orders@mail.acme.com",
    "to": ["ada@example.com"],
    "subject": "Your order shipped",
    "created_at": "2026-07-08T16:31:58.000Z",
    "tags": [{ "name": "category", "value": "order-shipped" }],
    "bounce": {
      "type": "Permanent",
      "subType": "General",
      "message": "smtp; 550 5.1.1 user unknown"
    }
  }
}

email.complained adds "complaint": { "feedbackType": "abuse" }. The other five types carry the base fields only.

Verify signatures

Every delivery is signed following Standard Webhooks conventions. Three headers arrive on each request:

HeaderValue
webhook-idThe event id (bare ULID). Stable across retries.
webhook-timestampUnix timestamp in seconds.
webhook-signaturev1,<signature> — base64 HMAC-SHA256 of {id}.{timestamp}.{body}.

To derive the HMAC key, strip the whsec_ prefix from the secret and base64url-decode the remainder. The signed content is the webhook-id, the webhook-timestamp, and the raw request body, joined with dots.

Two rules matter in practice:

  • Verify the raw body. The signature covers the exact bytes POSTed. Compute the HMAC over the unparsed body string, never a JSON.parse round-trip.
  • Enforce the timestamp tolerance. Reject timestamps more than 300 seconds old or in the future before doing any HMAC work.
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

function verifyWebhook(
  secret: string,
  headers: Record<string, string | undefined>,
  rawBody: string,
): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];
  if (!id || !timestamp || !signature) return false;

  // Reject stale or future-dated timestamps
  const now = Math.floor(Date.now() / 1000);
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(now - ts) > TOLERANCE_SECONDS) return false;

  // Key: base64url-decode the secret after stripping the whsec_ prefix
  const key = Buffer.from(secret.slice("whsec_".length), "base64url");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");

  // webhook-signature is a space-separated list of "version,signature" tokens
  return signature.split(" ").some((token) => {
    const [version, sig] = token.split(",");
    if (version !== "v1" || !sig) return false;
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

Respond with any 2xx status as soon as you have verified and stored the event. Anything else (a non-2xx status, a timeout, a connection error) counts as a failed attempt and triggers a retry.

Retries

A failed attempt is retried up to 6 times, 7 delivery attempts in total:

AttemptDelay after the previous failure
1Immediate
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours

Each delay gets up to 20% one-sided jitter added, so a retry never fires early but may land up to 20% late. Before jitter, the full schedule spans roughly 18 hours.

Inspect an endpoint's attempt history (attempt number, status code, next retry time) with GET /v1/webhooks/:id/deliveries. A status_code of null means the attempt never got an HTTP response: a transport error, a timeout, or a circuit-breaker skip.

Circuit breaker

Each endpoint has its own circuit breaker:

  • 5 consecutive failures open it. Any successful delivery resets the count.
  • While open, Mailfully skips the POST entirely. Each skip is recorded as an attempt with status_code: null and the next retry is scheduled on the normal backoff, so skips still consume attempts from the 7-attempt schedule and can be the exhausting one.
  • After a 60-second cooldown, exactly one trial delivery is admitted. Success closes the breaker; failure re-opens it for another cooldown.

Dead letters

When attempt 7 fails, two things happen: the endpoint is auto-disabled (its status flips to disabled and it stops receiving all events), and the undelivered event is written to your dead-letter list.

List dead letters with GET /v1/webhook-dead-letters, optionally filtered to one endpoint:

curl "https://api.mailfully.com/v1/webhook-dead-letters?webhook_id=whk_01J1X6NAB3V9GVJ8H4Q2W7R5ZD" \
  -H "Authorization: Bearer <session token>"
{
  "data": [
    {
      "id": "01J1X8C2R7VW5N4YQ9K3EHDMBZ",
      "webhook_id": "whk_01J1X6NAB3V9GVJ8H4Q2W7R5ZD",
      "event_id": "01J1X79F0S6D8PGA3QK5WYVMZT",
      "event_type": "email.bounced",
      "last_status_code": 500,
      "attempts": 7,
      "dead_lettered_at": "2026-07-09T10:22:41.000Z",
      "replayed_at": null
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Once your handler is fixed, replay the dead letter. Replay re-enables the endpoint and re-delivers the event immediately as a fresh attempt 1 with the full 7-attempt schedule:

curl -X POST https://api.mailfully.com/v1/webhook-dead-letters/01J1X8C2R7VW5N4YQ9K3EHDMBZ/replay \
  -H "Authorization: Bearer <session token>"
{
  "id": "01J1X8C2R7VW5N4YQ9K3EHDMBZ",
  "replayed_at": "2026-07-09T11:03:27.000Z"
}

Each dead letter can be replayed once. A second replay returns 409 conflict and enqueues nothing.

Handle duplicates

Delivery is at-least-once: the same event can reach your endpoint more than once, and every retry of an event carries the same webhook-id header. Use webhook-id as your idempotency key. Record processed ids and skip any id you have already seen:

const eventId = req.headers["webhook-id"];
if (await store.hasProcessed(eventId)) {
  return res.status(200).end(); // already handled — acknowledge and move on
}
await store.markProcessed(eventId);
// ...process the event

Local development

Webhook URLs must be reachable over http(s), so expose your local server through a tunnel:

cloudflared tunnel --url http://localhost:3000
# or: ngrok http 3000

Register the tunnel's public URL as a temporary endpoint, then delete it when you are done.

Test and live events

Endpoints are org-scoped, not environment-scoped: one endpoint receives matching events from both test and live sends. The envelope carries no environment field. If you need to tell the two apart, put a tag on your test sends and branch on data.tags in your handler.