Stand up chat, a call, and push
Everything below is the shipped surface — exact paths, exact frames, in the order you need them.
Work top to bottom and you will have a working messenger. Base URL is your deployment; examples
use https://api.example.com.
0 · Prerequisites #
- A deployed backend and its base URL, reachable over TLS.
- An account on the deployment —
POST /v1/account/signupreturns anas_…session and provisions your tenant. - For Path A: a server API key —
POST /v1/account/api-keysreturns anak_…key (shown once). Keep it server-side; it is tenant-pinned and never goes in a client. - For Path B: a directory app —
POST /v1/account/directory-appsreturns a publicda_…app id plus a delivery secret (shown once). - TURN reachable from your clients (bundled coturn), or calls will fail on restrictive networks.
1A · Identity, your way (server-to-server) #
Your server authenticates the user, then provisions a room and mints a room-scoped token. The backend stores no names, emails or phone numbers on this path.
- Provision the room and members
Use
external_refso a retry re-uses the same room instead of creating a second one.# 1. create (or re-use) a room — external_ref makes this idempotent curl -X POST https://api.example.com/v1/server/rooms \ -H "Authorization: Bearer ak_live_..." \ -H "Content-Type: application/json" \ -d '{"created_by":"user-42","external_ref":"order-1234"}' # 2. add each participant curl -X POST https://api.example.com/v1/server/rooms/$ROOM/members \ -H "Authorization: Bearer ak_live_..." \ -H "Content-Type: application/json" \ -d '{"principal_id":"user-42","role":"member"}' - Mint a session token for the client
One token authorises one principal, in one room, for a limited time — not blanket account
access. Pass
device_idso the same user can join a call from two devices.curl -X POST https://api.example.com/v1/server/session-tokens \ -H "Authorization: Bearer ak_live_..." \ -H "Content-Type: application/json" \ -d '{ "room_id": "0f5c…-…-…", "principal_id": "user-42", "ttl_seconds": 3600, "device_id": "iphone-15-pro" }' - Hand the whole bundle to the client
It contains both socket URLs and the ICE servers — do not hard-code these in the app.
{ "access_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9…", "expires_at": "2026-09-10T12:00:00Z", "chat_websocket_url": "wss://api.example.com/v1/chat/ws", "rtc_websocket_url": "wss://api.example.com/v1/rtc/ws", "ice_servers": [ { "urls": ["stun:turn.example.com:3478"] }, { "urls": ["turns:turn.example.com:5349?transport=tcp"], "username": "1757500000:user-42", "credential": "b64hmac…" } ] }
4401 socket close as "re-mint and retry".
1B · Identity, turnkey (phone-OTP directory) #
The client talks to Ollacore directly. You still own SMS delivery — we generate and verify the code, you put it on the wire via your own route.
# 1. client asks for a code (app_id is public — safe to embed)
curl -X POST https://api.example.com/v1/directory/otp/request \
-H "Content-Type: application/json" \
-d '{"app_id":"da_9f2c…","phone":"+14155550123"}'
# 2. your webhook receives {app_id, phone, code, expires_at}, signed —
# you send it over your own SMS route. Then:
curl -X POST https://api.example.com/v1/directory/otp/verify \
-H "Content-Type: application/json" \
-d '{"app_id":"da_9f2c…","phone":"+14155550123","code":"418322"}'
# → { "session_token": "du_…", "user_id": "…", "display_name": null }
The returned du_… session is the client's credential for the whole directory surface:
# contacts the user already has in their phone book
POST /v1/directory/contacts/lookup {"phones":["+14155550124", …]} → registered subset
# open a 1:1 (idempotent — same pair always returns the same room)
POST /v1/directory/conversations/direct {"peer_user_id":"…"} → { room_id, kind }
# the chat list
GET /v1/directory/inbox → peer/name, last message, unread
# a room-scoped token for that conversation
GET /v1/directory/conversations/{room_id}/token → same bundle as Path A 2 · Chat #
From here both paths are identical: you hold an access_token and the two socket URLs.
- Open the chat socket
Authentication rides the WebSocket subprotocol, never the query string, so tokens never land in
logs or referrers.
// Browser: the token rides the subprotocol, never the URL const ws = new WebSocket(chat_websocket_url, ["chatbox", "bearer." + access_token]); // Node / native: set the header directly // Sec-WebSocket-Protocol: chatbox, bearer.<access_token> - Send a message
Every client frame carries a
request_idyou choose; the ack echoes it.client_message_idis your idempotency key — retry with the same value and you get the original message back rather than a duplicate.{ "v": 1, "request_id": "0b0f2b1e-1f4a-4b6f-9a0f-2b1e1f4a4b6f", "type": "message.send", "payload": { "client_message_id": "local-7f3a", "kind": "text", "body": { "text": "hello" } } }{ "v": 1, "type": "ack", "request_id": "0b0f2b1e-…", "payload": { "event_seq": 4131, "message_id": "8e1c…" } } - Receive events
Server frames carry a monotonic
event_idper room. Persist the highest one you have processed — that is your resume point.{ "v": 1, "event_id": 4131, "type": "message.created", "room_id": "0f5c…", "payload": { "id": "8e1c…", "sender_id": "user-42", "body": { "text": "hello" }, "event_seq": 4131 }, "occurred_at": "2026-09-10T11:04:22Z" } - Reconnect without gaps
After any disconnect, re-open and immediately ask for everything after your last sequence.
Deliberately overlap by one; duplicates are cheap, gaps are not.
If you fall too far behind, the server sends{ "v": 1, "request_id": "…", "type": "catchup", "payload": { "after_seq": 4130, "limit": 200 } }resyncinstead of streaming — refetch history over REST and resume. - Receipts and unread
Send
receipt.deliveredon arrival andreceipt.readwhen the user actually sees it. Onlyreceipt.readadvances the read cursor, which is whatGET /v1/rooms/{room_id}/unreadand the directory inbox count from.
REST equivalents exist for every write (POST /v1/rooms/{id}/messages and
friends) — useful for server-side sends and for clients that cannot hold a socket.
3 · Audio & video calls #
The same token opens the RTC socket. Media is forwarded by an SFU: each participant uploads one stream regardless of how many people are in the room, and no client learns another's IP.
- Open the RTC socket
Same subprotocol scheme as chat, against
rtc_websocket_url. The server registers you from the verified token — there is no client-side "join" or "register" frame to send, and one would be rejected. - Create the peer connection with the ICE servers you were handed
Use the
ice_serversarray verbatim; the TURN credentials in it are per-session and time-limited. - Publish: offer, then trickle
// after getUserMedia + pc.setLocalDescription(offer) rtcWs.send(JSON.stringify({ cmd: "offer", sdp: pc.localDescription })); // trickle ICE as candidates arrive pc.onicecandidate = (e) => { if (e.candidate) rtcWs.send(JSON.stringify({ cmd: "candidate", candidate: e.candidate })); }; // leaving rtcWs.send(JSON.stringify({ cmd: "leave" })); - Subscribe: answer the SFU's re-offers
When somebody else publishes, the SFU sends you a new offer. Echo its
request_idin your answer or it will not be matched.// The SFU re-offers when another participant publishes. rtcWs.onmessage = async (ev) => { const f = JSON.parse(ev.data); if (f.event === "joined") return; // status frame if (f.type === "offer") { // SFU re-offer await pc.setRemoteDescription(f); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); rtcWs.send(JSON.stringify({ cmd: "answer", sdp: answer, request_id: f.request_id })); } else if (f.type === "answer") { await pc.setRemoteDescription(f); // answer to our offer } }; - Know when a call is happening
A room emits
call.startedwhen its first participant joins andcall.endedwhen the last leaves — both carry the samecall_id. They arrive on the chat socket, on your webhook, and as a push. That is how a device that is not already in the call finds out about one.
4 · Push & ringing an offline device #
Push is presence-aware: only members with no live chat socket are notified, and there is a per-room cooldown so an active conversation cannot become a notification storm.
Web Push
POST /v1/push/subscriptions # body = the browser PushSubscription JSON
DELETE /v1/push/subscriptions # body = { "endpoint": "…" }
GET /v1/push/vapid-public-key # the key your service worker subscribes with Native FCM / APNs
Native push uses your vendor credentials, stored per directory app and never readable back:
# once per app, from your account plane — creds are write-only
curl -X PUT https://api.example.com/v1/account/directory-apps/$APP/push \
-H "Authorization: Bearer as_…" \
-H "Content-Type: application/json" \
-d '{
"fcm": { "service_account": { "project_id": "…", "client_email": "…", "private_key": "-----BEGIN PRIVATE KEY-----\n…" } },
"apns": { "p8": "-----BEGIN PRIVATE KEY-----\n…", "key_id": "ABC123",
"team_id": "TEAM12", "topic": "com.example.app", "env": "production" }
}'
# then every signed-in device registers its token
POST /v1/directory/devices {"platform":"fcm","push_token":"…"} - A dead token (FCM
404 UNREGISTERED, APNs410) is dropped automatically. - An incoming call pushes on
call.startedonly — you are not notified that a call ended. - In encrypted rooms the payload is content-free by force: no sender, no preview.
message.created and
call.started and send push from your own app-server. Both paths are supported; native
sending just saves you the hop.
5 · Verify before you call it done #
Each of these has caught a real integration bug. Tick them deliberately.
| Check | Why it matters |
|---|---|
Kill the socket mid-conversation, reconnect, catchup | Proves you resume from the right sequence and render no gaps or duplicates. |
Send the same client_message_id twice | Proves your retry path is idempotent — you should see one message, not two. |
| Let a token expire mid-session | Proves you re-mint and reconnect instead of silently going dead. |
| Join a call from two devices as one user | Proves you pass distinct device_ids; without it the second join replaces the first. |
| Force TURN (block UDP to the SFU) | Proves relay works for users behind restrictive NATs — the common field failure. |
| Background the app, have someone message and call you | Proves real push and real ringing, on a real device. This cannot be faked in CI. |
| Read on device A, check the badge on device B | Proves the read cursor syncs across a user's devices. |
| Try another tenant's room id with your token | Should be refused. Confirms you are not relying on client-side scoping. |
6 · Mistakes we see most #
- Putting the token in the URL. It belongs in the subprotocol (sockets) or the
Authorizationheader (REST). URLs leak into logs and referrers. - Shipping the
ak_key in the app. It is a tenant-wide server credential. Clients get room-scoped session tokens only. - Treating
event_idas global. It is per room. Track a cursor per room. - Ignoring the ack's error frame. A rejected send returns
type: "error"with yourrequest_id— surface it rather than assuming delivery. - Hard-coding ICE servers. TURN credentials are time-limited and issued per session.
- Assuming push means delivered. Push is best-effort by design; the durable record is the message in the room.