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 Railway 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":"[email protected]","from":"[email protected]","to":["[email protected]"],"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
{
  "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": "[email protected]",
    "from": "[email protected]",
    "to": ["[email protected]"],
    "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"
  }
}

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": "[email protected]",
    "from": "[email protected]",
    "to": ["[email protected]"],
    "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": "[email protected]",
    "recipients": ["[email protected]"],
    "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": "[email protected]",
    "bounced_recipients": ["[email protected]"],
    "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": "[email protected]",
    "complained_recipients": ["[email protected]"],
    "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.