Webhooks
Signed POSTs when mail arrives, bounces, or is filtered.
Nothing here is required. Your agent already gets mail without it: webhooks exist so something else can be told the moment mail arrives, whether that is a service you wrote or an assistant that is already running. If you connected an assistant over MCP and only want it to wait for one reply, use wait_for_reply instead and skip this page.
Register an https endpoint under Webhooks in the dashboard, or with create_webhook, to receive signed POSTs. A failed delivery is retried five times, waiting about 30 seconds, then 2, 8, and 30 minutes, so a receiver that is restarting or briefly down does not lose the event. If your endpoint answers 429 or 503 with a Retry-After, we wait exactly that long instead.
POST /your-endpoint HTTP/1.1
content-type: application/json
x-mcpmailer-event: message.received
x-mcpmailer-delivery-id: whd_9c1f4a2b7e0d4c58a1b6
x-request-id: whd_9c1f4a2b7e0d4c58a1b6
x-mcpmailer-signature: t=1753440000,v1=9f2c...e1
{
"id": "whd_9c1f4a2b7e0d4c58a1b6",
"event": "message.received",
"timestamp": "2026-07-28T09:14:02.117Z",
"data": { "message_id": "msg_01J9X8Q2K7", "from": "ada@example.com" }
}Every delivery has the same envelope: id, event, timestamp as an ISO 8601 string, and data. The event name and the id are repeated in x-mcpmailer-event and x-mcpmailer-delivery-id headers, so a router can dispatch and dedup without parsing the body first. The id also goes out as x-request-id, which is the name gateways and proxies already dedup and correlate on, so a receiver that understands it gets that for free.
Handling repeats
Delivery is at-least-once. An endpoint that takes longer than ten seconds is treated as failed and tried again, even though it usually finished the work, so the same event can arrive more than once. Every copy carries the same id, and that is the value to key on: record the ids you have handled, and return 200 without doing anything when one comes back.
// At-least-once: the same event can arrive more than once, and every copy
// carries the same id. Record it, and let a repeat fall straight through.
if (await seen.has(body.id)) return new Response('ok');
await seen.add(body.id, { ttlSeconds: 86_400 });
await handle(body);Which agent, and which events
An endpoint follows the whole workspace by default, which is what you want when one service handles everything. Point it at a single agent instead, in the dashboard or with mailbox_id, when a workspace runs several and each has its own handler. A scoped endpoint hears only that agent, and bounces and complaints follow the agent that sent the message.
Endpoints that expect their own shape
Some receivers were built before they ever heard of us and will not accept our envelope. Two settings cover them. Custom headers carry whatever auth the receiver requires, one per line in the dashboard or as a headers object over the API. A body template replaces the envelope with the receiver's own JSON, filling {{event}}, {{timestamp}}, {{id}}, and any {{data.field}} placeholder. The signature still covers whatever body is actually sent.
When an endpoint keeps failing
Consecutive failures are counted on the endpoint and shown next to it. After twenty in a row we stop trying and mark it turned off, rather than keep POSTing at a URL nobody is listening on, and the workspace owner is emailed so this is not something you find out by chance. A successful test delivery clears the count, and resuming the endpoint starts it over. Events that happen while it is off are held for seven days and delivered once you turn it back on, within a few minutes of doing so, so fixing a receiver inside that window loses nothing. Pausing an endpoint yourself holds nothing: that is you saying you do not want these events, not a receiver we expect back.
Answering 410 Gone turns the endpoint off immediately instead. Use it when a URL is retired for good: it saves both sides several hours of attempts against a host that will never answer.
Finding out why
Every attempt is logged with the status code, the response body, and how long it took, and kept for thirty days. The log is on the endpoint in the dashboard and also on the API, because the thing that owns an endpoint is usually a program and it should be able to look without a person. A status code means the receiver answered and something about the request was wrong; an error with no status means the request never got there at all, which is DNS, TLS, or a timeout.
# What each attempt got back, newest first.
mcpmailer webhooks:deliveries <endpoint-id>
# Send a sample event now and print the response.
mcpmailer webhooks:test <endpoint-id>
# Send a stored payload again, under its original id.
mcpmailer webhooks:replay <delivery-id>A test sends a sample message.received event now and answers with exactly what came back, nothing queued and nothing retried. A replay sends a stored payload again, byte for byte and under its original id, which is what you want once a broken receiver is fixed. Both are list_webhook_deliveries, test_webhook, and replay_webhook_delivery over MCP.
Endpoints must be public https. A URL that resolves to a private or loopback address is refused when you register it and again before each attempt, so a laptop needs a tunnel rather than a LAN address.
Events
| event | Fires when |
|---|---|
message.received | Mail arrived and was stored. Subscribed by default. |
message.filtered | Inbound mail was dropped by the identity filter, before storage. |
message.sent | A message you sent was accepted for delivery. |
message.delivered | The receiving server accepted a message you sent. |
message.bounced | A recipient rejected a message. The address is now suppressed. |
message.complained | A recipient marked a message as spam. The address is now suppressed. |
approval.approved | A person released a message your agent was holding, and it went. Carries pending_id and message_id. |
approval.rejected | A person discarded it. Carries decision_note, their reason, which is worth reading before rewriting. |
approval.expired | Nobody acted on it within a week, so it will not be sent. |
domain.verified | A domain finished verification and can send. Fires whether a person clicked verify, an agent called verify_domain, or the nightly re-check found the records. |
workspace.throttled | Bounce or complaint rates crossed the throttle line. Cold sends are slowed; replies still go. Clears on its own after a clean day. |
workspace.paused | Rates crossed the pause line. Every send is refused until a person has looked. Carries the reason. |
workspace.resumed | A throttle cleared after a clean day of sending. |
reason on a filtered delivery is not_whitelisted when an allow-list identity did not recognise the sender, and blocked_sender when a rule blocked them outright. spam_verdict on a received delivery is the receiving verdict, PASS or FAIL, or UNKNOWN when none was returned.
message.received
The one most agents want. It fires after the message is stored, so the id in it can be passed straight to read_message or reply_all.
{
"id": "whd_9c1f4a2b7e0d4c58a1b6",
"event": "message.received",
"timestamp": "2026-07-28T09:14:02.117Z",
"data": {
"message_id": "msg_01J9X8Q2K7",
"thread_id": "thr_01J9X8Q2K7",
"mailbox": "scout@agents.yourcompany.com",
"from": "ada@example.com",
"subject": "Re: Shipping update",
"snippet": "That works for us, Thursday is fine.",
"spam_verdict": "PASS"
}
}message.filtered
Fires when inbound mail is dropped by the identity filter. It happens before storage, so there is no message id and nothing to fetch later. This is the only way to see mail that was rejected, which makes it the event to subscribe to when a whitelisted agent seems to be receiving nothing.
{
"id": "whd_4b8e2d6a0f3c47915ade",
"event": "message.filtered",
"timestamp": "2026-07-28T09:14:02.117Z",
"data": {
"mailbox": "scout@agents.yourcompany.com",
"from": "stranger@example.net",
"subject": "Quick question",
"reason": "not_whitelisted",
"filter_mode": "whitelist"
}
}message.sent and message.delivered
The other half of the picture for an agent that follows up on its own mail. message.sent fires when a message is accepted for delivery; message.delivered fires when the receiving server accepts it, which is as close to "it landed" as SMTP gets. Subscribe to these when the decision to chase depends on whether the first message arrived, because bounces only ever tell you about the failures.
{
"id": "whd_4b8e1c0a9f7d2e63b5a1",
"event": "message.delivered",
"timestamp": "2026-07-28T09:15:41.882Z",
"data": {
"message_id": "msg_01J9X8Q2K7",
"thread_id": "thr_01J9X8Q2K7",
"to": ["ada@example.com"],
"subject": "Your invoice"
}
}message.bounced and message.complained
Both carry the same shape, and both mean the address is now suppressed: later sends to it are refused with recipient_suppressed. bounce_type is present only on a bounce.
{
"id": "whd_71ad3f9c2b6e480d95cc",
"event": "message.bounced",
"timestamp": "2026-07-28T09:14:02.117Z",
"data": {
"message_id": "msg_01J9X8Q2K7",
"to": ["ada@example.com"],
"subject": "Shipping update",
"bounce_type": "Permanent"
}
}Verifying a delivery
The x-mcpmailer-signature header carries a timestamp and an HMAC-SHA256 of "timestamp.body", keyed with the endpoint secret. The SDK ships the check, and using it is the recommendation rather than a formality: it returns the parsed event when the delivery is authentic and null when it is not, so the handler is two lines.
import { verifyWebhook } from '@mcpmailer/sdk';
export async function POST(request: Request) {
// The raw bytes, not JSON. Parsing and re-serialising changes them, and the
// signature covers bytes.
const raw = await request.text();
const event = await verifyWebhook(raw, request.headers, process.env.MCPMAILER_WEBHOOK_SECRET);
if (!event) return new Response('bad signature', { status: 401 });
// Authentic, and recent. Safe to act on.
await handle(event);
return new Response('ok');
}Two things have to hold, and skipping either is where hand-written checks go wrong. The HMAC has to match, compared against the raw body before any JSON parsing and in constant time. The timestamp has to be within five minutes of now, because without that a delivery captured once stays valid forever and can be replayed at any later point. If you are not on the TypeScript SDK, this is the whole check in any language.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(raw: string, header: string, secret: string, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(',').map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)])
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Without this the signature is valid forever and a captured delivery can be
// replayed at any later point.
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
// Every v1 in the header, because during a secret rotation there is one per
// valid secret and any of them matching means the delivery is ours.
const expected = createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex');
return header
.split(',')
.filter((p) => p.startsWith('v1='))
.some((p) => {
const v1 = p.slice(3);
// Length first: timingSafeEqual throws on a mismatch, which would turn a
// malformed header into a 500 instead of a 401.
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
});
}Receivers that verify the generic scheme
The same signature also goes out under two generic names, on every delivery and with nothing to turn on: x-webhook-signature-v2 carries the bare hex digest, and x-webhook-timestamp carries the unix seconds it was made at. It is the identical construction, so a receiver that already knows how to check an HMAC of "timestamp.body" accepts our deliveries once you give it the signing secret, with no code written for us at all. Hermes is the one people hit first, and its recommended mode reads exactly these two headers.
If you are writing the receiver yourself, prefer our header: it is the one that carries both signatures during a rotation.
Rotating the secret
Rotating gives you a new secret once, and keeps the old one verifying for 24 hours. Deliveries in that window are signed with both, so you can rotate first and deploy the receiver afterwards without a gap where correct signature checks refuse real events. Update the receiver before the window closes; after it, only the new secret verifies. The generic x-webhook-signature-v2 header holds one digest rather than two, so a receiver verifying that one has no window at all: it carries the new secret from the moment you rotate.
Waking an assistant
A long-running assistant is the case webhooks are best at, and pointing one at OpenClaw, n8n, or Zapier is a page of its own: /docs/waking has the templates, the headers each of them wants, and the three ways of finding out mail arrived compared.