Skip to content

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