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#
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 |