Webhooks
Register an HTTPS endpoint and let AgentMail push signed events to your agent the moment mail arrives, bounces, or gets opened.
A webhook is a subscription: you register an HTTPS URL and AgentMail sends it a signed POST request every time a subscribed event happens, so your agent reacts in seconds instead of asking the API over and over.
Use WebSockets when your agent has no public URL, which is common in local development and for desktop agents. They carry the same events over a persistent connection with no tunnel or hosting required.
Events reference
| Event | Fires when | Use it for |
|---|---|---|
message.received | A received email is processed into one of your inboxes | Standard inbound mail workflows |
message.received.spam | A received email is classified as spam | Spam review or quarantine workflows |
message.received.blocked | A received email matches a block list entry from your inbound rules | Block list auditing |
message.received.unauthenticated | A received email arrives without authentication headers, so the sender cannot be verified | Deciding whether to trust unverified senders |
message.sent | A message successfully leaves AgentMail’s servers | Sent-mail tracking and follow-up workflows |
message.delivered | The recipient’s mail server confirms it accepted the message | Delivery tracking |
message.bounced | A sent message fails to deliver and bounces back | Suppression and remediation |
message.complained | A recipient reports your message as spam | Complaint handling and sender reputation |
message.rejected | A send is rejected before it goes out (validation or policy) | Send validation handling |
message.opened | A tracked message is opened for the first time | Timing follow-ups, measuring engagement |
domain.verified | A custom domain finishes verification | Starting work that waited on the domain |
What to know when picking from this list:
- The spam, blocked, and unauthenticated variants replace
message.receivedfor the mail they cover. A message classified as spam fires onlymessage.received.spam, nevermessage.received, so include each variant your agent should see. - Subscribing to
message.received.spam,message.received.blocked, ormessage.received.unauthenticatedrequires the API key creating the webhook to hold the matching label permission (label_spam_read,label_blocked_read, orlabel_unauthenticated_read). message.sentfires for your own outgoing mail. An inbound agent that subscribes to it must not treat those events as new work, or it will answer its own messages in a loop.message.sentandmessage.deliveredare different stages:sentmeans the message left AgentMail’s servers,deliveredmeans the receiving server answered that it accepted it. Delivered does not mean it landed in the recipient’s primary inbox. The provider decides placement after accepting.message.openedfires once per message, on the first open. It requirestrack_openson the send, a custom domain with tracking enabled, and an HTML body. One tracking pixel is injected per message, so a message with several recipients fires a single event without identifying who opened it. Clients that block remote images suppress opens, and image proxies can register an open within seconds of delivery without anyone reading the message.- After a hard bounce, spam complaint, or unsubscribe, AgentMail stops future sends to that address to protect your deliverability. Keep your account bounce rate under 2 percent. A rate above 10 percent across 50 or more recent sends flags the account for review.
Create a webhook
Creating a webhook registers your URL and the events it should receive. url and event_types are required, everything else narrows or annotates the subscription. The full parameter list is below the code.
agentmail webhooks create \
--url "https://worker.example.com/webhooks" \
--event-type message.received \
--event-type message.bounced \
--client-id "inbound-agent-v1"| Param | Type | What it means |
|---|---|---|
url | string | The HTTPS endpoint deliveries go to. Plain http URLs are rejected. |
event_types | string[] | The full list of event types to deliver, at least one. The webhook receives exactly the types you list. |
client_id | string | Your own stable id for this subscription. Creating again with the same client_id returns the existing webhook instead of a duplicate, which makes creation safe to retry. |
inbox_ids | string[] | Only deliver events for these inboxes. Omit both filters to receive events for the whole organization. |
pod_ids | string[] | Only deliver events for these pods. |
headers | map of string to string | Custom HTTP headers sent with every delivery. Values are write-only. See custom headers. |
A webhook can watch at most 10 inboxes and pods combined. A retried create with a known client_id returns the existing webhook as it is and ignores any new url or event_types in the request. Use update to change an existing subscription.
The response is the webhook object. webhook_id addresses every management call that follows, and secret is what your receiver verifies signatures with. Save the secret in your receiver’s secret store right away.
{
"organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d",
"webhook_id": "ep_1a2b3c4d5e6f7a8b9c0d1e2f3a4",
"client_id": "inbound-agent-v1",
"url": "https://worker.example.com/webhooks",
"event_types": ["message.bounced", "message.received"],
"secret": "whsec_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"enabled": true,
"updated_at": "2026-08-25T09:00:00Z",
"created_at": "2026-08-25T09:00:00Z"
}enabled tells you whether deliveries are active, see what happens when deliveries keep failing. If you lose the secret, get the webhook by id to read it again.
You can also create a webhook from the Console: open Webhooks in the sidebar, click Create Webhook, paste your URL, pick the events, and copy the signing secret.
Verify the signature on every delivery
Without verification, anyone who discovers your URL can post fake events and trigger your agent. Every delivery is signed with the webhook’s secret (it starts with whsec_) and carries three headers:
| Header | What it carries |
|---|---|
svix-id | Unique delivery id. Retries of the same event reuse it. |
svix-timestamp | Unix timestamp in seconds when the delivery was sent. |
svix-signature | One or more signatures, space separated, each in the form v1,<base64>. |
AgentMail delivers through Svix, so the svix libraries verify deliveries with one call. A minimal receiver that verifies before it does anything else:
// npm install express svix
import express from "express";
import { Webhook } from "svix";
const app = express();
const verifier = new Webhook(process.env.AGENTMAIL_WEBHOOK_SECRET!);
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event: any;
try {
event = verifier.verify(req.body, req.headers as Record<string, string>);
} catch {
return res.status(400).send();
}
// hand the verified event to your queue or worker here
console.log(event.event_type, event.event_id);
res.status(200).send();
});
app.listen(3000);Run it with npx tsx server.ts (or python server.py for the Flask version) and it listens on port 3000. What makes verification succeed or fail:
- The signature covers the exact raw bytes of the body. A parsed and re-serialized payload fails the check, which is the most common cause of verification errors.
- Header names are matched case-insensitively. When the headers are missing entirely, a reverse proxy in front of your service is usually stripping them.
- Deliveries with a timestamp more than 5 minutes off fail verification, which blocks replayed requests. A wrong server clock trips the same check.
- Answer a failed verification with a
400and do nothing else with the payload. Do not parse it, queue it, or call the AgentMail API with its contents.
Handle the event
Every payload carries the same envelope: type is always "event", event_type names what happened, event_id identifies this event for deduplication, and one event-specific object holds the details.
message.received and its spam, blocked, and unauthenticated variants are the only events that carry full data: a message object with the complete email and a thread object with conversation metadata such as message_count. If you need more metadata on other event types, email support@agentmail.cc.
{
"type": "event",
"event_type": "message.received",
"event_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d",
"message": {
"organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d",
"inbox_id": "example@agentmail.to",
"thread_id": "2b3c4d5e-6f7a-4b2c-9d3e-4f5a6b7c8d9e",
"message_id": "<010001a02e0497eb-1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d-000000@email.amazonses.com>",
"labels": ["received"],
"timestamp": "2026-08-25T09:00:00Z",
"from": "You <you@example.com>",
"from_": "You <you@example.com>",
"to": ["example@agentmail.to"],
"subject": "Question about my account",
"preview": "Hi, I have a question about...",
"text": "Hi, I have a question about my account.",
"html": "<html>...</html>",
"attachments": [
{
"attachment_id": "3c4d5e6f-7a8b-4c3d-8e4f-5a6b7c8d9e0f",
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size": 123456,
"inline": false
}
],
"created_at": "2026-08-25T09:00:00Z",
"updated_at": "2026-08-25T09:00:00Z"
},
"thread": {
"inbox_id": "example@agentmail.to",
"thread_id": "2b3c4d5e-6f7a-4b2c-9d3e-4f5a6b7c8d9e",
"senders": ["You <you@example.com>"],
"recipients": ["example@agentmail.to"],
"subject": "Question about my account",
"last_message_id": "<010001a02e0497eb-1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d-000000@email.amazonses.com>",
"message_count": 1,
"created_at": "2026-08-25T09:00:00Z",
"updated_at": "2026-08-25T09:00:00Z"
}
}Reading the payload:
- The sender appears as both
fromandfrom_. The underscore variant exists for Python, wherefromis a reserved word. textandpreviewcan be absent when the sender’s client produced HTML-only mail, common with Gmail and Outlook forwards. Treathtmlas the primary content source andtextas optional.- Attachment entries are metadata only. Download the file through the attachments call with the message’s ids.
- The other events carry one small object named for the stage instead of
messageandthread:sendanddeliveryhold the message ids andrecipients,bounceadds atype,sub_type, and per-recipient status,complainthas its owntypeandsub_type,rejecthas areason,openhas the message ids and the opentimestamp, anddomainhas thedomain_id,status, and DNSrecords.
Most events arrive with the complete text and html, so your agent can act without another call. When a message’s stored body is larger than 64 KB, which heavy HTML and inline images reach quickly, the event leaves the inline body out and carries only the metadata. When text and html are missing, fetch the message.
The event’s message.message_id is the same id the messages API uses everywhere, in RFC 822 form with angle brackets. Pass it together with the event’s message.inbox_id:
agentmail inboxes:messages get \
--inbox-id "example@agentmail.to" \
--message-id "<message_id>"A message_id contains <, >, and @, so URL-encode it when you build the API path by hand. The CLI and SDKs encode it for you.
Acknowledge delivery
A delivery counts as acknowledged only when your endpoint returns a 2xx status within 15 seconds. Anything else is a failure, including 3xx redirects. After a failure the same event is retried automatically: immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours after each preceding attempt. The final retry happens about 28 hours after the event, and a delivery that fails every attempt is marked failed and stops.
Design your receiver around that contract:
- Delivery is at least once. A receiver that times out or errors after doing the work sees the same event again, with the same
svix-idheader and the sameevent_idin the payload. - Deduplicate the business action, not just the delivery. For inbound mail, record the event’s
message.message_idin a durable store with a unique constraint before queuing the work. When the insert conflicts, the event is a duplicate: acknowledge it with a200without queuing a second job, because the original delivery already owns that message. - Return success only after the deduplication record and the queue handoff are durable. Acknowledging first and crashing loses the event, since an acknowledged delivery is never sent again.
- An endpoint that fails every delivery for about 5 days is disabled automatically. Its
enabledflag turnsfalseand deliveries stop. Fix the receiver, then create a fresh webhook and delete the disabled one.
Scope a webhook to a pod or inbox
An organization webhook covers everything unless you narrow it with inbox_ids and pod_ids. When one tenant maps to one pod, or one agent maps to one inbox, create the webhook directly under that resource instead. The scope then comes from the path:
- An inbox-scoped webhook is fixed to its inbox. Only its
event_typescan change later. - A pod-scoped webhook covers the whole pod, optionally narrowed to specific inboxes in it with
inbox_ids. The pod itself cannot change later. - An organization webhook can have its event types, inboxes, and pods all adjusted later.
# narrow an organization webhook to one pod
agentmail webhooks create \
--url "https://worker.example.com/webhooks" \
--event-type message.received \
--pod-id '["1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d"]'The scoped routes carry the same list, get, update, delete, and header operations as the organization ones, under /v0/pods/<pod_id>/webhooks and /v0/inboxes/<inbox_id>/webhooks.
Scoped webhooks pair with pod- and inbox-scoped API keys: such a key sees and manages only the webhooks within its scope, and a webhook it creates is automatically pinned there. Two rules follow from that:
- A scoped key cannot pass
inbox_idsorpod_idsoutside its own scope. A pod-scoped key may passinbox_idsto narrow within its pod. - A scoped webhook must always keep at least one inbox or pod subscription.
List and inspect your webhooks
Results cover every webhook visible to your API key, newest first, with limit, page_token, and ascending as optional paging parameters. List entries carry everything except the secret. To read a webhook’s secret again, get it by its webhook_id:
agentmail webhooks list
agentmail webhooks get --webhook-id "<webhook_id>"count is the number of webhooks in the current page, and a next_page_token appears when more remain:
{
"count": 1,
"webhooks": [
{
"organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d",
"webhook_id": "ep_1a2b3c4d5e6f7a8b9c0d1e2f3a4",
"client_id": "inbound-agent-v1",
"url": "https://worker.example.com/webhooks",
"event_types": ["message.bounced", "message.received"],
"enabled": true,
"updated_at": "2026-08-25T09:00:00Z",
"created_at": "2026-08-25T09:00:00Z"
}
]
}Update a webhook
Updating changes what an existing subscription receives. Event types are replaced wholesale, while inbox and pod subscriptions change through add and remove lists. All parameters are optional:
agentmail webhooks update \
--webhook-id "<webhook_id>" \
--event-type '["message.received", "message.delivered"]'| Param | Type | What it means |
|---|---|---|
event_types | string[] | Replaces the subscribed list in full, the same “set the whole list” behavior as create. It is not a merge or diff, so send every type you want after the update. Omit it to leave the types unchanged. |
add_inbox_ids | string[] | Inboxes to subscribe to the webhook. |
remove_inbox_ids | string[] | Inboxes to unsubscribe from the webhook. |
add_pod_ids | string[] | Pods to subscribe to the webhook. |
remove_pod_ids | string[] | Pods to unsubscribe from the webhook. |
The rules from create carry over, plus a few of update’s own:
- You cannot clear the event types, so a webhook always keeps at least one type.
- Adding the spam or blocked variants on update requires the same label permissions as on create.
- The combined inbox and pod count stays capped at 10, and a scoped webhook cannot drop its last subscription.
- The
urlis fixed for the webhook’s lifetime. To point a subscription at a new URL, create a webhook with the new URL and delete the old one.
The response is the updated webhook object, so you can confirm the resulting event_types, inbox_ids, and pod_ids in one look.
Delete a webhook
Deleting a webhook stops its deliveries immediately and permanently. Events that occur afterwards are not held for it, so create the replacement first when you are migrating receivers.
agentmail webhooks delete --webhook-id "<webhook_id>"A successful delete returns 204 with no body.
Send custom headers with every delivery
Headers are configured per webhook. The usual uses are an Authorization value your infrastructure already checks and routing metadata for a gateway. Header values are write-only: AgentMail sends them to your endpoint but never returns them from any read.
Set headers when creating the webhook, read the configured names with the headers call, and rotate values atomically with the header update:
curl -X POST "https://api.agentmail.to/v0/webhooks" \
-H "Authorization: Bearer $AGENTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://worker.example.com/webhooks",
"event_types": ["message.received"],
"headers": { "Authorization": "Bearer hook-token", "X-Environment": "production" }
}'
# names only, values are never returned
curl "https://api.agentmail.to/v0/webhooks/<webhook_id>/headers" \
-H "Authorization: Bearer $AGENTMAIL_API_KEY"
# rotate one value and drop another header in one atomic call
curl -X PATCH "https://api.agentmail.to/v0/webhooks/<webhook_id>/headers" \
-H "Authorization: Bearer $AGENTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"headers": { "Authorization": "Bearer rotated-token" },
"remove_headers": ["X-Environment"]
}'The same headers field and header calls exist on the pod- and inbox-scoped routes (pods.webhooks and inboxes.webhooks in the SDKs). The rules:
- The
headersmap must have at least one entry when provided, and every name and value must be a valid HTTP header. - The headers read returns names only. Since a lost value cannot be recovered, keep the values in a secret manager and rotate them with the header update.
- The header update sets, replaces, and removes in one atomic call. Provide at least one of
headersorremove_headers, and it answers204with no body.
{
"header_names": ["Authorization", "X-Environment"]
}General webhook best practices
Whatever framework or host your receiver runs on:
-
Accept deliveries only over HTTPS. AgentMail rejects plain
httpURLs at creation, and any hosting provider with a stable public HTTPS URL and environment variables works (Render, Railway, Replit, Fly.io, and the like). Make the webhook route accept onlyPOST, listen on the port your platform provides, and add a separate unauthenticated health route (aGET /healththat returns200) so you can confirm the service is reachable before wiring AgentMail to it. -
Verify the signature before anything else touches the payload, and answer a failed check with a
400. The check needs the exact raw bytes of the request body, so parse the JSON only after verification succeeds. On platforms that parse the body for you, such as Vercel, read the unmodified request body before callingrequest.json(). -
Handle events idempotently. Delivery is at least once, so deduplicate on the payload’s
event_id, or on the message id for inbound mail, before your agent acts. Acknowledge delivery shows the pattern. -
Respond fast and queue heavy work. You have 15 seconds to return a
2xx, so hand model calls and replies to a background queue instead of running them inside the request handler. -
Keep secrets out of URLs, client-side code, and logs. Store the webhook’s
secretin an environment variable likeAGENTMAIL_WEBHOOK_SECRETor in your platform’s secret settings. If you lose it, read it back by id. -
Rotate custom headers regularly. When your infrastructure checks an
Authorizationvalue on each delivery, keep that value in a secret manager and rotate it with the atomic header update, since a header value can never be read back. -
For local development, tunnel a public HTTPS URL to your machine with ngrok (
ngrok http 3000, matching your receiver’s port) and create a webhook for the tunnel URL. Free sessions end after 2 hours and a restarted tunnel gets a new URL, so create a webhook for the new URL and delete the one pointing at the dead tunnel. The tunnel is for AgentMail’s deliveries; open the receiver locally throughhttp://127.0.0.1:3000. If nothing arrives, confirm ngrok forwards to the port your receiver listens on and the webhookurlpath matches your route.