Events (SSE) API
Real-time server-sent event stream for receiving events without webhooks.
GET /v1/events
Open a persistent SSE connection to receive events in real time. Authenticate with a Bearer token in the Authorization header and set Accept: text/event-stream.
Query parameters
| Name | Type | Description |
|---|---|---|
| mailboxId | string | Filter events to a specific mailbox |
| events | string | Comma-separated event types to subscribe to (e.g. message.received,message.bounced) |
Available events
message.receivedmessage.sentmessage.deliveredmessage.bouncedmessage.complaint
Connection limits
Maximum 5 concurrent SSE connections per user. Attempting to open a 6th connection returns 429 Too Many Requests.
Heartbeat & lifetime
The server sends a :heartbeat comment every 30 seconds to keep the connection alive. Connections have a maximum lifetime of approximately 4.5 minutes — before the upstream proxy timeout, the server sends an event: reconnect frame to signal the client to reconnect gracefully.
GET /v1/events?events=message.received,message.bounced
Authorization: Bearer rm_a1b2c3d4...
Accept: text/event-streamReconnecting with Last-Event-ID
Each SSE frame includes an id field. When reconnecting, pass the last received ID in the Last-Event-ID header. The server replays any missed events from its buffer.
The replay buffer holds the last 100 events with a 1-hour TTL. Events older than 1 hour or beyond the buffer size are no longer available for replay.
Replay provides at-least-once delivery: on reconnect, you may receive a small number of duplicate events that you already processed before disconnecting. Use the event id field to deduplicate if needed.
GET /v1/events
Authorization: Bearer rm_a1b2c3d4...
Accept: text/event-stream
Last-Event-ID: 1710700000000-0000-a3f1SSE frame format
Each event is delivered as a standard SSE frame with id, event, and data fields. The data field contains a JSON object.
id: 1710700000000-0000-a3f1
event: message.received
data: {"event":"message.received","timestamp":"2026-03-18T12:00:00.000Z","data":{"message_id":"b2c3d4e5-6789-4abc-def0-222222222222","mailbox_id":"a1b2c3d4-5678-4def-abcd-111111111111","mailbox_address":"agent@robotomail.co","from":"sender@example.com","to":["you@robotomail.co"],"subject":"Hello","received_at":"2026-03-18T12:00:00.000Z"}}The id field is {timestamp_ms}-{counter}-{random_4hex}. The data field is the same {event, timestamp, data} payload shape that webhook deliveries use.
Event payloads
Every event payload has the same envelope: {event, timestamp, data}. The data object varies by event type. All field names use snake_case.
message.received
{
"event": "message.received",
"timestamp": "2026-03-18T12:00:00.000Z",
"data": {
"message_id": "b2c3d4e5-6789-4abc-def0-222222222222",
"mailbox_id": "a1b2c3d4-5678-4def-abcd-111111111111",
"mailbox_address": "agent@robotomail.co",
"from": "sender@example.com",
"to": ["agent@robotomail.co"],
"cc": [],
"subject": "Hello",
"body_text": "Plain text body",
"body_html": "<p>HTML body</p>",
"thread_id": "c3d4e5f6-789a-4bcd-ef01-333333333333",
"in_reply_to": null,
"received_at": "2026-03-18T12:00:00.000Z",
"attachments": [
{
"id": "f7e8d9c0-1234-4abc-def0-555555555555",
"filename": "invoice.pdf",
"content_type": "application/pdf",
"size_bytes": 12345,
"content_id": null,
"download_url": "https://r2.example.com/...?signed-24h"
}
]
}
}When an inbound message exceeds the per-attachment size cap (25 MB) or the per-message attachment count cap (20), the over-cap parts are dropped and the payload includes two extra top-level fields under data:
{
"event": "message.received",
"timestamp": "2026-03-18T12:00:00.000Z",
"data": {
"message_id": "b2c3d4e5-6789-4abc-def0-222222222222",
// ... all the usual fields ...
"attachments": [
// any successfully ingested attachments
],
"attachments_dropped": true,
"attachments_dropped_reason": "size"
// values: "size" | "count" | "both"
}
}The two new fields are omitted entirely (not set to false or null) when no attachments were dropped. Customers should test for the presence of attachments_dropped, not for a falsy value.
Attachments on inbound messages: each entry includes a fresh download_url (presigned R2 URL, valid for 24 hours from when this delivery attempt was sent). For inline images, content_id matches the original Content-ID header (without angle brackets) so you can rewrite cid: URLs in the body_html yourself. See Inbound attachments for the size/count caps and the inline image rewrite example.
message.received — over inbound limit (stub)
When your account exceeds its monthly inbound cap (Free 100 / Developer 2,000 / Growth 20,000 / Scale unlimited), every subsequent inbound message still fires a message.received event, but the data object drops to a stub. The message itself is persisted safely — a subsequent upgrade or the monthly reset unlocks the full content on the list endpoint. The envelope {event, timestamp, data} is unchanged.
{
"event": "message.received",
"timestamp": "2026-03-18T12:00:00.000Z",
"data": {
"over_limit": true,
"message_id": "b2c3d4e5-6789-4abc-def0-222222222222",
"mailbox_id": "a1b2c3d4-5678-4def-abcd-111111111111",
"mailbox_address": "agent@robotomail.co",
"received_at": "2026-03-18T12:00:00.000Z",
"account": {
"inbound_usage": {
"current": 101,
"limit": 100,
"percentage": 101,
"reset_date": "2026-04-01T00:00:00.000Z",
"status": "limit_reached"
},
"upgrade": {
"browser_url": "https://robotomail.com/billing",
"api_endpoint": { "method": "POST", "path": "/v1/billing/upgrade" },
"hint": "POST /v1/billing/upgrade for a programmatic checkout URL, or visit browser_url to sign in and upgrade"
}
}
}
}Absent fields on stubs: no from, to, cc, subject, body_text, body_html, thread_id, in_reply_to, or attachments. Test for data.over_limit === true to branch, or check for the presence of data.account.upgrade — stubs always carry it, full payloads never do.
Approaching the limit:once you cross 50 % of your tier's monthly cap, full message.received payloads also carry an account.inbound_usage block (no upgradesub-field yet). Use this to surface a countdown in your agent's logs.
Quota behaviour
Stub deliveries flow through the normal webhook lifecycle —PENDING → DELIVERED — with the same HTTP retry policy as any other delivery. There is no new terminal state. The API single-read endpoints (GET /v1/mailboxes/:id/messages/:msgId and GET /v1/attachments/:id) return 402 Payment Required with code INBOUND_LIMIT_EXCEEDED for over-limit messages until you upgrade or the monthly reset lands. See the 402 reference for the response shape and headers.
message.sent
{
"event": "message.sent",
"timestamp": "2026-03-18T12:01:00.000Z",
"data": {
"message_id": "b2c3d4e5-6789-4abc-def0-222222222222",
"mailbox_id": "a1b2c3d4-5678-4def-abcd-111111111111",
"mailbox_address": "agent@robotomail.co",
"from": "agent@robotomail.co",
"to": ["recipient@example.com"],
"subject": "Re: Hello",
"thread_id": "c3d4e5f6-789a-4bcd-ef01-333333333333",
"sent_at": "2026-03-18T12:01:00.000Z"
}
}message.delivered
{
"event": "message.delivered",
"timestamp": "2026-03-18T12:01:05.000Z",
"data": {
"message_id": "b2c3d4e5-6789-4abc-def0-222222222222",
"mailbox_id": "a1b2c3d4-5678-4def-abcd-111111111111",
"mailbox_address": "agent@robotomail.co",
"recipients": ["recipient@example.com"],
"delivered_at": "2026-03-18T12:01:05.000Z"
}
}message.bounced
{
"event": "message.bounced",
"timestamp": "2026-03-18T12:01:10.000Z",
"data": {
"message_id": "b2c3d4e5-6789-4abc-def0-222222222222",
"mailbox_id": "a1b2c3d4-5678-4def-abcd-111111111111",
"mailbox_address": "agent@robotomail.co",
"bounced_recipients": ["invalid@example.com"],
"bounced_at": "2026-03-18T12:01:10.000Z"
}
}message.complaint
{
"event": "message.complaint",
"timestamp": "2026-03-18T12:02:00.000Z",
"data": {
"message_id": "b2c3d4e5-6789-4abc-def0-222222222222",
"mailbox_id": "a1b2c3d4-5678-4def-abcd-111111111111",
"mailbox_address": "agent@robotomail.co",
"complained_recipients": ["annoyed@example.com"],
"complained_at": "2026-03-18T12:02:00.000Z"
}
}Webhook deliveries use the exact same payload structure.
Naming convention: Event payloads (webhooks and SSE) use snake_case field names (e.g. message_id, body_text). API responses (e.g. GET /v1/mailboxes/:id/messages/:msgId) use camelCase (e.g. messageId, bodyText). This is the same convention used by Stripe.
Limitations
Redis reconnect gap: The SSE stream is backed by Redis pub/sub. During brief Redis reconnection windows (e.g. failover or network blip), connected clients may miss events silently — the connection stays open but no frames are delivered for the missed events.
If your use case requires exactly-once delivery, we recommend periodic reconciliation by polling GET /v1/mailboxes/:id/messages alongside the SSE stream to catch any gaps.