# WebSockets (/advanced/websockets)

<!-- agent-signals: reading_time_min: 7 · est_tokens: 2951 · updated: 2026-09-06 -->
Related: [Webhooks](/advanced/webhooks.md), [Agent safety](/advanced/safety.md), [Custom domains](/advanced/custom-domains.md), [Deliverability and warmup](/advanced/deliverability.md), [Build a multi-tenant platform](/advanced/multi-tenant.md), [AgentID public-key authentication](/advanced/agentid.md)



# Stream AgentMail events over a WebSocket

One outbound connection to `wss://ws.agentmail.to/v0` carries the same mail events as webhooks, pushed as JSON frames. Use it when the agent has no public HTTP endpoint, common for local and desktop agents, or to hold a connection briefly while waiting for an expected reply.

## Do this

Connect and authenticate during the handshake:

```bash
npx wscat -c "wss://ws.agentmail.to/v0" \
  -H "Authorization: Bearer $AGENTMAIL_API_KEY"
```

Send a subscribe frame on the open connection:

```json
{"type":"subscribe","inbox_ids":["example@agentmail.to"],"event_types":["message.received"]}
```

Wait for the `subscribed` confirmation frame, then act on `event` frames as they arrive. On any close or connection error: wait with backoff, reconnect, resubscribe, wait for `subscribed` again, then reconcile missed mail through the mail API before treating new socket events as current. Reconcile by listing messages with `labels=unread` when the agent clears `unread` after handling, otherwise list with `after` set to the last processed `timestamp`. Deduplicate on `event_id` or `message_id`, because a message can arrive once over the socket and again during reconciliation.

## SDK

Install: `npm install agentmail` (TypeScript), `pip install agentmail` (Python).

| Operation | TypeScript                                                                     | Python                                                                                                                                              |
| --------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Connect   | `const socket = await client.websockets.connect()`                             | `with client.websockets.connect() as socket:` on `AgentMail`, `async with` on `AsyncAgentMail`                                                      |
| Subscribe | `socket.sendSubscribe({...})` with `inboxIds`, `podIds`, `eventTypes`          | `socket.send_subscribe(Subscribe(inbox_ids=[...], event_types=[...]))`, `Subscribe` imported from `agentmail`                                       |
| Receive   | `socket.on("message", handler)`, narrow on `event.type` then `event.eventType` | `for event in socket:` with `isinstance` checks, or `socket.on(...)` plus `socket.start_listening()` using `EventType` from `agentmail.core.events` |
| Close     | `socket.close()`                                                               | leave the `with` block                                                                                                                              |

TypeScript `connect()` resolves once the socket is open, so frames can be sent immediately. Install details: `/integrations/sdks-and-cli`.

## Facts

* Connection URL: `wss://ws.agentmail.to/v0`. Authenticate the handshake with an `Authorization: Bearer <api_key>` header, an `api_key` query parameter, or an `auth_token` query parameter. All three forms authenticate the same way.
* A handshake without a valid key is rejected with HTTP `403`. Missing, mistyped, and revoked keys all get the same `403`.
* Client frames: `subscribe` and `unsubscribe`. Server frames: `subscribed`, `unsubscribed`, `event`, and `error`. Check `type` first on every incoming frame.
* Subscribe frame fields: `type` (required, always `subscribe`), `inbox_ids`, `pod_ids`, `event_types` (all optional). A pod subscription covers every inbox in the pod.
* A subscribe 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, the subscription delivers every event type the key's permissions allow.

Complete event catalog, with the frame field that holds the payload:

| 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                                      |

* Scope rules: an organization-scoped key subscribes to any inbox or pod or the whole organization, a pod-scoped key to its pod and the inboxes inside it, an inbox-scoped key only to its own inbox.
* Restricted variants need label permissions on the key that opened the connection: `message.received.spam` needs `label_spam_read`, `message.received.blocked` needs `label_blocked_read`, `message.received.unauthenticated` needs `label_unauthenticated_read`. A key created without an explicit permissions object holds every permission.
* Permissions are fixed when the connection opens. With no `event_types` filter, restricted variants the key lacks are silently left out of the stream.
* Event frame envelope: `type` is `event`, `event_type` names what happened, `event_id` identifies the event for deduplication, and the payload sits under the field named in the catalog.
* A received-message event carries the full `message` object, body included, plus a `thread` summary with `message_count`. Restricted receive variants carry `spam`, `blocked`, or `unauthenticated` in `labels` alongside `received`.
* `message.message_id` is what a reply takes, together with the same `inbox_id` that received the message.
* `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.
* One connection holds up to 100 subscriptions. Each entry in `inbox_ids` or `pod_ids` counts as one, and a bare whole-scope subscribe counts as one.
* An organization holds up to 1,000 concurrent connections. A connection past that limit fails the handshake.
* Every connection has a 24-hour TTL. Recreate each connection before its lifetime ends, resubscribe, wait for `subscribed`, and reconcile.
* Events that fire while disconnected are never replayed. A subscription is valid only on the connection it was made on.
* An unsubscribe frame names the same `inbox_ids` or `pod_ids` as the subscribe. A frame with neither removes the whole-scope subscription a bare subscribe created. The server confirms with an `unsubscribed` frame echoing the removed scope.
* Closing the connection ends every subscription on it. Unsubscribe only when the connection stays open for other scopes.
* More subscribe frames can be sent later to add scopes to the same connection.
* In TypeScript the `unsubscribed` confirmation reaches the message handler as a raw frame with snake\_case keys, and the SDK logs a validation warning that is safe to ignore.

## Not supported

* No replay and no backfill. Events that fire while disconnected never reach the socket, so reconcile through the mail API after every interruption.
* Permissions cannot change on a live connection. A `forbidden` error frame for a restricted event type is fixed by reconnecting with a key that holds the permission, not by sending another frame.
* An inbox-scoped connection cannot name `pod_ids` in subscribe or unsubscribe frames.
* A frame whose `type` is neither `subscribe` nor `unsubscribe` gets `{"message": "Forbidden"}` back instead of an `error` frame.
* A subscription does not carry over to a new connection, and no connection outlives the 24-hour TTL.
* The sync Python client's `connect()` is a plain context manager. `async with` works only on the `AsyncAgentMail` client.

## Errors

| Error                      | Status or frame         | Cause                                                                                               | Fix                                                                               |
| -------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `403`                      | HTTP handshake response | Missing, mistyped, or revoked API key                                                               | Connect again with a valid key                                                    |
| `not_found`                | `error` frame           | An id in `inbox_ids` or `pod_ids` is not visible to the key                                         | Fix the typo or use a key whose scope covers the resource                         |
| `validation_error`         | `error` frame           | A malformed field, for example an event type that does not exist                                    | Correct the field named in the `errors` array and resend                          |
| `forbidden`                | `error` frame           | An inbox-scoped connection named `pod_ids`, or a restricted event type without its label permission | Reconnect with a pod- or organization-scoped key, or a key holding the permission |
| `limit_exceeded`           | `error` frame           | The frame would take the connection past 100 subscriptions                                          | Split scopes across connections or subscribe by pod                               |
| `{"message": "Forbidden"}` | raw frame               | Frame `type` is not `subscribe` or `unsubscribe`                                                    | Check the `type` spelling                                                         |

`error` frames carry the same `code`, `message`, and `fix` fields as API errors.

## Verify

```bash
npx wscat -c "wss://ws.agentmail.to/v0?api_key=$AGENTMAIL_API_KEY"
```

On the open connection send `{"type":"subscribe","inbox_ids":["<inbox_id>"]}`. A frame with `"type": "subscribed"` echoing the `inbox_ids` and the `organization_id` confirms the key, the connection, and the subscription in one round trip.

## Related

* `/advanced/webhooks` for durable signed delivery of the same events to a public HTTPS endpoint.
* `/advanced/safety` for keeping untrusted mail content from steering the agent.
* `/core/receive` for the list calls used to reconcile after a disconnect.
* `/advanced/errors` for the shared `code`, `message`, and `fix` error envelope.
