Skip to content
machinemail

Webhook events

Every event type, the payload of each, signature verification, retries and replay.

MachineMail records an event when mail arrives, is sent, is delivered or bounces, and when inboxes, domains and drafts change. Each event is:

Event types

Event typeWhen
message.receivedMail arrived and the trust layer found nothing to flag.
message.received.quarantinedMail arrived with trust level quarantined (prompt injection, phishing, or a blocked sender). Its INBOX label is replaced by QUARANTINE.
message.received.spamMail arrived that the classifier scored as spam (is_spam at least 0.9). INBOX is replaced by SPAM.
message.received.unauthenticatedMail arrived that failed DMARC, or failed both SPF and DKIM. UNAUTHENTICATED is added; INBOX stays.
message.received.blockedMail arrived from a sender on a receive block list. Its only label is BLOCKED.
message.sentA message was sent: send, reply, reply-all, forward or draft send.
message.deliveredThe receiving server accepted a sent message.
message.bouncedA sent message bounced. Permanent bounces add the recipients to suppressions.
message.complainedA recipient marked a sent message as spam. The recipient is suppressed.
message.rejectedMail arrived for a paused inbox and was not stored.
domain.verifiedA custom domain passed DNS verification.
domain.updatedA DNS check changed a custom domain’s status or a record’s status. Becoming VERIFIED also fires domain.verified.
inbox.createdAn inbox was created, through the API, MCP, the dashboard, or a catch-all domain.
inbox.expiredAn ephemeral inbox reached expires_at and was deleted with its mail.
draft.sentA draft was sent, by request or on its send_at schedule.
webhook.testSent 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": { "...": "..." }
  }
}
FieldTypeMeaning
type"event"Always event.
event_idstringUnique, time-sortable (evt_...). Use it to deduplicate.
event_typestringOne of the types above.
timestampstring (date-time)When the event was recorded.
dataobjectThe payload for this type (below).
payload keysThe 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.text rather than text or html.
  • Domain events have no inbox, so webhooks and streams filtered by inbox never receive them.
  • Expired inboxes are reaped once a minute, so inbox.expired can arrive up to about a minute after expires_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).

domainDomainRequired
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.

domainDomainRequired
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.

draft_idstringRequired
ID of the sent (and now deleted) draft.
inbox_idstringRequired
ID of the inbox.
sendobjectRequired

send:

message_idstringRequired
ID of the message.
thread_idstringRequired
ID of the thread.
timestampstring (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 or agent registration).

inboxInboxRequired
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",
    "...": "..."
  }
}

inbox.expired

An ephemeral inbox expired and was deleted with its mail.

inboxInboxRequired
deleted_messagesintegerRequired
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.

bounceBounceRequired

bounce is a Bounce:

inbox_idstringRequired
ID of the inbox.
thread_idstringRequired
ID of the thread.
message_idstringRequired
ID of the message.
timestampstring (date-time)Required
When it happened.
typestringRequired
Bounce type: Permanent or Transient (or the provider type).
sub_typestringRequired
Bounce sub-type, General when the provider gives none.
recipientsRecipient[]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.

complaintComplaintRequired

complaint is a Complaint:

inbox_idstringRequired
ID of the inbox.
thread_idstringRequired
ID of the thread.
message_idstringRequired
ID of the message.
timestampstring (date-time)Required
When it happened.
typestringRequired
Complaint type, complaint when the provider gives none.
sub_typestringRequired
Complaint sub-type, abuse when the provider gives none.
recipientsRecipient[]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.

deliveryDeliveryRequired

delivery is a Delivery:

inbox_idstringRequired
ID of the inbox.
thread_idstringRequired
ID of the thread.
message_idstringRequired
ID of the message.
timestampstring (date-time)Required
When it happened.
recipientsstring[]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.

messageMessageRequired
threadThreadItemRequired
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.

rejectRejectRequired

reject is a Reject:

inbox_idstringRequired
ID of the inbox.
thread_idnullRequired
Always null: nothing was stored.
message_idnullRequired
Always null: nothing was stored.
timestampstring (date-time)Required
When it happened.
reason"inbox_paused"Required
Why the message was rejected. inbox_paused: the inbox is paused.
fromstringRequired
Sender of the rejected message.
subjectstring
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).

sendSendRequired
messageMessageRequired
The sent message.
threadThreadItemRequired
Its thread.

send is a Send:

inbox_idstringRequired
ID of the inbox.
thread_idstringRequired
ID of the thread.
message_idstringRequired
ID of the message.
timestampstring (date-time)Required
When it happened.
recipientsstring[]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"],
    "...": "..."
  }
}

webhook.test

Sent by POST /v0/webhooks/{webhook_id}/test to that webhook only, whatever its event_types.

webhook_idstringRequired
ID of the tested webhook.
messagestringRequired
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:

X-MachineMail-SignaturestringRequired
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-EventstringRequired
Event type, as in event_type.
X-MachineMail-Event-IdstringRequired
ID of the event, as in event_id. The same for every delivery and replay of the event: dedupe on it.
X-MachineMail-Delivery-IdstringRequired
ID of this delivery (GET /v0/webhooks/{webhook_id}/deliveries). A replay is a new delivery.
  • Respond with any 2xx within 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 http or https. 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 timestamp or 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 attemptNext attempt in
11 minute
25 minutes
330 minutes
42 hours
512 hours
6 and later24 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|failed lists deliveries with attempt, status, response_status, response_body, last_error, next_attempt_at and delivered_at.
  • POST /v0/webhooks/{webhook_id}/deliveries/{delivery_id}/replay sends 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}/test sends a webhook.test event 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:

text
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:

  1. Read the raw body bytes before parsing JSON.
  2. Split the header on , and each part on the first = to get t and v1.
  3. Reject the request if t is more than 5 minutes from your clock.
  4. Compute the HMAC and compare it with v1 in constant time.
verify.ts
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 });
}
verify.py
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 by inbox_id, pod_id and event_types. Resume after a disconnect with Last-Event-ID (up to 500 missed events are replayed). The stream starts with an event: ready frame and sends a : ping comment 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.