Skip to content
MailMarcodocs

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 2xx response 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.

TypeFires when
email.queuedThe API accepted a send and persisted the message.
email.scheduledA message was accepted with a future scheduledAt.
email.sentThe message was handed to the receiving mail server.
email.deliveredThe receiving server accepted it for the mailbox.
email.delivery_delayedA soft failure; the message is still being retried.
email.bouncedA hard failure. The address is usually suppressed too.
email.complainedA recipient marked the message as spam.
email.openedThe tracking pixel was fetched (first time only).
email.clickedA tracked link was followed.
email.failedDelivery was abandoned.
email.receivedInbound mail arrived on one of your inbound domains. See Inbound.
domain.createdA sending domain was added.
domain.verifiedA domain transitioned into verified for the first time.
domain.failedA domain's verification did not pass.

Payload and headers

request from MailMarco
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"
  }
}
HeaderContents
x-mailforge-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. See below.
x-mailforge-event-idThe event id. Equals id in the body. Use it as your idempotency key.
x-mailforge-deliveryUnique per delivery attempt row. Differs between the original and a replay of the same event.
x-mailforge-eventThe event type, duplicated from the body.
user-agentMailForge-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:

signed payload
signature = hex( HMAC_SHA256( secret, "<t>.<event id>.<raw request body>" ) )
  • t is the t= value from the signature header, as a string. Not the created_at timestamp.
  • The event id is the x-mailforge-event-id header 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.
node (express)
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();
});

Retries and backoff

A non-2xx response, a connection error or a timeout schedules a retry with exponential backoff. Attempts are capped at 5.

AttemptFiresDelivery status after a failure
1Immediatelyfailed
215 minutes laterfailed
330 minutes laterfailed
41 hour laterfailed
52 hours laterdead

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 statusMeaning
pendingRow created, first attempt in flight.
deliveredA 2xx was received; `deliveredAt` is set.
failedRetryable failure; `nextRetryAt` says when the next attempt is due.
deadFive attempts exhausted. Not retried again — replay it once you have fixed the endpoint.
blockedThe 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.

RejectedExamples
Non-HTTP schemesanything other than http: or https:
Loopback127.0.0.0/8, ::1
Private IPv410.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Carrier-grade NAT100.64.0.0/10
Link-local, including cloud metadata169.254.0.0/16 (so 169.254.169.254 is blocked), fe80::/10
Unique-local IPv6fc00::/7
Multicast and reserved224.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 resolveno 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

POST/v1/webhooks
auth: API key201 on success

Register an endpoint. The response is the only time you see the plaintext secret.

Body

url
string (url)required
Must be a valid absolute URL; SSRF rules apply at delivery time.
eventTypes
string[]required
At least one type from the table above. Unknown types are a 400.
request
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"]
  }'
response · 201
{
  "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"
}
GET/v1/webhooks
auth: API key200 on success

All endpoints, newest first. Secrets are masked as whsec_***.

DELETE/v1/webhooks/{id}
auth: API key200 on success

Deletes the endpoint and its delivery history.

Path parameters

id
string (uuid)required
Endpoint id.
response · 200
{ "deleted": true }

Errors

  • 404No endpoint with that id.
POST/v1/webhooks/{id}/test
auth: API key201 on success

Sends a sample email.delivered event and returns the resulting delivery row.

Path parameters

id
string (uuid)required
Endpoint id.

Errors

  • 404No endpoint with that id.
POST/v1/webhooks/{id}/rotate-secret
auth: API key201 on success

Issues a new signing secret and returns the plaintext once.

Path parameters

id
string (uuid)required
Endpoint id.

Errors

  • 404No endpoint with that id.
POST/v1/webhooks/{id}/replay/{deliveryId}
auth: API key201 on success

Re-delivers a past event as a new delivery row with the same event id and payload.

Path parameters

id
string (uuid)required
Endpoint id.
deliveryId
string (uuid)required
The delivery to replay.

Errors

  • 404No such delivery, or its endpoint no longer exists.
GET/v1/webhook-events
auth: API key200 on success

The 100 most recent delivery attempts across all endpoints, newest first.

response · 200
[
  {
    "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 enabled flag, 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.