Skip to content
AgentMail
AgentMail
Advanced

WebSockets

Stream events to an agent that cannot expose a public HTTP endpoint, over one outbound connection.

If you don’t have an API key yet, follow the Quickstart first.

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 typePayload fieldFires when
message.receivedmessage, threadA message arrives in a subscribed inbox
message.received.spammessage, threadA received message is classified as spam
message.received.blockedmessage, threadA received message matches a block rule
message.received.unauthenticatedmessage, threadA received message could not be verified as coming from its claimed sender
message.sentsendA message leaves an inbox
message.delivereddeliveryThe recipient’s mail server accepts a sent message
message.bouncedbounceA sent message bounces
message.complainedcomplaintA recipient reports a sent message as spam
message.rejectedrejectA send is rejected before it goes out
message.openedopenA tracked message is opened for the first time
domain.verifieddomainA 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.spam requires label_spam_read
  • message.received.blocked requires label_blocked_read
  • message.received.unauthenticated requires label_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_key query parameter, for clients that cannot set headers on the handshake
  • an auth_token query parameter, which works the same as api_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"]}
ParamTypeWhat it means
typestringAlways subscribe.
inbox_idsstring[]Inboxes to stream events for. inboxIds in TypeScript.
pod_idsstring[]Pods to stream events for. A pod subscription covers every inbox in the pod. podIds in TypeScript.
event_typesstring[]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.

Sample response
{
  "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:

  • subscribed and unsubscribed confirm your own frames
  • event carries a mail or domain event, with event_type naming what happened
  • error reports 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.

Sample event frame
{
  "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_id is what you pass to reply, from the same inbox_id that received the message.
  • thread_id groups the conversation, and the thread object summarizes it, including message_count and who is involved.
  • event_id identifies this event. Store it and skip duplicates.
  • labels shows the classification. The restricted receive variants carry spam, blocked, or unauthenticated alongside received.

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:

Sample response
{
  "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 unread label once a message is handled, list messages with labels=unread in each subscribed inbox and work through what is left.
  • Otherwise, keep the timestamp of the last processed message as a checkpoint and list messages with after set 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.

Reconnect and reconcile pseudocode
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_ids or pod_ids counts 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.

Next Steps

Was this page helpful?Suggest editsRaise issue