Webhooks
Get delivery, bounce, complaint, and received-mail 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, and whenever an email arrives at an address you receive on. Webhooks are the push alternative to polling GET /v1/emails/:id.
Add an endpoint
Add endpoints in the dashboard, as an owner, admin, or member. API keys can't manage webhooks: the API and the Node SDK's webhooks.* methods return 403 insufficient_scope.
- Open Webhooks and select Add webhook.
- Enter the Endpoint URL. It must be an
http(s)URL. - Under Events, tick the event types you want. Sent lists the events for mail you send, and Received lists
email.received. Pick at least one. You can tickemail.suppressedandemail.unsubscribed, but Mailfully never sends them (see Event types). - Select Add webhook.
- Copy the signing secret. The dialog shows it once, and neither the dashboard nor the API shows it again.
- Select Done.
Store the secret as MAILFULLY_WEBHOOK_SECRET in your handler's environment. For the request schema, see Create webhook.
Event types
Eight 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. |
email.received | An email arrived at a domain with receiving on, or at the hosted address. |
Soft bounces send no webhook. No event fires for the initial send. A send refused at submission ends failed with no email.failed webhook. email.suppressed and email.unsubscribed can be subscribed to but are not sent; read those from GET /v1/emails/:id/events.
To sync opt-outs, use the Blocklist page in the dashboard and its CSV export. See Marketing emails and unsubscribe.
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 the seven sending 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",
"recipients": ["ada@example.com"]
}
}
}
email.complained adds "complaint": { "feedbackType": "abuse", "recipients": ["ada@example.com"] }. The other five sending types carry the base fields only.
email.received has a different data shape, because it describes mail you received rather than a message you sent: email_id is the received email's inb_ id, there are no tags, and it adds cc, bcc, received_for, message_id, attachments, and the spam and virus verdicts. Its webhook-id header is the same inb_ id. See Receiving email for the full payload.
recipients names the addresses the notification is actually about, which is narrower than data.to on a message with several recipients. It covers cc and bcc as well, so an address can appear there that is absent from data.to. The array is empty when the provider named no recipient. In test mode the labeled simulator address in the delivery notification is resolved back to the recipient you originally requested, so a test payload has the same shape as a live one.
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.
Verify the raw body: the signature covers the exact bytes POSTed, so compute the HMAC over the unparsed body string, never a JSON.parse round-trip. Reject a timestamp more than 300 seconds old or in the future before doing any HMAC work.
The Standard Webhooks JavaScript library rejects most Mailfully secrets, because the part after whsec_ is base64url. Use the handler below.
Receive events
A complete Express handler:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";
const SECRET = process.env.MAILFULLY_WEBHOOK_SECRET;
if (!SECRET?.startsWith("whsec_")) {
throw new Error("Set MAILFULLY_WEBHOOK_SECRET to your whsec_ signing secret");
}
const TOLERANCE_SECONDS = 300;
function verifyWebhook(
secret: string,
headers: IncomingHttpHeaders,
rawBody: string,
): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signature = headers["webhook-signature"];
if (
typeof id !== "string" ||
typeof timestamp !== "string" ||
typeof signature !== "string"
) {
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);
});
}
// Event ids you have already handled. Use a database in production.
const processed = new Set<string>();
const app = express();
app.post(
"/hooks/mailfully",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
if (!verifyWebhook(SECRET, req.headers, rawBody)) {
res.sendStatus(400);
return;
}
const eventId = req.headers["webhook-id"] as string;
if (processed.has(eventId)) {
res.sendStatus(200); // already handled
return;
}
const event = JSON.parse(rawBody);
console.log(event.type, event.data.email_id);
// ...handle the event here
processed.add(eventId);
res.sendStatus(200);
},
);
app.listen(3000, () => console.log("Listening on :3000"));
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.
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; the handler above does.
Test it
Run the handler, expose it with a tunnel (see Local development), and add the tunnel URL as an endpoint with email.bounced ticked. Then send with an mf_test_ key to the bounce address from Test mode. Your handler receives email.bounced with the same fields a live bounce carries.
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. Each skip is recorded as an attempt with
status_code: nulland the next retry is scheduled on the normal backoff, so skips use up attempts from the 7-attempt schedule. - After a 60-second cooldown, one trial delivery is admitted. Success closes the breaker; failure re-opens it for another cooldown.
Dead letters
After the last failed attempt the endpoint is disabled and stops receiving all events, and the event goes to dead letters. Events that happen while an endpoint is disabled are not delivered later; read them from GET /v1/emails/:id/events.
Fix your handler, then replay the event from the dashboard: open Webhooks, select View on the endpoint, open the Dead letters tab, and select Replay. Replaying re-enables the endpoint and re-delivers the event immediately as a fresh attempt 1 with the full 7-attempt schedule.
Each dead letter replays once; a second replay returns 409. To list dead letters over the API, see GET /v1/webhook-dead-letters.
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
Add the tunnel's public URL as a temporary endpoint, then delete it when you are done.
Test and live events
Endpoints belong to your organization, so one endpoint receives matching events from both test and live sends. The envelope has no environment field, so tag your test sends and branch on data.tags in your handler.
