Reference

The chat-service wire protocol

Everything a client needs to talk to a Paperkite chat server, straight from PROTOCOL.md in the chat-service repo. No WebSocket, no server-side sessions. A client's entire state is a JWT, a cursor, and, if polling, a backoff hint.

Overview

The service exposes two transports. There's no WebSocket, but two ways to receive messages, both built on the same per-room buffer:

TransportAddressPurpose
HTTP/JSON:8080Primary client protocol: connect, send, poll
gRPC:50051Alternative SendMessage RPC, same auth/semantics
  • GET /poll: short-interval long-polling with a server-computed backoff hint.
  • GET /events: a real Server-Sent Events stream for instant delivery.

Everything is stateless server-side aside from the in-memory per-room buffer and, for /events, the set of currently-connected live subscribers.

Identity & rooms

  • A room is md5(strip_trailing_slashes(url)). Two clients on the same URL land in the same room. There is no separate “create room” step.
  • Identity (username, browser, session_id, region) is asserted by the client at connect time and signed into a JWT. There's no password and no verification.
  • username is the one exception: the first successful claim is permanent, server-wide, case-insensitive, and survives restarts. A second username-based connect for a taken name gets 409.
  • A client that already holds a token can skip the claim entirely. Present it instead of username to join another room or mint a fresh token, with no 409.
  • The JWT never expires and isn't centrally revoked. “Disconnecting” is a client deciding to stop polling.

POST /connect

Establishes an identity and issues a JWT for a room. Exactly one of username / token must be present.

Request: claim a new username
{
  "url": "https://example.com/chat/room-1",
  "username": "alice",
  "browser": "Chrome/120",
  "session_id": "sess-abc123",
  "region": "us-east"
}
Response (200)
{
  "token": "eyJhbGciOi...",
  "cursor": 42
}
StatusCause
400missing required field, or invalid JSON
401token invalid, or its username no longer claimed
409username already claimed by anyone, ever
500server-side failure claiming/signing

POST /send

Publishes to the room encoded in the caller's token. Room and sender are never taken from the body.

Request
{ "content": "hello" }
Response (200)
{ "id": "1733950000000000000" }

No rate limiting of any kind. session_id is fully client-asserted, so a server-side per-session limit would only throttle well-behaved clients. Abuse mitigation lives at the network edge.

GET /poll?cursor={seq}

Fetches messages published after cursor. 304 means nothing new. 200 carries a batch and an advanced cursor.

Response (200)
{
  "messages": [
    {
      "id": "1733950000000000000",
      "seq": 43,
      "room": "5d41402abc4b2a76b9719d911017c592",
      "sender": "alice",
      "content": "hello",
      "timestamp": 1733950000123
    }
  ],
  "cursor": 43,
  "next_poll_ms": 1000
}

The server buffers up to 256 messages per room for up to 120s. A stale cursor returns the entire available buffer, not an error. Treat it as partial history.

GET /events?cursor={seq}

Upgrades to text/event-stream: catches up like /poll, then pushes every new message instantly.

Stream: one message
data: {"id":"1733950000000000000","seq":43,"room":"5d41402ab...","sender":"alice","content":"hello","timestamp":1733950000123}

A server-wide cap on live connections returns 503 once hit. A client should fall back to /poll instead of looping reconnects. Delivery is best-effort once live. A slow subscriber can have messages dropped for it alone.

gRPC

chat.Chat/SendMessage is a second entry point for publishing, functionally identical to POST /send. The JWT travels in the message body, not metadata.

proto/chat/chat.proto
service Chat {
  rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
}

message SendMessageRequest {
  string token   = 1; // JWT from /connect
  string content = 2;
}

message SendMessageResponse {
  string id = 1;
}

No gRPC poll or streaming RPC exists yet. gRPC clients still need the HTTP /poll loop to receive messages. Server reflection is enabled, so grpcurl/evans work without the .proto file.

Client tips

  • Choosing a transport: use /events for an interactive client. Fall back to /poll on a 503, a stream error, or an unplanned disconnect.
  • Polling loop: start at 1s, then follow X-Next-Poll-Ms exactly. The server already computed a backoff from room activity. Pause on visibilitychange.
  • Auth: persist the token durably. It never expires. On 401, reconnect with token, not username.
  • Multi-instance: the buffer and SSE fan-out are per-process today. /events needs sticky sessions behind a load balancer.
  • Don't build a WebSocket client. Don't parse id as a timestamp, even though it currently is one.