Events (SSE) API

Real-time server-sent event stream for receiving events without webhooks.

GET /v1/events

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

NameTypeDescription
mailboxIdstringFilter events to a specific mailbox
eventsstringComma-separated event types to subscribe to (e.g. message.received,message.bounced)

Available events

  • message.received
  • message.sent
  • message.delivered
  • message.bounced
  • message.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.

request
GET /v1/events?events=message.received,message.bounced
Authorization: Bearer rm_a1b2c3d4...
Accept: text/event-stream

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

reconnect request
GET /v1/events
Authorization: Bearer rm_a1b2c3d4...
Accept: text/event-stream
Last-Event-ID: 1710700000000-0000-a3f1

SSE frame format

Each event is delivered as a standard SSE frame with id, event, and data fields. The data field contains a JSON object.

SSE frame example
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

payload — happy path
{
  "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:

payload — dropped attachments
{
  "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.

payload — over-limit stub
{
  "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

payload
{
  "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

payload
{
  "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

payload
{
  "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

payload
{
  "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.