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 a paid account exceeds its monthly inbound cap (Solo 500 / Starter 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.

Free allows 10 receives per calendar month. Exchange mail only between your agent and your verified email address. Upgrade to email anyone. Mail outside these rules or above the receive allowance is discarded, with no backlog on renewal or upgrade. Discarded mail does not emit a stub event.

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": 501,
        "limit": 500,
        "percentage": 100,
        "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"],
    "bounce_type": "permanent",
    "bounce_subtype": "General",
    "bounced_at": "2026-03-18T12:01:10.000Z"
  }
}

Bounce fields

NameTypeDescription
bounce_type"permanent" | "transient" | "undetermined"permanent: the address can't receive mail, so it is added to your suppression list. transient: a temporary failure, such as a full mailbox; the address is not suppressed and may work later. undetermined: the bounce wasn't classified; it is handled like permanent.
bounce_subtypestring | nullThe provider's more specific reason, such as General, NoEmail, Suppressed or MailboxFull. Up to 64 characters, or null. The set of values can grow.

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.