Push Notifications & Real-Time Updates
Serverless Inbox uses a WebSocket channel to notify clients when data has changed. This is the mechanism that keeps a JMAP client in sync without polling.
How it fits into JMAP
Section titled “How it fits into JMAP”JMAP tracks object state with opaque state strings. Every Email, Mailbox, and other object type has a state string that advances whenever something changes. A client that wants to stay in sync must periodically call Email/changes (or similar) with its last known state to get a list of additions, modifications, and deletions.
The WebSocket channel does one thing: it tells clients when to make those calls. The server sends a stateChange notification that includes the new state string for each affected data type. The client compares this against what it has cached and issues the appropriate JMAP /changes request.
This means the WebSocket is not a data transport — it’s a signal channel. All actual data retrieval continues via standard JMAP over HTTP.
Connection lifecycle
Section titled “Connection lifecycle”sequenceDiagram participant Client participant APIGW as API Gateway (WS) participant Auth as Authorizer λ participant Connect as websocket-connect λ participant Message as websocket-message λ participant DDB as DynamoDB participant Broadcast as websocket-broadcast λ
Client->>APIGW: WSS connect (token in query string) APIGW->>Auth: Validate JWT Auth-->>APIGW: Allow APIGW->>Connect: $connect Connect->>DDB: Store connection (TTL 24h) Connect-->>Client: 200 (connected)
Client->>Message: {"subscribe": {...}} Message->>DDB: Update connection with account + types Message-->>Client: {"subscribed": {...}}
Note over Broadcast,Client: Data changes (email arrives, sent, etc.) Broadcast->>DDB: Query connections for accountId Broadcast-->>Client: {"stateChange": {...}}
Client->>APIGW: Disconnect APIGW->>DDB: Delete connection recordAuthentication
Section titled “Authentication”The WebSocket connection is authenticated via the same Lambda authorizer used by the REST APIs. Because browsers cannot send custom HTTP headers during a WebSocket handshake, the JWT token is passed using the Sec-WebSocket-Protocol header — a header that browsers do allow during the upgrade request.
The authorizer accepts the token in either of these formats:
Sec-WebSocket-Protocol: Bearer <token>Sec-WebSocket-Protocol: bearer, <token>The two-part comma-separated form (bearer, <token>) is the idiomatic pattern when the client also needs to negotiate a real subprotocol alongside the token. If the server echoes back bearer as the accepted subprotocol, clients should handle this gracefully.
The authorizer validates the token before the $connect Lambda runs. If the token is missing or invalid, the connection is rejected with HTTP 401 before any frame is exchanged.
Connection records are stored in DynamoDB with a 24-hour TTL as a safety net — a clean disconnect removes the record immediately.
Message protocol
Section titled “Message protocol”All frames are JSON text frames. The message type is indicated by the top-level key — there is no separate type discriminator field.
Only one message type is accepted from the client (subscribe). All others originate from the server.
subscribe — client → server
Section titled “subscribe — client → server”Sent after connecting to tell the server which account and data types to watch. A connection can only hold one subscription; sending a new subscribe replaces the previous one.
{ "subscribe": { "id": "req-1", "accountId": "abc123", "types": ["Email", "Mailbox"] }}| Field | Description |
|---|---|
id | Client-generated correlation ID, echoed back in the response |
accountId | The JMAP accountId to subscribe to |
types | Data types to watch. Omit or send [] to watch all types |
subscribed — server → client
Section titled “subscribed — server → client”Confirmation that the subscription was accepted.
{ "subscribed": { "id": "req-1" }}The id matches the correlation ID from the originating subscribe.
stateChange — server → client
Section titled “stateChange — server → client”Signals that one or more data types have a new state. The client should call the relevant JMAP */changes methods to retrieve the delta.
{ "stateChange": { "accountId": "abc123", "changes": { "Email": "n8", "Mailbox": "42" } }}| Field | Description |
|---|---|
accountId | The JMAP accountId where the change occurred |
changes | Map of data type to its new state string |
The client uses its previously cached state to call e.g. Email/changes with sinceState: <cached> — the server returns the list of IDs that were created, updated, or destroyed.
error — server → client
Section titled “error — server → client”Returned when a client message cannot be processed.
{ "error": { "id": "req-1", "code": "forbidden", "description": "Not authorized to subscribe to this account" }}code | Meaning |
|---|---|
forbidden | The authenticated user cannot access the requested accountId |
invalidArguments | The message could not be parsed or a required field is missing |
Note on the JMAP WebSocket standard
Section titled “Note on the JMAP WebSocket standard”RFC 8887 defines a WebSocket subprotocol for JMAP that allows full JMAP API requests and responses to be sent over the WebSocket connection — effectively replacing the HTTP transport entirely.
Serverless Inbox deliberately does not implement RFC 8887. The principle is to keep the WebSocket channel simple and unidirectional: standard JMAP over HTTP handles all API calls, and the WebSocket carries only server-initiated push notifications. This keeps the Lambda routing straightforward and avoids multiplexing request/response semantics over a persistent connection.
RFC 8887 support could be added in the future if client implementors need it.
Related
Section titled “Related”- System Architecture — how the WebSocket infrastructure fits into the overall system
- Email Processing Pipeline — what triggers state changes on the inbound path
- Email Sending Pipeline — what triggers state changes on the outbound path
- WebSocket Events Reference — quick-reference table of all message types