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:
| Transport | Address | Purpose |
|---|---|---|
| HTTP/JSON | :8080 | Primary client protocol: connect, send, poll |
| gRPC | :50051 | Alternative 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.
usernameis the one exception: the first successful claim is permanent, server-wide, case-insensitive, and survives restarts. A secondusername-based connect for a taken name gets409.- A client that already holds a token can skip the claim entirely. Present it instead of
usernameto join another room or mint a fresh token, with no409. - 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.
{
"url": "https://example.com/chat/room-1",
"username": "alice",
"browser": "Chrome/120",
"session_id": "sess-abc123",
"region": "us-east"
}{
"token": "eyJhbGciOi...",
"cursor": 42
}| Status | Cause |
|---|---|
| 400 | missing required field, or invalid JSON |
| 401 | token invalid, or its username no longer claimed |
| 409 | username already claimed by anyone, ever |
| 500 | server-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.
{ "content": "hello" }{ "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.
{
"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.
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.
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
/eventsfor an interactive client. Fall back to/pollon a503, a stream error, or an unplanned disconnect. - Polling loop: start at 1s, then follow
X-Next-Poll-Msexactly. The server already computed a backoff from room activity. Pause onvisibilitychange. - Auth: persist the token durably. It never expires. On
401, reconnect withtoken, notusername. - Multi-instance: the buffer and SSE fan-out are per-process today.
/eventsneeds sticky sessions behind a load balancer. - Don't build a WebSocket client. Don't parse
idas a timestamp, even though it currently is one.