Integration checklist

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.

Pick your identity path first. Path A — your app-server owns users and mints tokens. Path B — Ollacore's turnkey directory owns users (phone-OTP). Chat, calls and push are identical afterwards; only how the client gets a room token differs.

0 · Prerequisites #

  • A deployed backend and its base URL, reachable over TLS.
  • An account on the deployment — POST /v1/account/signup returns an as_… session and provisions your tenant.
  • For Path A: a server API key — POST /v1/account/api-keys returns an ak_… 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-apps returns a public da_… 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.

  1. Provision the room and members Use external_ref so 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"}'
  2. 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_id so 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"
      }'
  3. 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…" }
      ]
    }
Token lifetime. Default 600 s, maximum 1 h. Your client must be able to fetch a fresh one and reconnect; treat a 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
Delivery is bring-your-own. Default mode signs the code and POSTs it to your webhook (SSRF-guarded). Optional provider mode calls Twilio, Plivo or MSG91 with credentials you store on the app. We hold no telecom account and DLT/sender registration stays yours.

2 · Chat #

From here both paths are identical: you hold an access_token and the two socket URLs.

  1. 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>
  2. Send a message Every client frame carries a request_id you choose; the ack echoes it. client_message_id is 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…" } }
  3. Receive events Server frames carry a monotonic event_id per 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"
    }
  4. 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.
    { "v": 1, "request_id": "…", "type": "catchup",
      "payload": { "after_seq": 4130, "limit": 200 } }
    If you fall too far behind, the server sends resync instead of streaming — refetch history over REST and resume.
  5. Receipts and unread Send receipt.delivered on arrival and receipt.read when the user actually sees it. Only receipt.read advances the read cursor, which is what GET /v1/rooms/{room_id}/unread and 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.

  1. 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.
  2. Create the peer connection with the ICE servers you were handed Use the ice_servers array verbatim; the TURN credentials in it are per-session and time-limited.
  3. 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" }));
  4. Subscribe: answer the SFU's re-offers When somebody else publishes, the SFU sends you a new offer. Echo its request_id in 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
      }
    };
  5. Know when a call is happening A room emits call.started when its first participant joins and call.ended when the last leaves — both carry the same call_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.
Screen share is a second track, not a mode. Add another video transceiver; the SFU forwards it as an independent stream so participants can see camera and screen at once.

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, APNs 410) is dropped automatically.
  • An incoming call pushes on call.started only — you are not notified that a call ended.
  • In encrypted rooms the payload is content-free by force: no sender, no preview.
Alternative: subscribe a webhook to 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.

CheckWhy it matters
Kill the socket mid-conversation, reconnect, catchupProves you resume from the right sequence and render no gaps or duplicates.
Send the same client_message_id twiceProves your retry path is idempotent — you should see one message, not two.
Let a token expire mid-sessionProves you re-mint and reconnect instead of silently going dead.
Join a call from two devices as one userProves 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 youProves real push and real ringing, on a real device. This cannot be faked in CI.
Read on device A, check the badge on device BProves the read cursor syncs across a user's devices.
Try another tenant's room id with your tokenShould 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 Authorization header (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_id as global. It is per room. Track a cursor per room.
  • Ignoring the ack's error frame. A rejected send returns type: "error" with your request_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.