Skip to content

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.


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.


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 record

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.


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.

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"]
}
}
FieldDescription
idClient-generated correlation ID, echoed back in the response
accountIdThe JMAP accountId to subscribe to
typesData types to watch. Omit or send [] to watch all types

Confirmation that the subscription was accepted.

{
"subscribed": {
"id": "req-1"
}
}

The id matches the correlation ID from the originating subscribe.

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"
}
}
}
FieldDescription
accountIdThe JMAP accountId where the change occurred
changesMap 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.

Returned when a client message cannot be processed.

{
"error": {
"id": "req-1",
"code": "forbidden",
"description": "Not authorized to subscribe to this account"
}
}
codeMeaning
forbiddenThe authenticated user cannot access the requested accountId
invalidArgumentsThe message could not be parsed or a required field is missing

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.