Webhook events · MachineMail Docs
Event types
MachineMail records an event when mail arrives, is sent, is delivered or bounces, and when inboxes, domains and drafts change. Each event is:
- POSTed to every matching webhook,
- pushed to live streams (
GET /v0/events/streamand the/v0/wsWebSocket), - kept in the event log (
GET /v0/events), with no retention limit.
Event types
| Event type | When |
|---|---|
message.received |
Mail arrived and the trust layer found nothing to flag. |
message.received.quarantined |
Mail arrived with trust level quarantined (prompt injection, phishing, or a blocked sender). Its INBOX label is replaced by QUARANTINE. |
message.received.spam |
Mail arrived that the classifier scored as spam (is_spam at least 0.9). INBOX is replaced by SPAM. |
message.received.unauthenticated |
Mail arrived that failed DMARC, or failed both SPF and DKIM. UNAUTHENTICATED is added; INBOX stays. |
message.received.blocked |
Mail arrived from a sender on a receive block list. Its only label is BLOCKED. |
message.sent |
A message was sent: send, reply, reply-all, forward or draft send. |
message.delivered |
The receiving server accepted a sent message. |
message.bounced |
A sent message bounced. Permanent bounces add the recipients to suppressions. |
message.complained |
A recipient marked a sent message as spam. The recipient is suppressed. |
message.rejected |
Mail arrived for a paused inbox and was not stored. |
domain.verified |
A custom domain passed DNS verification. |
domain.updated |
A DNS check changed a custom domain’s status or a record’s status. Becoming VERIFIED also fires domain.verified. |
inbox.created |
An inbox was created, through the API, MCP, the dashboard, or a catch-all domain. |
inbox.expired |
An ephemeral inbox reached expires_at and was deleted with its mail. |
draft.sent |
A draft was sent, by request or on its send_at schedule. |
webhook.test |
Sent only by POST /v0/webhooks/{webhook_id}/test, to that one webhook. |
An inbound message fires exactly one message.received* event. The checks run in this order and the first match wins: blocked sender, quarantined, spam, unauthenticated, otherwise message.received.
When you create a webhook, event_types picks the types it receives. Omitting it, or sending an empty array, subscribes it to every type. An unknown type returns 400 Unknown event type: <type>.
MachineMail does not emit AgentMail’s message.opened or calendar events.
Envelope
Every webhook body, SSE data: line and WebSocket event frame is the same JSON envelope:
Envelope
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "message.received",
"timestamp": "2026-10-01T12:00:00.000Z",
"message": { "...": "the payload keys, at the top level" },
"thread": { "...": "..." },
"data": {
"message": { "...": "the same payload, again under data" },
"thread": { "...": "..." }
}
}
| Field | Type | Meaning |
|---|---|---|
type |
"event" | Always event. |
event_id |
string | Unique, time-sortable (`evt_..."). Use it to deduplicate. |
event_type |
string | One of the types above. |
timestamp |
string (date-time) | When the event was recorded. |
data |
object | The payload for this type (below). |
| payload keys | The keys of data are also copied to the top level, as AgentMail does (message, thread, send, bounce, …). |
The event log (GET /v0/events) returns a different shape for each event: event_id, event_type, organization_id, pod_id, inbox_id, timestamp, data and created_at.
Payloads
The payload is what appears under data (and at the top level). Message, ThreadItem, Inbox and Domain objects are the same as the API returns elsewhere.
- Message content is untrusted input. When you pass mail to a model, use
message.trust.content.textrather thantextorhtml. message.received*payloads carrymessage.authentication_results(SPF, DKIM and DMARC verdicts with their domains), as AgentMail’s do. See Authentication.- Domain events have no inbox, so webhooks and streams filtered by inbox never receive them.
- Expired inboxes are reaped once a minute, so
inbox.expiredcan arrive up to about a minute afterexpires_at.
domain.updated
A DNS check changed the domain status or a records[].status (verification progress). The change to VERIFIED also sends domain.verified. Sent only to webhooks whose event_types list it (an empty list does not include it).
FieldDescription
domain Domain Required
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "domain.updated",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"domain": {
"pod_id": "pod_01k6x2m9qd0001b8j4mr0w3d5f",
"domain_id": "dom_01k6x3d5kq0000g3q9sx5b8j0m",
"domain": "example.com",
"...": "..."
}
}
domain.verified
A domain became VERIFIED.
FieldDescription
domain Domain Required
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "domain.verified",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"domain": {
"pod_id": "pod_01k6x2m9qd0001b8j4mr0w3d5f",
"domain_id": "dom_01k6x3d5kq0000g3q9sx5b8j0m",
"domain": "example.com",
"...": "..."
}
}
draft.sent
A draft was sent, explicitly or at its send_at. A message.sent event is emitted too.
FieldDescription
draft_id``stringRequired
ID of the sent (and now deleted) draft.
inbox_id``stringRequired
ID of the inbox.
sendobjectRequired
send:
FieldDescription
message_id``stringRequired
ID of the message.
thread_id``stringRequired
ID of the thread.
timestamp``string (date-time)Required
When the message was sent.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "draft.sent",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"draft_id": "drf_01k6x3b7hn0000e1n7qv3z6g8j",
"inbox_id": "agent@machinemail.dev",
"send": {
"message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"timestamp": "2026-10-01T12:00:00.000Z"
}
}
inbox.created
An inbox was created (API, dashboard, catch-all, agent registration or an import).
FieldDescription
inbox Inbox Required
import_source``"agentmail"
Set when an importer copied the inbox from another provider (import_source on Create Inbox). Such events are recorded and streamed but never sent to webhooks.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "inbox.created",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"inbox": {
"pod_id": "pod_01k6x2m9qd0001b8j4mr0w3d5f",
"inbox_id": "agent@machinemail.dev",
"email": "agent@machinemail.dev",
"...": "..."
},
"import_source": "agentmail"
}
inbox.expired
An ephemeral inbox expired and was deleted with its mail.
FieldDescription
inbox Inbox Required
deleted_messages``integerRequired
Number of messages deleted with it.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "inbox.expired",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"inbox": {
"pod_id": "pod_01k6x2m9qd0001b8j4mr0w3d5f",
"inbox_id": "agent@machinemail.dev",
"email": "agent@machinemail.dev",
"...": "..."
},
"deleted_messages": 1
}
message.bounced
A sent message bounced.
FieldDescription
bounce Bounce Required
bounce is a Bounce:
FieldDescription
inbox_id``stringRequired
ID of the inbox.
thread_id``stringRequired
ID of the thread.
message_id``stringRequired
ID of the message.
timestamp``string (date-time)Required
When it happened.
type``stringRequired
Bounce type: Permanent or Transient (or the provider type).
sub_type``stringRequired
Bounce sub-type, General when the provider gives none.
recipients Recipient[] Required
Bounced recipients. Permanent bounces are added to the suppression list.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "message.bounced",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"bounce": {
"inbox_id": "agent@machinemail.dev",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
"timestamp": "2026-10-01T12:00:00.000Z",
"type": "string",
"sub_type": "string",
"recipients": [
{
"address": "agent@machinemail.dev",
"status": "string"
}
]
}
}
message.complained
A recipient marked a sent message as spam.
FieldDescription
complaint Complaint Required
complaint is a Complaint:
FieldDescription
inbox_id``stringRequired
ID of the inbox.
thread_id``stringRequired
ID of the thread.
message_id``stringRequired
ID of the message.
timestamp``string (date-time)Required
When it happened.
type``stringRequired
Complaint type, complaint when the provider gives none.
sub_type``stringRequired
Complaint sub-type, abuse when the provider gives none.
recipients Recipient[] Required
Complaining recipients, as {address, status} objects (AgentMail documents plain strings). They are added to the suppression list.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "message.complained",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"complaint": {
"inbox_id": "agent@machinemail.dev",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
"timestamp": "2026-10-01T12:00:00.000Z",
"type": "string",
"sub_type": "string",
"recipients": [
{
"address": "agent@machinemail.dev",
"status": "string"
}
]
}
}
message.delivered
The receiving server accepted a sent message.
FieldDescription
delivery Delivery Required
delivery is a Delivery:
FieldDescription
inbox_id``stringRequired
ID of the inbox.
thread_id``stringRequired
ID of the thread.
message_id``stringRequired
ID of the message.
timestamp``string (date-time)Required
When it happened.
recipients``string[]Required
Delivered recipients.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "message.delivered",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"delivery": {
"inbox_id": "agent@machinemail.dev",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
"timestamp": "2026-10-01T12:00:00.000Z",
"recipients": ["agent@machinemail.dev"]
}
}
message.received, message.received.blocked, message.received.quarantined, message.received.spam, message.received.unauthenticated
A message was received and stored. The sub-types use the same payload.
FieldDescription
message Message Required
thread ThreadItem Required
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "message.received",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"message": {
"inbox_id": "agent@machinemail.dev",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
"...": "..."
},
"thread": {
"inbox_id": "agent@machinemail.dev",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"labels": ["UNREAD"],
"...": "..."
}
}
message.rejected
An incoming message was refused and not stored.
FieldDescription
reject Reject Required
reject is a Reject:
FieldDescription
inbox_id``stringRequired
ID of the inbox.
thread_id``nullRequired
Always null: nothing was stored.
message_id``nullRequired
Always null: nothing was stored.
timestamp``string (date-time)Required
When it happened.
reason``"inbox_paused" | "plan_limit" | "plan_limit_expired"Required
Why the message was rejected. inbox_paused: the inbox is paused. plan_limit (MachineMail): the organization reached a Free plan limit and already holds as much mail as it may. plan_limit_expired (MachineMail): held mail was not released in time and was deleted.
from``stringRequired
Sender of the rejected message.
subject``string
Subject of the rejected message.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "message.rejected",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"reject": {
"inbox_id": "agent@machinemail.dev",
"thread_id": null,
"message_id": null,
"timestamp": "2026-10-01T12:00:00.000Z",
"reason": "inbox_paused",
"from": "Support Agent <agent@machinemail.dev>",
"subject": "Order #1042"
}
}
message.sent
A message was sent (send, reply, forward or draft).
FieldDescription
send Send Required
message Message Required
The sent message.
thread ThreadItem Required
Its thread.
send is a Send:
FieldDescription
inbox_id``stringRequired
ID of the inbox.
thread_id``stringRequired
ID of the thread.
message_id``stringRequired
ID of the message.
timestamp``string (date-time)Required
When it happened.
recipients``string[]Required
Every recipient (to, cc and bcc).
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "message.sent",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"send": {
"inbox_id": "agent@machinemail.dev",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
"timestamp": "2026-10-01T12:00:00.000Z",
"recipients": ["agent@machinemail.dev"]
},
"message": {
"inbox_id": "agent@machinemail.dev",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"message_id": "msg_01k6x3a1g00000d0m6pt2y5f7h",
"...": "..."
},
"thread": {
"inbox_id": "agent@machinemail.dev",
"thread_id": "thd_01k6x3a1fz0000c9k5ns1x4e6g",
"labels": ["UNREAD"],
"...": "..."
}
}
trusted_domain.created
A trusted domain was created (MachineMail): the entry as it is now. Sent only to webhooks whose event_types list it (an empty list does not include it).
FieldDescription
trusted_domain TrustedDomain Required
A trusted domain or address.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "trusted_domain.created",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"trusted_domain": {
"trusted_domain_id": "string",
"entry_type": "domain",
"domain": "example.com",
"...": "..."
}
}
trusted_domain.deleted
A trusted domain was deleted (MachineMail): the entry as it was. Sent only to webhooks whose event_types list it (an empty list does not include it).
FieldDescription
trusted_domain TrustedDomain Required
A trusted domain or address.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "trusted_domain.deleted",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"trusted_domain": {
"trusted_domain_id": "string",
"entry_type": "domain",
"domain": "example.com",
"...": "..."
}
}
trusted_domain.updated
A trusted domain was updated (MachineMail): the entry as it is now. Sent only to webhooks whose event_types list it (an empty list does not include it).
FieldDescription
trusted_domain TrustedDomain Required
A trusted domain or address.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "trusted_domain.updated",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"trusted_domain": {
"trusted_domain_id": "string",
"entry_type": "domain",
"domain": "example.com",
"...": "..."
}
}
webhook.test
Sent by POST /v0/webhooks/{webhook_id}/test to that webhook only, whatever its event_types.
FieldDescription
webhook_id``stringRequired
ID of the tested webhook.
message``stringRequired
This is a test event from MachineMail.
Example
{
"type": "event",
"event_id": "evt_01k6x3g4pt0000k6t2wa8e1n3q",
"event_type": "webhook.test",
"timestamp": "2026-10-01T12:00:00.000Z",
"data": {
"...": "the same keys as at the top level"
},
"webhook_id": "whk_01k6x3c2jp0000f2p8rw4a7h9k",
"message": "string"
}
Delivery
Each delivery is an HTTP POST with Content-Type: application/json, User-Agent: MachineMail-Webhooks/1.0 and these headers:
FieldDescription
X-MachineMail-Signature``stringRequired
t=<unix seconds>,v1=<hex HMAC-SHA256>: the HMAC of <t>.<raw request body> with the webhook secret as key. Recompute it over the raw body, compare in constant time, and reject old t values (the reference verifier allows 300 seconds).
X-MachineMail-Event``stringRequired
Event type, as in event_type.
X-MachineMail-Event-Id``stringRequired
ID of the event, as in event_id. The same for every delivery and replay of the event: dedupe on it.
X-MachineMail-Delivery-Id``stringRequired
ID of this delivery (GET /v0/webhooks/{webhook_id}/deliveries). A replay is a new delivery.
- Respond with any
2xxwithin 10 seconds. Anything else counts as a failure: other status codes, timeouts, connection errors and redirects (redirects are not followed). - The URL must be public
httporhttps. It is checked again before every attempt, and private, loopback and link-local addresses are refused. - The first 1024 bytes of your response body and its status are stored on the delivery (
response_status,response_body). - Delivery is at least once. A retry, a replay or a crash can deliver the same event twice; deduplicate on
X-MachineMail-Event-Id. - Deliveries are not ordered. Use
timestampor the event ID, which sorts by time, to order events.
Retries
A failed attempt is retried on this schedule, measured from the previous attempt:
| After attempt | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |
| 6 and later | 24 hours |
Retries stop when the next attempt would be more than 5 days after the delivery was created; the delivery is then failed. That is about 10 attempts. The delivery worker checks for due retries every 5 seconds.
Webhooks are never disabled automatically. PATCH /v0/webhooks/{webhook_id} with enabled: false stops new deliveries; deliveries already queued are still attempted. Events that happen while a webhook is disabled are not delivered later.
Inspecting and replaying
GET /v0/webhooks/{webhook_id}/deliveries?status=pending|delivered|failedlists deliveries withattempt,status,response_status,response_body,last_error,next_attempt_atanddelivered_at.POST /v0/webhooks/{webhook_id}/deliveries/{delivery_id}/replaysends the original event body again as a new delivery, right away, with a fresh signature timestamp. It works for any delivery status and whether or not the webhook is enabled. If it fails, the normal retry schedule applies.POST /v0/webhooks/{webhook_id}/testsends awebhook.testevent to that webhook right away and returns the delivery.
Both need the webhook_update permission.
Verifying signatures
Each webhook has a secret that starts with whsec_. It is returned when you create or fetch the webhook. There is no rotation endpoint: to rotate, create a new webhook and delete the old one.
The signature header is:
X-MachineMail-Signature: t=1759320000,v1=5f2b6c...e91a
v1 is the lowercase hex HMAC-SHA256 of <t>.<raw request body>, keyed with the whole secret string (including whsec_, as UTF-8 bytes; the secret is not base64-decoded). t is the time of this attempt, so retries and replays carry new signatures.
To verify:
- Read the raw body bytes before parsing JSON.
- Split the header on
,and each part on the first=to gettandv1. - Reject the request if
tis more than 5 minutes from your clock. - Compute the HMAC and compare it with
v1in constant time.
TypeScriptPython
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyMachinemailSignature(secret: string, header: string | null, rawBody: string, toleranceSeconds = 300): boolean {
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
// Example with a fetch-style handler (Bun, Deno, Hono, Next.js route handlers).
export async function handle(req: Request): Promise<Response> {
const raw = await req.text();
if (!verifyMachinemailSignature(process.env.MACHINEMAIL_WEBHOOK_SECRET!, req.headers.get('x-machinemail-signature'), raw)) {
return new Response('invalid signature', { status: 401 });
}
const event = JSON.parse(raw);
// Deduplicate on event.event_id, then hand off to a queue and return quickly.
return new Response(null, { status: 204 });
}
import hashlib
import hmac
import time
def verify_machinemail_signature(secret: str, header: str | None, raw_body: bytes, tolerance_seconds: int = 300) -> bool:
if not header:
return False
parts = {}
for part in header.split(","):
key, _, value = part.partition("=")
parts[key.strip()] = value.strip()
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_seconds:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask example
from flask import Flask, request, abort
import json, os
app = Flask(__name__)
@app.post("/webhooks/machinemail")
def machinemail_webhook():
raw = request.get_data()
if not verify_machinemail_signature(os.environ["MACHINEMAIL_WEBHOOK_SECRET"], request.headers.get("X-MachineMail-Signature"), raw):
abort(401)
event = json.loads(raw)
# Deduplicate on event["event_id"], then process asynchronously.
return "", 204
Streams
The same envelopes are available without a public URL:
- Server-sent events:
GET /v0/events/stream, filtered byinbox_id,pod_idandevent_types. Resume after a disconnect withLast-Event-ID(up to 500 missed events are replayed). The stream starts with anevent: readyframe and sends a: pingcomment after 15 seconds without events. - WebSocket:
/v0/ws. Send{"type":"subscribe","inbox_ids":[...],"pod_ids":[...],"event_types":[...]}to narrow it, and{"type":"ping"}to get{"type":"pong"}. No resume. - Polling:
GET /v0/events?after=<event_id>returns everything after an event, oldest first.
Both streams accept the API key as ?token= for clients that cannot set an Authorization header. webhook.test events are not streamed.