Case Study: Messaging API
In-product chat: conversations, message history, read state, and live delivery to open clients.
Messaging forces a decision most APIs get to dodge: which operations belong on request/response and which need a persistent connection? The answer here is deliberately unglamorous — everything is REST except the one thing that can't be: learning that something happened *now*. Sends, history, read state are plain HTTP because they need retries, caching, and debuggability; a single WebSocket carries only wake-up events (WebSocket Message Contracts). The other defining problem is history pagination: messages arrive constantly, users scroll backwards, and offset pagination produces duplicated or skipped messages within seconds — the textbook case for cursors (Cursor Pagination: An Opaque Bookmark, Not a Position). Watch how often the design answer is "make the operation idempotent and let the client retry" rather than "make the network reliable".
Consumers
Instant delivery while a tab is open, infinite scroll backwards through years of history, and unread counts that match reality across devices.
Sends that survive tunnels and app suspension — a message tapped once must appear exactly once, no matter how many retries happened underneath.
Server-side integrations that post notifications and read history over plain HTTP with an API key — no WebSocket, no session state.
Requirements
- • List a user's conversations ordered by recent activity, with last-message preview and unread count.
- • Send a message; a client retry after a timeout must never create a duplicate.
- • Paginate backwards through history that is being appended to *while* the user scrolls — no gaps, no duplicates.
- • Per-user read state, updated from any device, consistent across all of them.
- • Sub-second delivery of new messages to connected clients; disconnected clients catch up losslessly on reconnect.
- • Bots do everything except live delivery over stateless HTTP.
Resources
The container and the authorization boundary — every message operation checks membership of the conversation, nothing else. Carries denormalized `last_message_at` because "order my conversations by activity" is the single hottest read.
Immutable once accepted (edits create revisions), identified by a server-assigned id *and* a server-assigned position in the conversation's total order — that position is what makes cursor pagination and reconnect catch-up exact.
A user's relationship to a conversation, and the natural home of per-user state: `last_read_position`, notification level. Read state on the membership (not the message) means marking read is one write, not N.
The realtime vocabulary: `message.created`, `read.updated`, each carrying the conversation position. Events are notifications *about* resources, deliberately thin — the REST resource remains the truth a client re-fetches when in doubt.
Operations
| Operation | Purpose | Design notes |
|---|---|---|
| GET /conversations | List the caller's conversations by recent activity. | Cursor-paginated on (last_message_at, id). Embeds last-message preview and unread count — without that, rendering an inbox is 1 + 2N requests, the classic chattiness failure (Over-Fetching and Under-Fetching). |
| POST /conversations | Create a conversation with initial members. | For DMs, creation is *idempotent on the member pair*: two clients "starting a chat" with the same person concurrently converge on one conversation (200 with the existing one) instead of racing into two. |
| POST /conversations/{id}/messages | Send a message. | Requires a client-generated client_key (UUID per send attempt-group). A retry with the same key returns the *original* message with 200 — the mobile tunnel scenario, solved in the contract rather than in every client (Idempotency vs Deduplication). Response includes the assigned position. |
| GET /conversations/{id}/messages | Page through history. | Cursor on the position sequence, direction: older | newer, default newest-first. Position cursors are exact under concurrent appends: scrolling up while messages arrive below never skips or repeats — the property offset pagination cannot offer here at any price (Cursor Pagination: An Opaque Bookmark, Not a Position). |
| POST /conversations/{id}/read | Advance the caller's read position. | A command carrying position, and *monotonic*: the server ignores moves backwards, so two devices racing (position: 118 after position: 120) can't make a conversation flip back to unread. Idempotent by construction — same position twice is a no-op. |
| GET /events | WebSocket upgrade: live events for all of the caller's conversations. | One connection per client, *not* one per conversation — connections are the scarce resource. On connect the client sends its last seen positions and receives a gap-fill, making reconnect lossless without a second sync protocol. |
| GET /conversations/{id}/members | List members and their roles. | Paginated — group conversations grow, and member lists are the second unbounded collection hiding in every chat design (Unbounded Collections: The Anti-Pattern With a Fuse). |
| DELETE /conversations/{id}/messages/{msgId} | Delete (tombstone) a message. | Tombstone rather than removal: positions must stay dense for cursor math, and other clients need a message.deleted event referencing something. Body content is gone; the slot remains. |
Error contract
| Code | Status | When | Retryable |
|---|---|---|---|
| NOT_PARTICIPANT | 403 | Any operation on a conversation the caller isn't a member of — one error for send, read, and list, because membership is the single boundary. | no |
| MESSAGE_TOO_LARGE | 413 | Body exceeds 64 KB. Attachments go through the upload flow and are *referenced* — a messaging API is not a file transfer API. | no |
| INVALID_CURSOR | 400 | Cursor is malformed or from an incompatible API version. Clients recover by restarting from the newest page — documented as the standard recovery, so nobody caches cursors as bookmarks. | no |
| CONVERSATION_ARCHIVED | 409 | Sending into an archived conversation. Reads still work — the state gates writes only, and the error names the unarchive operation. | no |
| RATE_LIMITED | 429 | Send rate exceeded (per-user, per-conversation). `Retry-After` set; bots get higher documented budgets on their keys. | after delay |
| EVENT_STREAM_STALE | 410 | On WS reconnect, the client's positions are older than the gap-fill window (7 days). Instructs a full resync via REST — the contract admits the stream buffer is finite instead of silently dropping history. | no |
Decision log
Decision → reason → alternative → trade-off. The alternative is part of the record.
curl-debuggability for free; the socket does the one thing HTTP can't: server push (Which API Style Should I Use?).message.created only — the one event where the follow-up fetch was universal.How it evolves
- • Reactions arrive as a sub-resource (
POST /messages/{id}/reactions) plus areaction.updatedevent — old clients ignore the unknown event type by documented rule and simply don't render reactions; nothing breaks. - • Threads reuse the conversation machinery: a thread is a conversation with a
parent_messagefield. No new pagination, read-state, or event contracts — the payoff of resources modeled around behavior rather than UI (From Domain to Resources). - • Edit history: messages gain
revisionand anedited_at;message.updatedjoins the event vocabulary. Immutability-plus-revisions was chosen in V1 partly because it makes this additive. - • Presence and typing ship as a separate ephemeral event class over the existing socket, explicitly excluded from gap-fill — the contract distinguishes durable events (messages) from ephemeral ones (typing) so the reconnect promise stays honest (Server-Sent Events).