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 Railway 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":"[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
{
"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
{
"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
{
"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
{
"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
{
"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.