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

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

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`](/api-reference/emails/get).

## Create an endpoint

Register a URL and the event types you want:

```bash
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"]
  }'
```

```json
{
  "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"
}
```

<Note>
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](https://dashboard.mailfully.com), or call the API with a session token as shown here.
</Note>

Store `signing_secret` immediately — it is returned only in this `201` response. [List](/api-reference/webhooks/list) and [get](/api-reference/webhooks/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](/api-reference/webhooks/create) for the full request schema.

## Event types

Seven event types are delivered to webhook endpoints:

| Event | Fires when |
|---|---|
| `email.delivered` | The recipient's mail server accepted the message. |
| `email.bounced` | The message permanently bounced (hard bounce). |
| `email.complained` | The recipient marked the message as spam. |
| `email.failed` | The send failed — the message was rejected or rendering failed. |
| `email.delivery_delayed` | Delivery was temporarily delayed. |
| `email.opened` | The recipient opened the message. |
| `email.clicked` | The 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](/guides/suppressions), the `suppressed` event appears only in the message's event timeline via [`GET /v1/emails/:id/events`](/api-reference/emails/list-events).

## The event envelope

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

| Field | Type | Notes |
|---|---|---|
| `type` | string | The event type, e.g. `email.bounced`. |
| `created_at` | string (ISO 8601) | When the event occurred. |
| `data` | object | Message 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](#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`](/api-reference/emails/get)).

A typical `email.delivered` payload:

```json
{
  "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):

```json
{
  "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](https://www.standardwebhooks.com) conventions. Three headers arrive on each request:

| Header | Value |
|---|---|
| `webhook-id` | The event id (bare ULID). Stable across retries. |
| `webhook-timestamp` | Unix timestamp in seconds. |
| `webhook-signature` | `v1,<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.

<Tabs>
<Tab title="Manual (node:crypto)">

```typescript
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);
  });
}
```

</Tab>
<Tab title="standard-webhooks library">

The scheme follows Standard Webhooks conventions, but the secret after `whsec_` is base64url-encoded and some libraries expect plain base64. If your library rejects the secret or signatures never match, verify manually as shown in the other tab.

```typescript
import { Webhook } from "standard-webhooks";

const wh = new Webhook(process.env.MAILFULLY_WEBHOOK_SECRET);

// In your handler. rawBody must be the unparsed request body string.
try {
  wh.verify(rawBody, {
    "webhook-id": req.headers["webhook-id"],
    "webhook-timestamp": req.headers["webhook-timestamp"],
    "webhook-signature": req.headers["webhook-signature"],
  });
} catch {
  return res.status(400).end(); // invalid signature — reject
}

const event = JSON.parse(rawBody);
```

</Tab>
</Tabs>

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:

| Attempt | Delay after the previous failure |
|---|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 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`](/api-reference/webhooks/list-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`](/api-reference/webhooks/list-dead-letters), optionally filtered to one endpoint:

```bash
curl "https://api.mailfully.com/v1/webhook-dead-letters?webhook_id=whk_01J1X6NAB3V9GVJ8H4Q2W7R5ZD" \
  -H "Authorization: Bearer <session token>"
```

```json
{
  "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](/api-reference/webhooks/replay-dead-letter) 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:

```bash
curl -X POST https://api.mailfully.com/v1/webhook-dead-letters/01J1X8C2R7VW5N4YQ9K3EHDMBZ/replay \
  -H "Authorization: Bearer <session token>"
```

```json
{
  "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:

```typescript
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:

```bash
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.
