Skip to navigation

Webhook Events

As mentioned in the overview, webhooks allow us to create event-driven applications.

AgentMail supports multiple event types that allow you to build comprehensive, event-driven workflows for your email agents.

All webhook payloads follow the same basic structure:

{
"type": "event",
"event_type": "event.name",
"event_id": "c4ca4238a0b923820dcc509a6f75849b"
// ... event-specific data object
}

event_id identifies the event, not the delivery: it is the same on every retry and on every webhook that receives the event, so use it to deduplicate. Its format varies by event type; treat it as an opaque string. See Duplicates and ordering.

Parsing events with SDKs

The AgentMail SDKs export typed classes for each webhook payload shape, so you can parse raw payloads into fully typed objects.

import { serialization } from "agentmail";
async function handleWebhook(payload: Record<string, unknown>) {
const eventType = payload.event_type;
const receivedEventTypes = [
"message.received",
"message.received.spam",
"message.received.blocked",
"message.received.unauthenticated",
];
if (receivedEventTypes.includes(String(eventType))) {
const event = await serialization.events.MessageReceivedEvent.parseOrThrow(payload);
// access typed fields
console.log(event.message.subject);
console.log(event.thread.messageCount);
} else if (eventType === "message.sent") {
const event = await serialization.events.MessageSentEvent.parseOrThrow(payload);
console.log(event.send.recipients);
} else if (eventType === "message.bounced") {
const event = await serialization.events.MessageBouncedEvent.parseOrThrow(payload);
console.log(event.bounce.type);
} else if (eventType === "message.delivered") {
const event = await serialization.events.MessageDeliveredEvent.parseOrThrow(payload);
console.log(event.delivery.recipients);
} else if (eventType === "message.complained") {
const event = await serialization.events.MessageComplainedEvent.parseOrThrow(payload);
console.log(event.complaint.type);
} else if (eventType === "message.rejected") {
const event = await serialization.events.MessageRejectedEvent.parseOrThrow(payload);
console.log(event.reject.reason);
} else if (eventType === "message.opened") {
const event = await serialization.events.MessageOpenedEvent.parseOrThrow(payload);
console.log(event.open.messageId, event.open.timestamp);
} else if (eventType === "message.security.completed") {
const event = await serialization.events.MessageSecurityCompletedEvent.parseOrThrow(payload);
console.log(event.message.messageId, event.message.agentArmor.verdict);
} else if (eventType === "message.security.review") {
const event = await serialization.events.MessageSecurityReviewEvent.parseOrThrow(payload);
console.log("held for review:", event.message.messageId);
} else if (eventType === "message.security.override") {
const event = await serialization.events.MessageSecurityOverrideEvent.parseOrThrow(payload);
console.log("released:", event.message.messageId, event.message.manualOverride.reason);
} else if (eventType === "domain.verified") {
const event = await serialization.events.DomainVerifiedEvent.parseOrThrow(payload);
console.log(event.domain.status);
}
}

Copy for Cursor / Claude

Copy one of the blocks below into Cursor or Claude for webhook event parsing in one shot.

"""
AgentMail Webhook Events — copy into Cursor/Claude.
Parse typed events: MessageReceivedEvent (message.received, message.received.spam, message.received.blocked, message.received.unauthenticated),
MessageSentEvent (send), MessageBouncedEvent (bounce), MessageDeliveredEvent (delivery), MessageComplainedEvent (complaint),
MessageRejectedEvent (reject), MessageOpenedEvent (open), MessageSecurityCompletedEvent (message), DomainVerifiedEvent (domain).
Only message.received* includes full message+thread; others have send/delivery/bounce/complaint/reject/domain.
message.security.completed (Agent Armor Monitor mode only) carries message ids + agent_armor (no content) and needs message_read.
message.security.review (Agent Armor hold mode held the message) carries the same; message.security.override (someone released it; may arrive before its message.received) carries ids + manual_override. Both need message_read.
calendar.event.* (created, updated, deleted, responded, starting, ending) carry inbox_id and calendar_event; they need calendar_event_read. See https://docs.agentmail.to/calendar-webhooks.
Note: message.received.spam, message.received.blocked, and message.received.unauthenticated require label_spam_read / label_blocked_read / label_unauthenticated_read permissions
and must be explicitly included in event_types. Dedupe on event_id: it is the same across retries and across webhooks.
"""
from agentmail import (
MessageReceivedEvent,
MessageSentEvent, MessageBouncedEvent,
MessageDeliveredEvent, MessageComplainedEvent, MessageRejectedEvent, MessageOpenedEvent, MessageSecurityCompletedEvent,
MessageSecurityReviewEvent, MessageSecurityOverrideEvent, DomainVerifiedEvent,
)
def handle(payload: dict):
t = payload.get("event_type")
if t in {"message.received", "message.received.spam", "message.received.blocked", "message.received.unauthenticated"}: e = MessageReceivedEvent(**payload); print(e.message.subject, e.thread.message_count)
elif t == "message.sent": e = MessageSentEvent(**payload); print(e.send.recipients)
elif t == "message.bounced": e = MessageBouncedEvent(**payload); print(e.bounce.type)
elif t == "message.delivered": e = MessageDeliveredEvent(**payload)
elif t == "message.complained": e = MessageComplainedEvent(**payload)
elif t == "message.rejected": e = MessageRejectedEvent(**payload); print(e.reject.reason)
elif t == "message.opened": e = MessageOpenedEvent(**payload); print(e.open.message_id, e.open.timestamp)
elif t == "message.security.completed": e = MessageSecurityCompletedEvent(**payload); print(e.message.message_id, e.message.agent_armor.verdict)
elif t == "message.security.review": e = MessageSecurityReviewEvent(**payload); print("held:", e.message.message_id)
elif t == "message.security.override": e = MessageSecurityOverrideEvent(**payload); print("released:", e.message.message_id, e.message.manual_override.reason)
elif t == "domain.verified": e = DomainVerifiedEvent(**payload); print(e.domain.status)

Message Events

message.received

  • Description: Triggered whenever a new email is successfully received and processed in one of your Inboxes. This is the most common trigger to kick off agent workflows.
  • Example use-case: Kick off a internal workflow when a customer complaint email hits the support inbox

Something here to notice is message.received and its filtered variants are the only webhook events that include both the metadata on the Thread and the Message in the payload. Other event types send only metadata on the event. Let us know if you need metadata on other event types by emailing support@agentmail.cc

The text and preview fields may be absent when the original email only contains HTML with no plain-text part. This is common with forwarded emails from clients like Gmail and Outlook. When handling incoming messages, always check for html as the primary content source and treat text as optional.

{
"type": "event",
"event_type": "message.received",
"event_id": "c81e728d9d4c2f636f067f89cc14862c",
"message": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<abc123@agentmail.to>",
"labels": ["received"],
"timestamp": "2023-10-27T10:00:00Z",
"from": "Jane Doe <jane@example.com>",
"to": ["Support Agent <support@agentmail.to>"],
"subject": "Question about my account",
"preview": "A short preview of the email text...",
"text": "The full text body of the email.",
"html": "<html>...</html>",
"size": 2048,
"updated_at": "2023-10-27T10:00:00Z",
"created_at": "2023-10-27T10:00:00Z"
},
"thread": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"labels": ["received"],
"timestamp": "2023-10-27T10:00:00Z",
"senders": ["Jane Doe <jane@example.com>"],
"recipients": ["Support Agent <support@agentmail.to>"],
"subject": "Question about my account",
"preview": "A short preview of the email text...",
"last_message_id": "<abc123@agentmail.to>",
"message_count": 1,
"size": 2048,
"updated_at": "2023-10-27T10:00:00Z",
"created_at": "2023-10-27T10:00:00Z"
}
}

message.received.spam

  • Description: Triggered when a new email is received and classified as spam. The payload structure is the same as message.received. Messages classified as spam are not delivered as message.received events.
  • Example use-case: Route spam to a review queue or log spam patterns for analysis.

This event is only delivered if the API key used to create the webhook has the label_spam_read permission. Without this permission, the webhook creation will be rejected with a 403 Forbidden error.

{
"type": "event",
"event_type": "message.received.spam",
"event_id": "eccbc87e4b5ce2fe28308fd9f2a7baf3",
"message": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<spam123@example.com>",
"labels": ["spam"],
"timestamp": "2023-10-27T10:00:00Z",
"from": "spammer@example.com",
"to": ["agent@agentmail.to"],
"subject": "Suspicious offer",
"preview": "You've been selected...",
"text": "Full spam message body.",
"html": "<html>...</html>",
"size": 1024,
"updated_at": "2023-10-27T10:00:00Z",
"created_at": "2023-10-27T10:00:00Z"
},
"thread": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"labels": ["spam"],
"timestamp": "2023-10-27T10:00:00Z",
"senders": ["spammer@example.com"],
"recipients": ["agent@agentmail.to"],
"subject": "Suspicious offer",
"preview": "You've been selected...",
"last_message_id": "<spam123@example.com>",
"message_count": 1,
"size": 1024,
"updated_at": "2023-10-27T10:00:00Z",
"created_at": "2023-10-27T10:00:00Z"
}
}

message.received.blocked

  • Description: Triggered when a new email is received and matched a block list entry. The payload structure is the same as message.received. Messages that match a block list are not delivered as message.received events.
  • Example use-case: Audit blocked senders or maintain a log of blocked messages.

This event is only delivered if the API key used to create the webhook has the label_blocked_read permission. Without this permission, the webhook creation will be rejected with a 403 Forbidden error.

{
"type": "event",
"event_type": "message.received.blocked",
"event_id": "a87ff679a2f3e71d9181a67b7542122c",
"message": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<blocked456@example.com>",
"labels": ["blocked"],
"timestamp": "2023-10-27T10:00:00Z",
"from": "blocked-sender@example.com",
"to": ["agent@agentmail.to"],
"subject": "Blocked message",
"preview": "This message was blocked...",
"text": "Full blocked message body.",
"html": "<html>...</html>",
"size": 512,
"updated_at": "2023-10-27T10:00:00Z",
"created_at": "2023-10-27T10:00:00Z"
},
"thread": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"labels": ["blocked"],
"timestamp": "2023-10-27T10:00:00Z",
"senders": ["blocked-sender@example.com"],
"recipients": ["agent@agentmail.to"],
"subject": "Blocked message",
"preview": "This message was blocked...",
"last_message_id": "<blocked456@example.com>",
"message_count": 1,
"size": 512,
"updated_at": "2023-10-27T10:00:00Z",
"created_at": "2023-10-27T10:00:00Z"
}
}

message.received.unauthenticated

  • Description: Triggered when a new email is delivered with the unauthenticated label: none of SPF, DKIM, or DMARC passed (for example because the sender publishes no records, or its SPF and DKIM both failed), or DMARC failed under the sender’s none policy. A failing SPF or DKIM check on its own does not drop a message. AgentMail drops a message only when DMARC fails under an enforcing policy (quarantine or reject) and no trusted forwarder’s ARC seal vouches for it, so dropped mail sends no event. See Why are my emails not showing up? for the full rules.
  • Example use-case: Monitor senders whose messages do not authenticate so you can decide whether to trust them or ask them to improve their email setup.
  • Permission: Subscribing requires the label_unauthenticated_read permission on the API key.
{
"type": "event",
"event_type": "message.received.unauthenticated",
"event_id": "e4da3b7fbbce2345d7772b0674a318d5",
"message": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<unauth789@example.com>",
"labels": ["unauthenticated"],
"timestamp": "2023-10-27T10:00:00Z",
"from": "sender@example.com",
"to": ["agent@agentmail.to"],
"subject": "Unauthenticated message",
"preview": "This message is missing authentication headers...",
"text": "Full unauthenticated message body.",
"html": "<html>...</html>",
"size": 512,
"updated_at": "2023-10-27T10:00:00Z",
"created_at": "2023-10-27T10:00:00Z"
},
"thread": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"labels": ["unauthenticated"],
"timestamp": "2023-10-27T10:00:00Z",
"senders": ["sender@example.com"],
"recipients": ["agent@agentmail.to"],
"subject": "Unauthenticated message",
"preview": "This message is missing authentication headers...",
"last_message_id": "<unauth789@example.com>",
"message_count": 1,
"size": 512,
"updated_at": "2023-10-27T10:00:00Z",
"created_at": "2023-10-27T10:00:00Z"
}
}

message.sent

  • Description: Triggered when a message is successfully sent from one of your Inboxes.
  • Use Case: Track outgoing messages, update your database, or trigger follow-up workflows after sending.
{
"type": "event",
"event_type": "message.sent",
"event_id": "1679091c5a880faf6fb5e6087eb1b2dc",
"send": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<abc123@agentmail.to>",
"timestamp": "2023-10-27T10:05:00Z",
"recipients": [
"recipient@example.com"
]
}
}

message.delivered

  • Description: Triggered when a sent message is successfully delivered to the recipient’s mail server.
  • Use Case: Confirm successful delivery, update message status, or trigger delivery confirmation workflows.
{
"type": "event",
"event_type": "message.delivered",
"event_id": "0f7c3a2e-5b1d-4e8a-9c6f-2d4b8a1e7c35",
"delivery": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<abc123@agentmail.to>",
"timestamp": "2023-10-27T10:06:00Z",
"recipients": [
"recipient@example.com"
]
}
}

What is the difference between message.sent and message.delivered?

message.sent means the message has successfully left our servers and is out to travel the network. This typically happens before message.delivered where message.delivered means the receiving email server (whether it’s Gmail or Outlook) gives us the 200 OK saying the email has been received. What they do with the email after is unknown.

Nope. As mentioned message.delivered means the receiving email server, whether it’s Gmail or Outlook tells us “Hey AgentMail, we got your email, we’ll take it from here!”. They typically have their own proprietary algorithms to determine whether the email is going to end up in the inbox or spam, but rest assured we handle everything needed for providers like Gmail to deem the emails primary inbox worthy

message.bounced

  • Description: Triggered when a sent message fails to deliver and bounces back. Includes bounce type and sub-type information.
  • Use Case: Handle bounced emails, update recipient status, or trigger bounce handling workflows.
{
"type": "event",
"event_type": "message.bounced",
"event_id": "6a9d2b4f-1e7c-4c3a-8f5b-9e2d7a4c1b68",
"bounce": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<abc123@agentmail.to>",
"timestamp": "2023-10-27T10:07:00Z",
"type": "Permanent",
"sub_type": "General",
"recipients": [
{
"address": "invalid@example.com",
"status": "bounced"
}
]
}
}

message.complained

  • Description: Triggered when a recipient marks your message as spam or files a complaint.
  • Use Case: Handle spam complaints, update sender reputation, or trigger complaint handling workflows.
{
"type": "event",
"event_type": "message.complained",
"event_id": "b3e8f1c6-7a2d-4f9b-a5c4-3d1e6b8f2a97",
"complaint": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<abc123@agentmail.to>",
"timestamp": "2023-10-27T10:08:00Z",
"type": "abuse",
"sub_type": "spam",
"recipients": [
"complainer@example.com"
]
}
}

message.rejected

  • Description: Triggered when a message is rejected before being sent, typically due to validation errors or policy violations.
  • Use Case: Handle rejected messages, log rejection reasons, or trigger validation workflows.
{
"type": "event",
"event_type": "message.rejected",
"event_id": "d2f5a8c1-4b6e-4a3d-9e7f-8c1b5d2a6e43",
"reject": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<abc123@agentmail.to>",
"timestamp": "2023-10-27T10:09:00Z",
"reason": "Invalid recipient address"
}
}

If you send an email to a bounced address, rejected address, or complained address we prevent you from sending to this email address ever again to keep bounce rates low. AgentMail weighs bounce and complaint signals across your account and may suspend sending when they indicate a problem.

message.opened

  • Description: Triggered the first time a tracked message is opened. Requires track_opens on the send, a custom domain with tracking enabled, and an HTML body.
  • Use Case: Time a follow up, mark a conversation as engaged, or measure reply rates against opens.
{
"type": "event",
"event_type": "message.opened",
"event_id": "9e4b7d2a-3c8f-4e1b-b6a5-7f2d9c4e8a10",
"open": {
"inbox_id": "inbox_456def",
"thread_id": "thd_789ghi",
"message_id": "<abc123@agentmail.to>",
"timestamp": "2023-10-27T10:15:00Z"
}
}

Sent once per message, on the first open. Repeat opens do not resend it, so there are no open counts. One pixel is injected per message rather than per recipient, so a message with several recipients fires once when any of them opens it, and the event does not identify which one.

Open tracking is a directional signal, not proof of reading. Mail clients that block remote images suppress opens, and image proxies used by some providers and security scanners can register an open within seconds of delivery without a person seeing the message.

message.security.completed

  • Description: Triggered when Agent Armor reaches a verdict on a received message. Sent only for complete results, only to organizations that run Agent Armor in Monitor mode, and to webhooks that list it in event_types.
  • Use Case: Hold a message back from your agent until its verdict is in, and route review verdicts to a person.
{
"type": "event",
"event_type": "message.security.completed",
"event_id": "5b1e6f2c-8a43-5d0e-9f71-3c2a4b6d8e90",
"message": {
"organization_id": "7f0c2a52-6a8e-4d0e-9a5e-2f3b1c4d5e6f",
"inbox_id": "inbox_456def",
"message_id": "<abc123@agentmail.to>",
"agent_armor": {
"status": "complete",
"verdict": "review",
"reason": "The message asks the agent to ignore its instructions and send account details to an outside address.",
"model": "armor-2026-10-07",
"completed_at": "2023-10-27T10:00:04Z"
}
}
}

The event carries no message content: fetch the message for it. It is not sent for messages labeled spam, blocked, unauthenticated or trash, and requires permission to read messages.

message.security.review

  • Description: Triggered when Agent Armor in Hold mode holds a received message for review. Its message.received is not sent until someone releases it.
  • Use Case: Alert a person to check the message, then release it.
{
"type": "event",
"event_type": "message.security.review",
"event_id": "8d2f4b6a-1c3e-5a7b-9d0f-2e4c6a8b0d1f",
"message": {
"organization_id": "7f0c2a52-6a8e-4d0e-9a5e-2f3b1c4d5e6f",
"inbox_id": "inbox_456def",
"message_id": "<abc123@agentmail.to>",
"agent_armor": {
"status": "complete",
"verdict": "review",
"reason": "The message asks the agent to ignore its instructions and send account details to an outside address.",
"model": "armor-2026-10-07",
"completed_at": "2023-10-27T10:00:04Z"
}
}
}

The event carries no message content, requires permission to read messages, and is not sent for messages labeled spam, blocked, unauthenticated or trash. List held mail by the agent-armor-held label to see all of it.

message.security.override

  • Description: Triggered when someone releases a message Agent Armor held for review. Its message.received is sent as well, and the two can arrive in either order.
  • Use Case: Record who released held mail, when and why.
{
"type": "event",
"event_type": "message.security.override",
"event_id": "c4f1a2b3-7d6e-5f40-9a8b-1c2d3e4f5a6b",
"message": {
"organization_id": "7f0c2a52-6a8e-4d0e-9a5e-2f3b1c4d5e6f",
"inbox_id": "inbox_456def",
"message_id": "<abc123@agentmail.to>",
"manual_override": {
"override_id": "c4f1a2b3-7d6e-5f40-9a8b-1c2d3e4f5a6b",
"actor": { "kind": "console" },
"reason": "Known vendor; the payment details were confirmed by phone.",
"original_verdict": "review",
"released_at": "2023-10-27T10:20:00Z"
}
}
}

The event carries no message content, requires permission to read messages, and is not sent for messages labeled spam, blocked, unauthenticated or trash.

Domain Events

domain.verified

  • Description: Triggered when a domain is successfully verified and ready to use for sending emails.
  • Use Case: Automatically enable domain-specific features, update domain status, or trigger post-verification workflows.
{
"type": "event",
"event_type": "domain.verified",
"event_id": "domain.verified.example.com.1698397200000",
"domain": {
"domain_id": "dom_123abc",
"status": "verified",
"feedback_enabled": true,
"records": [
// ... DNS verification records
],
"created_at": "2023-10-27T09:00:00Z",
"updated_at": "2023-10-27T10:00:00Z"
}
}

Calendar Events

Calendar events report changes to an inbox’s calendar and fire when its events start and end. They require the calendar_event_read permission. See Calendar Webhooks for full payloads, timing and delivery guarantees.

EventTriggered when
calendar.event.createdA calendar event is created through the API or from a received invitation, or a date of a recurring event is edited for the first time.
calendar.event.updatedA calendar event, or one or more dates of a recurring event, changes. Also sent when dates of a recurring event are cancelled.
calendar.event.deletedA one-off or recurring calendar event is deleted.
calendar.event.respondedThe inbox responds to an invitation, or an attendee replies to an invitation the inbox sent.
calendar.event.startingA calendar event, or one date of a recurring event, starts.
calendar.event.endingA calendar event, or one date of a recurring event, ends.
{
"type": "event",
"event_type": "calendar.event.starting",
"event_id": "3f8a2c1e-6b4d-4e9f-a7c2-5d1b8e0f9a36_single.start",
"inbox_id": "scheduler@agentmail.to",
"calendar_event": {
"event_id": "3f8a2c1e-6b4d-4e9f-a7c2-5d1b8e0f9a36",
"kind": "single",
"title": "Intro call with Acme",
"start_at": "2026-10-15T18:00:00Z",
"end_at": "2026-10-15T18:30:00Z"
// ... the full calendar event
},
"scheduled_at": "2026-10-15T18:00:00Z"
}

Event Filtering

When creating a webhook, you can specify which events to subscribe to. This allows you to:

  • Reduce webhook traffic by only subscribing to events you need
  • Create specialized webhooks for specific workflows

For example, if you only need to trigger workflows on incoming messages, you can subscribe to just message.received. If you’re building a delivery tracking system, you might subscribe to message.sent, message.delivered, and message.bounced.

By default, spam, blocked, and unauthenticated events are not delivered. To receive them, you must explicitly include message.received.spam, message.received.blocked, or message.received.unauthenticated in the event_types list when creating your webhook. The API key must also have the corresponding label_spam_read, label_blocked_read, or label_unauthenticated_read permission.

If you have any specific webhook notifications you would like, please ping us in the #feature-requests channel in the Discord