Two lanes, and a client holds one connection on each.
| Chat lane | Notification lane | |
|---|---|---|
| Carries | messages, typing, receipts | "something landed in your feed" |
| Lifetime | while a thread is open | always on |
| Inbound | send, read, typing, focus, since, pong |
pong |
They are split because they want opposite reconnect policies, not because of routing. The notification pipe should reconnect patiently forever and keep a badge honest while the user is anywhere in the app or nowhere in it; the chat pipe is screen-scoped, carries events that are meaningless outside an open thread, and should not be held open when no thread is.
Each lane offers a WebSocket and an SSE stream carrying identical payloads. A client picks one transport per lane.
This is still not one connection per chat. The envelope's ref says which thread an
event belongs to, so the chat lane is one connection no matter how many journeys are
active.
seq is the ordering, and eventId is how you resumeEvery durable event carries eventId = "<chatId>:<seq>". Send the last one back as
Last-Event-ID (SSE) or ?since= (WebSocket) and the server replays what you missed from
the database — so a two-second gap and a two-day gap are the same code path.
Ephemeral events carry eventId: null. Never advance your stored position on one, or a
reconnect resumes from a place no message ever occupied and silently skips messages.
A connection is keyed by the session in the token, not by the user. A second connection presenting the same session on the same lane replaces the first. A user on a phone and a tablet holds two per lane and receives every envelope on both.
Revocation reaches an open connection: the session is re-checked on every heartbeat, so a signed-out device's sockets — both lanes — close within one beat.
Production. Caddy terminates TLS and proxies the upgrade through.
The ordinary access token on the handshake — the same credential every REST call uses, through the same filter. There is no connection-token endpoint and no query-parameter credential.
For browsers only, which cannot set headers on a WebSocket:
new WebSocket(url, ['bearer', accessToken])
// → Sec-WebSocket-Protocol: bearer, <jwt>
Accepted only on an upgrade, never on an ordinary request, and the server echoes
bearer back as the negotiated subprotocol — a client that offers a subprotocol the
server does not echo closes the connection itself.
It is the same token in a different header: same issuer, same 15-minute lifetime, same per-heartbeat revocation check. Not a second credential.
(Modelled as userPassword because AsyncAPI 3 has no scheme for a subprotocol-carried
bearer; the description is the contract.)
Local development.
The ordinary access token on the handshake — the same credential every REST call uses, through the same filter. There is no connection-token endpoint and no query-parameter credential.
For browsers only, which cannot set headers on a WebSocket:
new WebSocket(url, ['bearer', accessToken])
// → Sec-WebSocket-Protocol: bearer, <jwt>
Accepted only on an upgrade, never on an ordinary request, and the server echoes
bearer back as the negotiated subprotocol — a client that offers a subprotocol the
server does not echo closes the connection itself.
It is the same token in a different header: same issuer, same 15-minute lifetime, same per-heartbeat revocation check. Not a second credential.
(Modelled as userPassword because AsyncAPI 3 has no scheme for a subprotocol-carried
bearer; the description is the contract.)
Full-duplex, and screen-scoped: open it when a thread opens, close it when the user leaves chat. Nothing on this lane matters when no thread is open.
Query parameters are read at the handshake and cannot be changed without reconnecting
— except focus and since, which the frames of the same names re-declare on a live
socket.
?focus=chat:{chatId} — what the user is looking at. Suppresses the redundant push
and the notification-lane signal for that thread, and marks the resulting feed row
read so the badge stays exact.
Unknown focus fails open: you get both.?since=<eventId> — replay position, the WebSocket equivalent of Last-Event-ID.
An event id is {chatId}:{seq}, so it names one thread. Opening a second one does
not need a reconnect: send a since frame instead.Every envelope the chat lane pushes.
Available only on servers:
Accepts one of the following messages:
{
"type": "chat-message",
"eventId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e:6",
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"messageId": "829d8baa-573e-4ee0-b9b4-d234f9896654"
},
"data": {
"id": "829d8baa-573e-4ee0-b9b4-d234f9896654",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"seq": 6,
"kind": "TEXT",
"senderId": "11111111-aaaa-4aaa-8aaa-000000000001",
"body": {
"text": "See you by the clock"
},
"clientMessageId": "7f609b51-e1dd-4d74-b479-dcd810640d9f",
"createdAt": "2026-08-28T13:42:27.453470Z"
},
"durable": true
}
Ephemeral. Never stored, never pushed, server-debounced.
{
"type": "chat-typing",
"eventId": null,
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e"
},
"data": {
"userId": "11111111-aaaa-4aaa-8aaa-000000000001"
},
"durable": false
}
Ephemeral. This is what moves the sender's ticks.
{
"type": "chat-receipt",
"eventId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e:6",
"ref": {
"journeyId": "7a0b9313-7bec-4206-be51-61a977e770e9",
"requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
"offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d",
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d",
"messageId": "8540d774-4863-4d2b-b788-4ecb19412e85"
},
"data": {
"userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
"readUpToSeq": 0
},
"durable": true
}
Every ~10 seconds. It is **also the server's liveness check**: the same beat re-runs the session check, so a revoked device's connection closes within one beat.
{
"type": "heartbeat",
"eventId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e:6",
"ref": {
"journeyId": "7a0b9313-7bec-4206-be51-61a977e770e9",
"requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
"offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d",
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d",
"messageId": "8540d774-4863-4d2b-b788-4ecb19412e85"
},
"data": {
"at": "2019-08-24T14:15:22Z"
},
"durable": true
}
Carries the **same stable token the REST path returns** — `CONTENT_BLOCKED`, `CHAT_NOT_FOUND`, `CHAT_CLOSED`, `INVALID_MESSAGE` — so a client branches on one vocabulary whichever lane it used. A bad frame does **not** close the socket: it carries a chat screen, and dropping it over one malformed message would cost the user every subsequent message too.
{
"type": "error",
"eventId": null,
"ref": {},
"data": {
"frame": "send",
"error": "CONTENT_BLOCKED",
"message": "this message cannot be sent"
},
"durable": false
}
Full-duplex, and screen-scoped: open it when a thread opens, close it when the user leaves chat. Nothing on this lane matters when no thread is open.
Query parameters are read at the handshake and cannot be changed without reconnecting
— except focus and since, which the frames of the same names re-declare on a live
socket.
?focus=chat:{chatId} — what the user is looking at. Suppresses the redundant push
and the notification-lane signal for that thread, and marks the resulting feed row
read so the badge stays exact.
Unknown focus fails open: you get both.?since=<eventId> — replay position, the WebSocket equivalent of Last-Event-ID.
An event id is {chatId}:{seq}, so it names one thread. Opening a second one does
not need a reconnect: send a since frame instead.The five frames the chat socket accepts.
send and read are a second write path onto the same ChatService the REST
endpoints call — the frame handler holds no validation, no authorisation and no
persistence of its own, which is what stops the two entry points drifting apart.
A frame that fails returns an error envelope carrying the same stable token the REST
path would have put in ApiError, so a client branches on one vocabulary.
Available only on servers:
Accepts one of the following messages:
The same command the REST body carries. **Always include `clientMessageId`**: it is the idempotency key, and without it a retry creates a duplicate.
{
"type": "send",
"data": {
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"clientMessageId": "7f609b51-e1dd-4d74-b479-dcd810640d9f",
"kind": "TEXT",
"text": "See you by the clock"
}
}
Monotonic and idempotent — a lower value is ignored, so two devices racing is harmless.
{
"type": "read",
"data": {
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d",
"upToSeq": 0
}
}
**Socket only — there is deliberately no REST equivalent.** One request per keystroke is the volume this avoids, so an SSE client receives typing and cannot send it. The server debounces regardless of what a client sends.
{
"type": "typing",
"data": {
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d"
}
}
Suppresses the redundant push for the thread on screen. **Only the socket can re-declare without reconnecting**; SSE declares once in its query string. Unknown focus fails open.
{
"type": "focus",
"data": {
"focus": "string"
}
}
Asks for everything after `seq` in one thread. **Socket only** — SSE has no inbound channel and re-declares by reconnecting with `Last-Event-ID`.
An event id is {chatId}:{seq}, so a connection's replay position belongs to one
thread. Open a second one and send this rather than reconnecting, which would cost your
live position on every other thread.
Nothing is remembered: this triggers a replay and returns. Sending it twice sends the same messages twice — deduplicate by message id, which you must do anyway, because your own optimistic bubble and the server's row are the same message.
A thread you do not participate in, or a malformed id, replays nothing. It is not an error: you are already connected, and refusing over a stale cursor would be worse than sending nothing.
Bounded — see replay-truncated.
{
"type": "since",
"data": {
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d",
"seq": 0
}
}
Optional. The server's own beat is what decides liveness.
{
"type": "pong"
}
An HTTP text/event-stream carrying exactly the same envelopes as the chat socket.
Differences from the socket, and only these:
typing has no REST endpoint either,
because one request per keystroke is the volume the design avoids.focus can only be declared once, in the query string.Last-Event-ID header rather than ?since=, and cannot
re-declare it — there is no inbound channel, so switching threads means reconnecting
with a different Last-Event-ID. The socket's since frame has no SSE equivalent, and
that asymmetry is in the transport rather than in the design.Authorization: Bearer only. A browser's EventSource cannot set
headers, so a browser must read the stream through fetch() and parse the framing.The same chat envelopes, over `text/event-stream`.
Available only on servers:
Accepts one of the following messages:
{
"type": "chat-message",
"eventId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e:6",
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"messageId": "829d8baa-573e-4ee0-b9b4-d234f9896654"
},
"data": {
"id": "829d8baa-573e-4ee0-b9b4-d234f9896654",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"seq": 6,
"kind": "TEXT",
"senderId": "11111111-aaaa-4aaa-8aaa-000000000001",
"body": {
"text": "See you by the clock"
},
"clientMessageId": "7f609b51-e1dd-4d74-b479-dcd810640d9f",
"createdAt": "2026-08-28T13:42:27.453470Z"
},
"durable": true
}
Ephemeral. Never stored, never pushed, server-debounced.
{
"type": "chat-typing",
"eventId": null,
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e"
},
"data": {
"userId": "11111111-aaaa-4aaa-8aaa-000000000001"
},
"durable": false
}
Ephemeral. This is what moves the sender's ticks.
{
"type": "chat-receipt",
"eventId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e:6",
"ref": {
"journeyId": "7a0b9313-7bec-4206-be51-61a977e770e9",
"requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
"offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d",
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d",
"messageId": "8540d774-4863-4d2b-b788-4ecb19412e85"
},
"data": {
"userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
"readUpToSeq": 0
},
"durable": true
}
Every ~10 seconds. It is **also the server's liveness check**: the same beat re-runs the session check, so a revoked device's connection closes within one beat.
{
"type": "heartbeat",
"eventId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e:6",
"ref": {
"journeyId": "7a0b9313-7bec-4206-be51-61a977e770e9",
"requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
"offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d",
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d",
"messageId": "8540d774-4863-4d2b-b788-4ecb19412e85"
},
"data": {
"at": "2019-08-24T14:15:22Z"
},
"durable": true
}
Always on. Hold it for as long as the app is running, reconnect with patient backoff, and never tie its lifetime to a screen. It exists so the badge is right without polling.
Inbound it accepts pong and nothing else — there is no write path onto a feed.
A frame is a signal, not a rendering: it tells you
something happened and where it points, and you refetch GET /v0.1/api/v1/notifications for
the title and body. Copy is composed at read time, in your language, against a reveal
permission that changes with the clock — so a body baked into a pushed frame would be
wrong for one of those three reasons.
?since=<cursor> replays missed notifications from the feed's own keyset cursor.
Feed signals and connection frames.
Available only on servers:
Accepts one of the following messages:
A signal plus a deep link. Refetch the feed for copy.
{
"type": "notification",
"eventId": null,
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416"
},
"data": {
"notificationId": "4c2f7a10-6d2e-4a1f-9d2e-2a7b1c8e5f30",
"subtype": "request-accepted",
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416"
},
"unreadCount": 7,
"createdAt": "2026-09-07T09:14:02.118934Z"
},
"durable": false
}
{
"type": "notification",
"eventId": null,
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"messageId": "829d8baa-573e-4ee0-b9b4-d234f9896654"
},
"data": {
"notificationId": "91b0d5c4-3f77-4f0a-8a55-6e2d4b9c1a02",
"subtype": "chat-message",
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"messageId": "829d8baa-573e-4ee0-b9b4-d234f9896654"
},
"unreadCount": 8,
"createdAt": "2026-09-07T09:15:44.902311Z"
},
"durable": false
}
Every ~10 seconds. It is **also the server's liveness check**: the same beat re-runs the session check, so a revoked device's connection closes within one beat.
{
"type": "heartbeat",
"eventId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e:6",
"ref": {
"journeyId": "7a0b9313-7bec-4206-be51-61a977e770e9",
"requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
"offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d",
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d",
"messageId": "8540d774-4863-4d2b-b788-4ecb19412e85"
},
"data": {
"at": "2019-08-24T14:15:22Z"
},
"durable": true
}
Carries the **same stable token the REST path returns** — `CONTENT_BLOCKED`, `CHAT_NOT_FOUND`, `CHAT_CLOSED`, `INVALID_MESSAGE` — so a client branches on one vocabulary whichever lane it used. A bad frame does **not** close the socket: it carries a chat screen, and dropping it over one malformed message would cost the user every subsequent message too.
{
"type": "error",
"eventId": null,
"ref": {},
"data": {
"frame": "send",
"error": "CONTENT_BLOCKED",
"message": "this message cannot be sent"
},
"durable": false
}
Always on. Hold it for as long as the app is running, reconnect with patient backoff, and never tie its lifetime to a screen. It exists so the badge is right without polling.
Inbound it accepts pong and nothing else — there is no write path onto a feed.
A frame is a signal, not a rendering: it tells you
something happened and where it points, and you refetch GET /v0.1/api/v1/notifications for
the title and body. Copy is composed at read time, in your language, against a reveal
permission that changes with the clock — so a body baked into a pushed frame would be
wrong for one of those three reasons.
?since=<cursor> replays missed notifications from the feed's own keyset cursor.
`pong`, and nothing else. There is no write path onto a feed.
Available only on servers:
Accepts the following message:
Optional. The server's own beat is what decides liveness.
{
"type": "pong"
}
The notification lane over text/event-stream. Same payloads as the notification socket,
receive-only, resuming with Last-Event-ID rather than ?since=.
The same signals, over `text/event-stream`.
Available only on servers:
Accepts one of the following messages:
A signal plus a deep link. Refetch the feed for copy.
{
"type": "notification",
"eventId": null,
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416"
},
"data": {
"notificationId": "4c2f7a10-6d2e-4a1f-9d2e-2a7b1c8e5f30",
"subtype": "request-accepted",
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416"
},
"unreadCount": 7,
"createdAt": "2026-09-07T09:14:02.118934Z"
},
"durable": false
}
{
"type": "notification",
"eventId": null,
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"messageId": "829d8baa-573e-4ee0-b9b4-d234f9896654"
},
"data": {
"notificationId": "91b0d5c4-3f77-4f0a-8a55-6e2d4b9c1a02",
"subtype": "chat-message",
"ref": {
"journeyId": "edbffd9a-65ab-48e7-ae68-4bc6b5bee416",
"chatId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e",
"messageId": "829d8baa-573e-4ee0-b9b4-d234f9896654"
},
"unreadCount": 8,
"createdAt": "2026-09-07T09:15:44.902311Z"
},
"durable": false
}
Every ~10 seconds. It is **also the server's liveness check**: the same beat re-runs the session check, so a revoked device's connection closes within one beat.
{
"type": "heartbeat",
"eventId": "b9af51de-a221-4aa6-be8e-2bb1a186cd4e:6",
"ref": {
"journeyId": "7a0b9313-7bec-4206-be51-61a977e770e9",
"requestId": "d385ab22-0f51-4b97-9ecd-b8ff3fd4fcb6",
"offerId": "42da58f8-9040-4cf5-98ce-bce9965cca0d",
"chatId": "f255124e-3419-4f6e-b7ee-17a6577db94d",
"messageId": "8540d774-4863-4d2b-b788-4ecb19412e85"
},
"data": {
"at": "2019-08-24T14:15:22Z"
},
"durable": true
}
A signal plus a deep link. Refetch the feed for copy.
Ephemeral. Never stored, never pushed, server-debounced.
Ephemeral. This is what moves the sender's ticks.
Every ~10 seconds. It is **also the server's liveness check**: the same beat re-runs the session check, so a revoked device's connection closes within one beat.
Carries the **same stable token the REST path returns** — `CONTENT_BLOCKED`, `CHAT_NOT_FOUND`, `CHAT_CLOSED`, `INVALID_MESSAGE` — so a client branches on one vocabulary whichever lane it used. A bad frame does **not** close the socket: it carries a chat screen, and dropping it over one malformed message would cost the user every subsequent message too.
The same command the REST body carries. **Always include `clientMessageId`**: it is the idempotency key, and without it a retry creates a duplicate.
Monotonic and idempotent — a lower value is ignored, so two devices racing is harmless.
**Socket only — there is deliberately no REST equivalent.** One request per keystroke is the volume this avoids, so an SSE client receives typing and cannot send it. The server debounces regardless of what a client sends.
Suppresses the redundant push for the thread on screen. **Only the socket can re-declare without reconnecting**; SSE declares once in its query string. Unknown focus fails open.
Asks for everything after `seq` in one thread. **Socket only** — SSE has no inbound channel and re-declares by reconnecting with `Last-Event-ID`.
An event id is {chatId}:{seq}, so a connection's replay position belongs to one
thread. Open a second one and send this rather than reconnecting, which would cost your
live position on every other thread.
Nothing is remembered: this triggers a replay and returns. Sending it twice sends the same messages twice — deduplicate by message id, which you must do anyway, because your own optimistic bubble and the server's row are the same message.
A thread you do not participate in, or a malformed id, replays nothing. It is not an error: you are already connected, and refusing over a stale cursor would be worse than sending nothing.
Bounded — see replay-truncated.
The gap was larger than one replay may push. Page the rest over `GET /v0.1/api/v1/chats/{chatId}/messages`.
Ephemeral, so it carries no id and does not move your Last-Event-ID — a durable
one would leave you resuming from a position no message ever occupied.
fromSeq is the seq of the first message not sent, so ask REST for everything from
there. You will meet what you already have and can stop.
You get this rather than silence on purpose: a truncated replay you cannot detect is a hole in a thread, which is the failure this whole replay design exists to prevent.
Optional. The server's own beat is what decides liveness.
Deprecated — follow deepLink instead. The raw navigation ids, as the notification
feed and the push payload also publish them. It carries several because a chat event
needs three at once: the journey to authorise against, the thread to open, the message
to scroll to.
It is kept for one release so a push delivered before deepLink existed still
navigates, and is removed after that.
Where tapping this goes. One ontheway:// URI, resolved server-side, and the same
string the notification feed and the push payload carry — so a client follows one link
rather than inferring a destination from which ids happen to be present, and the three
pipes cannot disagree about where a tap lands.
| Subtype | Opens |
|---|---|
request-incoming |
ontheway://offers/{offerId} |
request-accepted, journey-*, rating-received |
ontheway://journeys/{journeyId} |
request-expired |
ontheway://requests/{requestId} |
chat-message |
ontheway://journeys/{journeyId}/chat |
request-incoming opens your offer, not the request: you are an Offerer, and the
request is the Seeker's own row.
One message. Identical to MessageResponse in the OpenAPI document.
What the notification lane pushes. Enough to draw a banner without fetching anything: the words, where tapping goes, and the badge.
title and body are composed when the frame is sent, in your stored language and
against the reveal state at that instant — which is the same way a push payload is
composed, and safe in the same direction: a frame sent before identities unlock carries
the masked name, and cannot be un-sent by the unlock.
So a frame's words and the feed's can differ across the T-60 reveal, for the same notification — and today it is the frame that names the person while the centre, opened a moment later, may still show the masked handle. The frame asks whether identities have unlocked; the feed does not yet. Neither ever shows a name before the unlock.
Treat them as optional. If a lookup behind them is unavailable the frame ships without words rather than not at all, and the fallback is the fetch you would have made anyway.
So: update the badge from unreadCount, navigate with deepLink, draw title and
body when they are there, and fetch GET /v0.1/api/v1/notifications when they are not.