06 · MailMarco API
Webhooks
Delivery outcomes only exist as events. If you are not consuming webhooks, you do not know whether your mail arrived.
Delivery model
- Every message state change is written to an event outbox and fanned out to each enabled endpoint in your organization that subscribes to that type.
- Delivery is at-least-once. The dispatcher sends first and marks the event dispatched afterwards, so a crash in between re-sends. Deduplicate on the event id.
- Any
2xxresponse counts as success. Anything else — including a network error or a timeout — is a failure and is retried. - Your response body is ignored. Return quickly and process asynchronously.
Event types
These are the exact strings accepted in eventTypes when creating an endpoint, and the exact strings that arrive in the type field.
| Type | Fires when |
|---|---|
email.queued | The API accepted a send and persisted the message. |
email.scheduled | A message was accepted with a future scheduledAt. |
email.sent | The message was handed to the receiving mail server. |
email.delivered | The receiving server accepted it for the mailbox. |
email.delivery_delayed | A soft failure; the message is still being retried. |
email.bounced | A hard failure. The address is usually suppressed too. |
email.complained | A recipient marked the message as spam. |
email.opened | The tracking pixel was fetched (first time only). |
email.clicked | A tracked link was followed. |
email.failed | Delivery was abandoned. |
email.received | Inbound mail arrived on one of your inbound domains. See Inbound. |
domain.created | A sending domain was added. |
domain.verified | A domain transitioned into verified for the first time. |
domain.failed | A domain's verification did not pass. |
Payload and headers
POST /hooks/mailmarco HTTP/1.1
content-type: application/json
user-agent: MailForge-Webhooks/1
x-mailforge-event: email.delivered
x-mailforge-event-id: 4f6a1b2c-3d4e-4f5a-8b9c-0d1e2f3a4b5c
x-mailforge-delivery: 91a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8
x-mailforge-signature: t=1785317890,v1=7b1c9f0e8d2a5c4b6e3f1a0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b
{
"id": "4f6a1b2c-3d4e-4f5a-8b9c-0d1e2f3a4b5c",
"type": "email.delivered",
"created_at": "2026-07-29T09:22:12.907Z",
"data": {
"message_id": "b1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"provider_response": "250 2.0.0 OK"
}
}| Header | Contents |
|---|---|
x-mailforge-signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See below. |
x-mailforge-event-id | The event id. Equals id in the body. Use it as your idempotency key. |
x-mailforge-delivery | Unique per delivery attempt row. Differs between the original and a replay of the same event. |
x-mailforge-event | The event type, duplicated from the body. |
user-agent | MailForge-Webhooks/1 |
data always carries message_id (which is null on a test event), plus any event-specific fields — recipient_id for opens, recipient_id, url and link_id for clicks — and provider_response when the upstream server said something.
Verifying the signature
Compute an HMAC-SHA256 over three dot-joined parts, keyed with your endpoint secret:
signature = hex( HMAC_SHA256( secret, "<t>.<event id>.<raw request body>" ) )tis thet=value from the signature header, as a string. Not thecreated_attimestamp.- The event id is the
x-mailforge-event-idheader value. - The body is the raw bytes as received. Re-serialising the parsed JSON will change the bytes and break verification.
- Compare with a constant-time comparison, never with
===on the whole string.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SECRET = process.env.MAILMARCO_WEBHOOK_SECRET!;
// The raw body is required — parse it as a Buffer, not as JSON.
app.post("/hooks/mailmarco", express.raw({ type: "application/json" }), (req, res) => {
const header = String(req.header("x-mailforge-signature") ?? "");
const eventId = String(req.header("x-mailforge-event-id") ?? "");
const parts = new Map(header.split(",").map((p) => p.split("=") as [string, string]));
const t = parts.get("t");
const v1 = parts.get("v1");
if (!t || !v1 || !eventId) return res.status(400).end();
// Reject anything older than five minutes so a captured request cannot be replayed forever.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).end();
const expected = createHmac("sha256", SECRET)
.update(t + "." + eventId + "." + req.body.toString("utf8"))
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate: the same event id may arrive more than once.
void handle(event.id, event.type, event.data);
res.status(200).end();
});import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request): Promise<Response> {
const raw = await request.text(); // raw bytes, before any JSON parsing
const header = request.headers.get("x-mailforge-signature") ?? "";
const eventId = request.headers.get("x-mailforge-event-id") ?? "";
const t = /(?:^|,)t=([^,]+)/.exec(header)?.[1];
const v1 = /(?:^|,)v1=([^,]+)/.exec(header)?.[1];
if (!t || !v1 || !eventId) return new Response(null, { status: 400 });
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return new Response(null, { status: 400 });
const expected = createHmac("sha256", process.env.MAILMARCO_WEBHOOK_SECRET!)
.update(`${t}.${eventId}.${raw}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response(null, { status: 401 });
}
const event = JSON.parse(raw);
await handle(event.id, event.type, event.data);
return new Response(null, { status: 200 });
}import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, sig_header: str, event_id: str) -> bool:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or not event_id:
return False
if abs(time.time() - int(t)) > 300: # replay window
return False
signed = f"{t}.{event_id}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Retries and backoff
A non-2xx response, a connection error or a timeout schedules a retry with exponential backoff. Attempts are capped at 5.
| Attempt | Fires | Delivery status after a failure |
|---|---|---|
1 | Immediately | failed |
2 | 15 minutes later | failed |
3 | 30 minutes later | failed |
4 | 1 hour later | failed |
5 | 2 hours later | dead |
Cumulative window: a permanently broken endpoint stops being retried about 3¾ hours after the first attempt, and the delivery is parked as dead. There is no jitter, so a batch of failures retries in lockstep.
| Delivery status | Meaning |
|---|---|
pending | Row created, first attempt in flight. |
delivered | A 2xx was received; `deliveredAt` is set. |
failed | Retryable failure; `nextRetryAt` says when the next attempt is due. |
dead | Five attempts exhausted. Not retried again — replay it once you have fixed the endpoint. |
blocked | The endpoint URL resolved to a forbidden address. Permanent, never retried — see below. |
Replay and test deliveries
POST /v1/webhooks/:id/test sends a sample email.delivered event to one endpoint regardless of what it subscribes to. The payload is { "test": true, "message_id": null } and the event id is prefixed evt_test_.
POST /v1/webhooks/:id/replay/:deliveryId re-sends a past event as a new delivery row carrying the same event id and payload.
Secret rotation
POST /v1/webhooks/:id/rotate-secret generates a new whsec_<64 hex chars> secret and returns the plaintext once. Listings always show whsec_***; secrets are encrypted at rest and never readable again.
Endpoint URL restrictions
The endpoint URL is resolved and checked before every attempt, not only at creation. A URL whose host resolves into a private or infrastructure range is refused, the delivery is marked blocked, and it is never retried — an internal target will not become public on the next try.
| Rejected | Examples |
|---|---|
| Non-HTTP schemes | anything other than http: or https: |
| Loopback | 127.0.0.0/8, ::1 |
| Private IPv4 | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| Carrier-grade NAT | 100.64.0.0/10 |
| Link-local, including cloud metadata | 169.254.0.0/16 (so 169.254.169.254 is blocked), fe80::/10 |
| Unique-local IPv6 | fc00::/7 |
| Multicast and reserved | 224.0.0.0/4, 240.0.0.0/4, 192.0.0.0/24, 198.18.0.0/15 |
| IPv4-mapped IPv6 in either form | ::ffff:127.0.0.1 and the hex form ::ffff:7f00:1 |
| Hosts that do not resolve | no A or AAAA answer |
Every address a hostname resolves to is checked, so a public name with one private A record is refused. Plain http:// to a public host is permitted, but use HTTPS.
Endpoints
/v1/webhooksRegister an endpoint. The response is the only time you see the plaintext secret.
Body
urlstring (url)required | Must be a valid absolute URL; SSRF rules apply at delivery time. |
eventTypesstring[]required | At least one type from the table above. Unknown types are a 400. |
curl -X POST https://api.mailmarco.com/v1/webhooks \
-H "authorization: Bearer $MAILMARCO_API_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://example.com/hooks/mailmarco",
"eventTypes": ["email.delivered","email.bounced","email.complained"]
}'{
"id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"url": "https://example.com/hooks/mailmarco",
"enabled": true,
"eventTypes": ["email.delivered", "email.bounced", "email.complained"],
"secret": "whsec_1f2e3d4c5b6a79880f1e2d3c4b5a69788f9e0d1c2b3a49586f7e8d9c0b1a2938",
"createdAt": "2026-07-29T09:40:00.000Z"
}/v1/webhooksAll endpoints, newest first. Secrets are masked as whsec_***.
/v1/webhooks/{id}Deletes the endpoint and its delivery history.
Path parameters
idstring (uuid)required | Endpoint id. |
{ "deleted": true }Errors
404— No endpoint with that id.
/v1/webhooks/{id}/testSends a sample email.delivered event and returns the resulting delivery row.
Path parameters
idstring (uuid)required | Endpoint id. |
Errors
404— No endpoint with that id.
/v1/webhooks/{id}/rotate-secretIssues a new signing secret and returns the plaintext once.
Path parameters
idstring (uuid)required | Endpoint id. |
Errors
404— No endpoint with that id.
/v1/webhooks/{id}/replay/{deliveryId}Re-delivers a past event as a new delivery row with the same event id and payload.
Path parameters
idstring (uuid)required | Endpoint id. |
deliveryIdstring (uuid)required | The delivery to replay. |
Errors
404— No such delivery, or its endpoint no longer exists.
/v1/webhook-eventsThe 100 most recent delivery attempts across all endpoints, newest first.
[
{
"id": "91a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"endpointId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"eventId": "4f6a1b2c-3d4e-4f5a-8b9c-0d1e2f3a4b5c",
"eventType": "email.delivered",
"status": "delivered",
"attempts": 1,
"responseCode": 200,
"nextRetryAt": null,
"deliveredAt": "2026-07-29T09:22:13.204Z"
}
]Operational notes
- There is no paging on
/v1/webhook-events— it is a fixed window of 100 rows, not a durable log. Persist what you need. - Deleting an endpoint deletes its deliveries, so replay it before you remove it.
- Endpoints carry an
enabledflag, and a disabled endpoint is skipped for both new events and pending retries — but no route exposes toggling it today. To stop deliveries, delete the endpoint.