OnTheWayBack realtime 0.1

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 resume

Every 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.

One session per connection, per lane

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.

Servers

  • wss://api-v0-1.ontheways.nl/wssproduction

    Production. Caddy terminates TLS and proxies the upgrade through.

    Security:
    • HTTP
      • Scheme: bearer
      • Bearer format: JWT

      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.

    • User/Password

      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.)

  • ws://localhost:8080/wslocal

    Local development.

    Security:
    • HTTP
      • Scheme: bearer
      • Bearer format: JWT

      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.

    • User/Password

      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.)

Operations

  • SEND /v0.1/api/v1/realtime/chat/socket

    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.

    Operation IDsendChatEnvelopes

    Available only on servers:

    object

    Accepts one of the following messages:

    • #0A new message

      Durable. Persisted, notified and pushed.

      Message IDchatMessage
      allOf

      Examples

    • #1Somebody is typing

      Ephemeral. Never stored, never pushed, server-debounced.

      Message IDchatTyping
      allOf

      Examples

    • #2The other side has read this far

      Ephemeral. This is what moves the sender's ticks.

      Message IDchatReceipt
      allOf

      Examples

    • #3Keepalive

      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.

      Message IDheartbeat
      allOf

      Examples

    • #4A frame you sent failed

      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.

      Message IDerror
      allOf

      Examples

  • RECEIVE /v0.1/api/v1/realtime/chat/socket

    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.

    Operation IDreceiveChatFrames

    Available only on servers:

    object

    Accepts one of the following messages:

    • #0Send a message

      The same command the REST body carries. **Always include `clientMessageId`**: it is the idempotency key, and without it a retry creates a duplicate.

      Message IDsendFrame
      object

      Examples

    • #1Move the read mark

      Monotonic and idempotent — a lower value is ignored, so two devices racing is harmless.

      Message IDreadFrame
      object

      Examples

    • #2Announce typing

      **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.

      Message IDtypingFrame
      object

      Examples

    • #3Re-declare what the user is looking at

      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.

      Message IDfocusFrame
      object

      Examples

    • #4Replay a thread from a position, without reconnecting

      Asks for everything after `seq` in one thread. **Socket only** — SSE has no inbound channel and re-declares by reconnecting with `Last-Event-ID`.

      Message IDsinceFrame

      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.

      object

      Examples

    • #5Liveness reply

      Optional. The server's own beat is what decides liveness.

      Message IDpongFrame
      object

      Examples

  • SEND /v0.1/api/v1/realtime/chat/stream

    An HTTP text/event-stream carrying exactly the same envelopes as the chat socket.

    Differences from the socket, and only these:

    • Receive only. There is no inbound channel, so an SSE client can receive typing and cannot send it. That asymmetry is deliberate: 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.
    • Resumes with the standard 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.
    • Authenticates with 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`.

    Operation IDstreamChatEnvelopes

    Available only on servers:

    object

    Accepts one of the following messages:

    • #0A new message

      Durable. Persisted, notified and pushed.

      Message IDchatMessage
      allOf

      Examples

    • #1Somebody is typing

      Ephemeral. Never stored, never pushed, server-debounced.

      Message IDchatTyping
      allOf

      Examples

    • #2The other side has read this far

      Ephemeral. This is what moves the sender's ticks.

      Message IDchatReceipt
      allOf

      Examples

    • #3Keepalive

      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.

      Message IDheartbeat
      allOf

      Examples

  • SEND /v0.1/api/v1/realtime/notifications/socket

    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.

    Operation IDsendNotificationEnvelopes

    Available only on servers:

    object

    Accepts one of the following messages:

    • #0Something landed in your feed

      A signal plus a deep link. Refetch the feed for copy.

      Message IDnotification
      allOf

      Examples

    • #1Keepalive

      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.

      Message IDheartbeat
      allOf

      Examples

    • #2A frame you sent failed

      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.

      Message IDerror
      allOf

      Examples

  • RECEIVE /v0.1/api/v1/realtime/notifications/socket

    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.

    Operation IDreceiveNotificationFrames

    Available only on servers:

    object

    Accepts the following message:

    Liveness reply

    Optional. The server's own beat is what decides liveness.

    Message IDpongFrame
    object

    Examples

  • SEND /v0.1/api/v1/realtime/notifications/stream

    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`.

    Operation IDstreamNotificationEnvelopes

    Available only on servers:

    object

    Accepts one of the following messages:

    • #0Something landed in your feed

      A signal plus a deep link. Refetch the feed for copy.

      Message IDnotification
      allOf

      Examples

    • #1Keepalive

      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.

      Message IDheartbeat
      allOf

      Examples

Messages

  • #1Something landed in your feed

    A signal plus a deep link. Refetch the feed for copy.

    Message IDnotification
    allOf
  • #2A new message

    Durable. Persisted, notified and pushed.

    Message IDchatMessage
    allOf
  • #3Somebody is typing

    Ephemeral. Never stored, never pushed, server-debounced.

    Message IDchatTyping
    allOf
  • #4The other side has read this far

    Ephemeral. This is what moves the sender's ticks.

    Message IDchatReceipt
    allOf
  • #5Keepalive

    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.

    Message IDheartbeat
    allOf
  • #6A frame you sent failed

    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.

    Message IDerror
    allOf
  • #7Send a message

    The same command the REST body carries. **Always include `clientMessageId`**: it is the idempotency key, and without it a retry creates a duplicate.

    Message IDsendFrame
    object
  • #8Move the read mark

    Monotonic and idempotent — a lower value is ignored, so two devices racing is harmless.

    Message IDreadFrame
    object
  • #9Announce typing

    **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.

    Message IDtypingFrame
    object
  • #10Re-declare what the user is looking at

    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.

    Message IDfocusFrame
    object
  • #11Replay a thread from a position, without reconnecting

    Asks for everything after `seq` in one thread. **Socket only** — SSE has no inbound channel and re-declares by reconnecting with `Last-Event-ID`.

    Message IDsinceFrame

    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.

    object
  • #12The replay stopped early

    The gap was larger than one replay may push. Page the rest over `GET /v0.1/api/v1/chats/{chatId}/messages`.

    Message IDreplayTruncated

    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.

    object
  • #13Liveness reply

    Optional. The server's own beat is what decides liveness.

    Message IDpongFrame
    object

Schemas

  • object
    deprecated

    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.

  • object

    One message. Identical to MessageResponse in the OpenAPI document.

  • object
  • object

    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.