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:

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.

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.

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

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:

  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.

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:

Both streams accept the API key as ?token= for clients that cannot set an Authorization header. webhook.test events are not streamed.