API Design
Two types of API in a chat system
Not everything needs real-time. Sending and receiving messages needs WebSockets — the server must push without waiting for a request. Loading chat history and the inbox are request-response — the client asks once, the server responds. Use the right tool for each job.
The rule¶
Needs server to push unprompted? → WebSocket event
Request-response is fine? → REST API
Chat history and inbox don't change while you're looking at them in a way that requires a push. You open a conversation, load the history, done. REST is perfectly adequate. Message delivery is different — Bob can't poll for Alice's message, it has to arrive the moment she sends it. That needs WebSocket.
WebSocket Events¶
Client → Server: Send a message¶
When the user hits send, the client emits this event over the open WebSocket connection:
event: "message.send"
payload: {
conversation_id: "conv_abc123", // which conversation this belongs to
sender_id: "user_001", // who is sending
message_id: "msg_xyz789", // client-generated ID for deduplication
content: "hey", // the message text
timestamp: 1713087600000 // client timestamp (used for ordering)
}
Why conversation_id and not receiver_id?
conversation_id is the right abstraction. A conversation is the container — the server looks it up and knows who the participants are. Using receiver_id means the server has to reconstruct "which conversation are these two people in?" on every message. conversation_id gives you that answer immediately. It also future-proofs for group chat — a group conversation has N participants, there is no single receiver_id.
Why client-generated message_id?
The client generates the message ID before sending. This enables idempotency — if the network drops and the client retries, the server sees the same message_id and deduplicates rather than storing the message twice. Without this, a retry creates a duplicate message.
Server → Client: Deliver a message¶
When a message arrives for a user, the server pushes this event to the recipient's WebSocket connection:
event: "message.receive"
payload: {
conversation_id: "conv_abc123", // which conversation to display in
sender_id: "user_001", // who sent it
message_id: "msg_xyz789", // same ID as the send event
content: "hey",
timestamp: 1713087600000
}
Why sender_id in the push event?
The payload is self-contained — the client doesn't need to infer anything from context. sender_id tells the client whose avatar to show and which side of the chat to render the bubble on. It also future-proofs for group chat where multiple senders exist in the same conversation.
REST APIs¶
Fetch chat history¶
Called when a user opens a conversation. Loads messages in reverse chronological order (newest first) with cursor-based pagination.
GET /api/v1/chat/:conversation_id?cursor=<message_id>&limit=20
Response:
{
conversation_id: "conv_abc123",
messages: [
{
message_id: "msg_xyz789",
sender_id: "user_001",
content: "hey",
timestamp: 1713087600000
},
...
],
next_cursor: "msg_abc456"
}
Cursor-based, not offset-based pagination
The cursor is a message_id, not a page number. With offset pagination (page=2), if new messages arrive while the user is scrolling, the offset shifts — messages get skipped or duplicated. A cursor pointing to a specific message always returns exactly the messages before that point, regardless of new arrivals. next_cursor in the response is what the client passes on the next request to load older messages.
Fetch inbox¶
Called on app open. Returns all conversations for the user, sorted by most recent message first — this is the main chat list screen.
GET /api/v1/chats/:user_id
Response:
{
chats: [
{
conversation_id: "conv_abc123",
participant: "user_002", // the other person in the conversation
last_message: "hey", // preview text shown in inbox
timestamp: 1713087600000 // used for sort order
},
...
]
}
participant not sender_id/receiver_id
The client already knows it's the logged-in user. What it needs to know is who the other person is — their name, avatar, and last message preview. A single participant field gives the client exactly what it needs without redundant data.
Sorted by timestamp descending — most recent conversation at the top, satisfying FR #3.
Summary¶
| Operation | Protocol | Endpoint / Event |
|---|---|---|
| Send message | WebSocket | event: message.send |
| Receive message | WebSocket | event: message.receive |
| Load chat history | REST GET | /api/v1/chat/:conversation_id |
| Load inbox | REST GET | /api/v1/chats/:user_id |