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:
| 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.suppressedis 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, thesuppressedevent appears only in the message's event timeline viaGET /v1/emails/:id/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).
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:
| 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.parseround-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:
| 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. 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: nulland 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.