WebSockets
Stream events to an agent that cannot expose a public HTTP endpoint, over one outbound connection.
AgentMail can tell your agent about mail three ways. Pick the transport that matches how the agent runs:
- Use WebSockets when the agent has no public HTTP endpoint, which is common for local and desktop agents. The agent opens one outbound connection and events are pushed over it. They also fit an agent waiting briefly for an expected reply: hold the connection open, act on the event, then close it.
- Use webhooks when a server can accept requests at a public HTTPS endpoint. They are the durable choice for server-side delivery in production.
- Keep polling when periodic checks are enough and delayed detection is acceptable.
A WebSocket is a live delivery channel, not an event archive. Events that fire while you are disconnected are not replayed, so reconcile through the mail API after every interruption before treating the connection as current again. Disconnection and recovery shows how.
Events reference
A connection can deliver these event types. The payload field names where the details sit in the frame:
| Event type | Payload field | Fires when |
|---|---|---|
message.received | message, thread | A message arrives in a subscribed inbox |
message.received.spam | message, thread | A received message is classified as spam |
message.received.blocked | message, thread | A received message matches a block rule |
message.received.unauthenticated | message, thread | A received message could not be verified as coming from its claimed sender |
message.sent | send | A message leaves an inbox |
message.delivered | delivery | The recipient’s mail server accepts a sent message |
message.bounced | bounce | A sent message bounces |
message.complained | complaint | A recipient reports a sent message as spam |
message.rejected | reject | A send is rejected before it goes out |
message.opened | open | A tracked message is opened for the first time |
domain.verified | domain | A custom domain finishes verification |
message.opened fires once per message, on the first open of an HTML message sent with open tracking from a custom domain that has tracking enabled.
The spam, blocked, and unauthenticated variants describe mail that inboxes hide by default, so they are delivered only when the API key that opened the connection holds the matching label permission:
message.received.spamrequireslabel_spam_readmessage.received.blockedrequireslabel_blocked_readmessage.received.unauthenticatedrequireslabel_unauthenticated_read
A key created without an explicit permissions object holds every permission. Permissions are fixed on the connection when it opens. The same rule applies with no filter at all, where the restricted variants are left out of the stream when the key lacks the permission.
Connect and authenticate
Your agent opens the connection outward, authenticates during the handshake, and keeps the socket open while events stream in. One connection carries everything that follows on this page:
Open and authenticate the connection
Connect to wss://ws.agentmail.to/v0 with the same API key you use for the rest of the API. Any of these forms authenticates the handshake:
- an
Authorization: Bearer <api_key>header - an
api_keyquery parameter, for clients that cannot set headers on the handshake - an
auth_tokenquery parameter, which works the same asapi_key
The SDKs pass the key for you when you create the client.
# interactive raw connection
npx wscat -c "wss://ws.agentmail.to/v0" \
-H "Authorization: Bearer $AGENTMAIL_API_KEY"
# or authenticate in the query when you cannot set headers
npx wscat -c "wss://ws.agentmail.to/v0?api_key=$AGENTMAIL_API_KEY"The sync Python client’s connect() is a plain context manager, so open it with with. async with works only on the AsyncAgentMail client shown in the async tab, where every socket call takes an await.
TypeScript’s connect() resolves once the socket is open by default, so you can send frames immediately. On a raw connection, wait for the WebSocket open event before sending anything.
Subscribe to inboxes, pods, or your whole scope
A new connection is silent. Deliveries start when you send a subscribe frame naming what you want events for, and what a connection may subscribe to follows the key that opened it:
- An organization-scoped key can subscribe to any inbox or pod in the organization, or to the whole organization at once.
- A pod-scoped key can subscribe to its pod and the inboxes inside it.
- An inbox-scoped key can subscribe only to its own inbox.
# send on the open wscat connection:
{"type":"subscribe","inbox_ids":["example@agentmail.to"],"event_types":["message.received"]}| Param | Type | What it means |
|---|---|---|
type | string | Always subscribe. |
inbox_ids | string[] | Inboxes to stream events for. inboxIds in TypeScript. |
pod_ids | string[] | Pods to stream events for. A pod subscription covers every inbox in the pod. podIds in TypeScript. |
event_types | string[] | Deliver only these event types. See the Events reference. |
Everything except type is optional. A frame with neither inbox_ids nor pod_ids subscribes to the key’s whole scope: the organization for an organization key, the pod for a pod key, the inbox for an inbox key.
Without an event_types filter, a subscription delivers every event type in the Events reference that the key’s permissions allow. Filter when your agent acts on only some of them, so the stream carries only what it handles.
Use inbox_ids for one or a small set of named inboxes, and pod_ids when the agent owns every inbox in a pod. Subscribe at the narrowest scope that covers the agent’s work instead of subscribing broadly and filtering in your own code.
Wait for the subscribed confirmation
The server confirms each subscribe frame with a subscribed frame that echoes the scope. Wait for it before you rely on delivery. You can send more subscribe frames later to add scopes to the same connection.
{
"type": "subscribed",
"inbox_ids": ["example@agentmail.to"],
"organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d"
}Handle events
Everything the gateway sends is a JSON frame. Check type first:
subscribedandunsubscribedconfirm your own frameseventcarries a mail or domain event, withevent_typenaming what happenederrorreports a frame that failed
For an event, the payload sits under the field named in the Events reference. An event for a received message carries the full message object, body included, plus a thread summary of the conversation it belongs to, so your agent can usually act without another API call.
{
"type": "event",
"event_type": "message.received",
"event_id": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"message": {
"inbox_id": "example@agentmail.to",
"thread_id": "7d54a1cf-20b8-4e19-9d63-52c40a8e2b91",
"message_id": "<010001a03838d5af-6ff5c14f-55b4-48c8-9762-5611b5657b56-000000@email.amazonses.com>",
"labels": ["received", "unread"],
"timestamp": "2026-08-25T09:20:43.000Z",
"from": "You <you@example.com>",
"to": ["example@agentmail.to"],
"subject": "Order 4512 refund",
"preview": "Hi, could you refund order 4512?",
"text": "Hi, could you refund order 4512?",
"html": "<div>Hi, could you refund order 4512?</div>",
"extracted_text": "Hi, could you refund order 4512?",
"size": 4599,
"created_at": "2026-08-25T09:20:44.223Z",
"updated_at": "2026-08-25T09:20:44.223Z"
},
"thread": {
"inbox_id": "example@agentmail.to",
"thread_id": "7d54a1cf-20b8-4e19-9d63-52c40a8e2b91",
"labels": ["received", "unread"],
"senders": ["You <you@example.com>"],
"recipients": ["example@agentmail.to"],
"subject": "Order 4512 refund",
"last_message_id": "<010001a03838d5af-6ff5c14f-55b4-48c8-9762-5611b5657b56-000000@email.amazonses.com>",
"message_count": 1,
"size": 4599,
"created_at": "2026-08-25T09:20:44.223Z",
"updated_at": "2026-08-25T09:20:44.223Z"
}
}The fields you will reach for:
message.message_idis what you pass to reply, from the sameinbox_idthat received the message.thread_idgroups the conversation, and thethreadobject summarizes it, includingmessage_countand who is involved.event_ididentifies this event. Store it and skip duplicates.labelsshows the classification. The restricted receive variants carryspam,blocked, orunauthenticatedalongsidereceived.
The SDKs parse each frame into a typed object: match with isinstance in Python and narrow on event.type and event.eventType in TypeScript, as in the subscribe example. If you prefer callbacks to iterating in Python, register them with socket.on using the EventType enum from agentmail.core.events (open, message, error, close) and run socket.start_listening(). In TypeScript, socket.on takes the same four events as strings, and socket.close() ends the connection.
Disconnection and recovery
When one task finishes but other subscriptions still need the connection, stop deliveries for the finished scope with an unsubscribe frame instead of closing the socket. It names the same inbox_ids or pod_ids you subscribed with, and a frame with neither removes the subscription to the key’s whole scope that a bare subscribe created. The same scope rules apply, so an inbox-scoped connection cannot unsubscribe from pods.
# send on the open wscat connection:
{"type":"unsubscribe","inbox_ids":["example@agentmail.to"]}The server confirms with an unsubscribed frame echoing the scope you removed:
{
"type": "unsubscribed",
"inbox_ids": ["example@agentmail.to"],
"organization_id": "1a2b3c4d-5e6f-4a1b-8c2d-3e4f5a6b7c8d"
}In TypeScript, read the confirmation off the raw frame: it reaches your message handler with type: "unsubscribed" and snake_case keys, and the SDK logs a validation warning you can ignore.
Closing the connection ends every subscription on it, so unsubscribe only when the connection stays open for other scopes.
Losing the connection is the harder case. A subscription confirms only the connection it was made on, and events that fire before you resubscribe never reach the socket, so reconnecting alone is not enough for workflows that must see every message.
Store enough state to know which inboxes you were watching and when you last processed an event. On a close or connection error: wait with backoff, connect again, resubscribe, wait for the subscribed confirmation, then reconcile through the mail API before treating new socket events as current. Two reconcile queries cover most agents:
- If your agent removes the
unreadlabel once a message is handled, list messages withlabels=unreadin each subscribed inbox and work through what is left. - Otherwise, keep the
timestampof the last processed message as a checkpoint and list messages withafterset to it.
Make handling idempotent with the event’s event_id or the message’s message_id, since a message can arrive once over the socket and again during reconciliation.
lastProcessed = loadCheckpoint()
while running:
connect()
subscribe(savedScope, savedEventTypes)
waitForSubscribed()
reconcileMailStateAfter(lastProcessed)
for event in socket:
if event is already processed:
continue
process(event)
saveCheckpoint(event)
wait(nextBackoffDelay())Connection limits
Three limits shape how you spread work across connections:
- One connection holds up to 100 subscriptions, and each entry in
inbox_idsorpod_idscounts as one (a bare subscribe of the key’s whole scope counts as one too). - An organization can hold up to 1,000 concurrent connections.
- Every connection has a 24-hour TTL.
Keep one connection per agent process when its subscription set fits within 100 entries. Group inboxes by pod, or subscribe to the key’s whole scope, when that reduces entries without exposing events the process should not receive. Spread larger workloads across a bounded connection pool, leave headroom below the 1,000-connection organization limit, and recreate each connection before its 24-hour lifetime ends. Every replacement connection must repeat the subscribe confirmation and the reconciliation step.