SOMVAD Developer Guide

SOMVAD is an API-first trader-voice platform: everything the web console does, it does through this public API — and so can you. This guide covers the data model, the workflows behind each UI feature, and the real-time event contracts. The machine contract lives next door:

Every request below uses Authorization: Bearer $TOKEN unless marked public. Set BASE to your deployment (e.g. https://dev.somvad.com).


Every surface and machine endpoint in one place. Paths are relative to your deployment; the live one is https://dev.somvad.com (UI login + /docs + /devguide are gated by the demo password or a ?token=).

Surfaces (browser UIs)

Portal URL For
Console /ui Calls, conferences, chat, directory, recordings, admin panels
Turret strip /ui/turret The trader cockpit — lines, multi-leg audio, chat, directory, drag-invite
Admin Console /uiAdmin Tenant switcher, traders, lines, desks, gateway networking, impersonation, audit (see Admin)

API & docs

Resource URL What
Swagger / API explorer /docs Interactive OpenAPI UI
OpenAPI spec /openapi.json Machine contract (codegen / agents)
Developer guide /devguide This guide
Postman collection docs/api/postman_collection.json Repo artifact (importable)
Health /health Liveness (open, unauthenticated)

Realtime & media endpoints

Endpoint Path What
Events WebSocket wss://<host>/presence/ws?token= Presence, chat, signals, line-state (Events)
Browser SIP config GET /browser/config Per-call SIP credential + ICE/TURN (Browser calling)
Audio test services GET /browser/test-dial Echo / music / tone numbers (Audio tests)
Inbound carrier (PSTN) sip:sbc.<domain>:5061;transport=tls Twilio origination → DID routing (Inbound PSTN)

Data model & hierarchy

Tenant ─────────────────── an organisation (bank/fund); hard isolation boundary
 ├── Desk ───────────────── a trading desk; has a HEAD (supervises its calls)
 │    └── User ──────────── a per-tenant PROFILE; one person (one identity) may
 │                          hold a distinct profile in several tenants
 ├── Conference ─────────── THE voice primitive: ad-hoc call, private call, hoot…
 │    │                     status: created→starting→active⇄locked→…  (lifecycle)
 │    │                     visibility: tenant | private   desk_id: supervision scope
 │    └── Participant ───── a user's leg: invited→joining→connected→left/removed
 ├── Call ───────────────── a RING pointing at a conference (signaling overlay):
 │                          ringing→accepted|declined|cancelled|missed
 ├── Entitlement ────────── user ⇄ resource (conference/line/hoot) permission
 └── Hoot / Line / PrivateWire ── always-on rooms, DDI lines, ring-downs

Media plane (FreeSWITCH mixers) is reached ONLY via the control plane: /join
returns the mixer + room + its own wss:// endpoint; audio never touches this API.

Roles: member < tenant_admin (their tenant) < platform_admin (env allow-list, never stored). A desk head is not a role — it is supervision scope on one desk.


Authentication

POST /auth/login (public) issues a dev/demo JWT; Microsoft Entra (OIDC) tokens are accepted on the same endpoints when enabled (GET /auth/config, public, tells you which providers exist). The token's tenant_id picks WHICH per-tenant profile you act as.

TOKEN=$(curl -s $BASE/auth/login -H 'content-type: application/json' \
  -d '{"email":"trader@bank.com","tenant_id":"<TENANT_ID>","password":"<demo pw>"}' \
  | jq -r .access_token)

curl -s $BASE/auth/me -H "Authorization: Bearer $TOKEN"
# -> {"identity":{...},"user_id":"…","role":"member","tenant_id":"…"}

Quickstart: create a conference and join from a browser

# 1. create (visibility "tenant" = anyone on the desk may see+join)
CONF=$(curl -s $BASE/conferences -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"tenant_id":"ignored-for-members","name":"London Rates","visibility":"tenant"}')
CID=$(echo $CONF | jq -r .id)

# 2. ask the control plane WHERE to connect (mixer placement, ADR 0014/0016)
curl -s -X POST $BASE/conferences/$CID/join -H "Authorization: Bearer $TOKEN"
# -> {"room":"7003","mixer_id":"mixer-1","ws_url":"wss://mixer-1.sip.<domain>", "status":"ready",...}

# 3. browser: GET /browser/config for a fresh SIP credential + ICE/TURN servers,
#    register SIP.JS against ws_url, INVITE sip:<room>@<domain>. status:"spawning"
#    means a mixer is booting — poll /join until "ready".

# 4. hang up: drop the SIP leg AND close your control-plane leg
curl -s -X POST $BASE/conferences/$CID/leave -H "Authorization: Bearer $TOKEN"

Key fields on ConferenceRead: status (lifecycle — locked means temporarily private), visibility (private = invite-only, hidden from non-invitees), created_by_name, desk_id, dial_number (the SIP room), party_limit.

Invite people at creation: pass invited_user_ids (same-tenant user ids) and each gets an invited participant leg — they can see and join immediately. A conference created WITH invitees and WITHOUT an explicit visibility defaults to private: adding people to a call means only those people see it. (To ring an invitee's screen, follow up with POST /calls + conference_id — see calls.)

Call admins (ADR 0027): every live call keeps at least one admin leg. The creator's leg is promoted automatically on join; if the last admin hangs up, the longest-connected participant is promoted (watch for the admin_changed activity frame and the call_admin signal on the events WS). Pass adminship with POST /conferences/{id}/participants/{user_id}/role ({"role":"admin"|"member"}, callable by a live call admin or a tenant/platform admin); demoting the only admin is a 409. GET /conferences/{id}/participants returns each leg's role + lifecycle status (the roster is private-call-scoped like the conference itself).

Lone-party auto-drop: when everyone else hangs up and ONE participant is left on a non-always-on call for ~3 minutes, the platform kicks the survivor and parks the call (status: held, resumable via /join) — nobody sits on a dead call. Hoots/squawks/ringdowns are exempt. An auto_dropped activity frame announces it.

Browser calling in your own app

The web console is a reference client — any SIP-over-WebSocket library works (it uses SIP.JS). The full recipe:

// GET /browser/config  (authed) ->
{
  "sip": { "username": "91234567", "password": "…",   // FRESH short-lived credential
           "domain": "<sip domain>", "ws_url": "wss://…" },
  "ice_servers": [ { "urls": "turn:turn.<domain>:3478?transport=udp",
                     "username": "…", "credential": "…" } ],
  "ice_transport_policy": "all",          // pass straight into RTCPeerConnection
  "conferences": [ { "id": "…", "name": "…", "dial_number": 7003 } ]
}
  1. POST /conferences/{id}/join → take ws_url + room from the response (each mixer has its OWN wss endpoint — always register where /join points you).
  2. Create your user agent with sip:<username>@<domain>, transport ws_url, auth username/password from /browser/config, and the ICE servers + policy.
  3. REGISTER, then INVITE sip:<room>@<domain> with an audio-only offer. The mixer is ICE-lite and answers without client-side candidates beyond the relay.
  4. On hangup: BYE + POST /conferences/{id}/leave.
  5. To RECEIVE calls, keep the events WS open and treat incoming_call as your ring: POST /calls/{id}/accept, then run steps 1–3 on its conference_id.

Live truth vs control-plane truth: GET /conferences/{id}/members is what the mixer reports right now; /participants is the control plane's leg bookkeeping (invited/joining/connected/left/removed). GET /conferences/{id}/topology shows mixer placement and cross-mixer bridges.

Audio path self-test

GET /browser/test-dial (authed, tenant user) returns an active mixer's WSS endpoint and the reserved dialable test services — no second person needed to verify a client's whole audio path (device → browser → TURN → mixer):

{ "mixer_id": "mixer-0", "ws_url": "wss://mixer-0.sip.<domain>",
  "services": [
    { "number": "9196", "kind": "echo",  "label": "Echo test (hear your own mic)" },
    { "number": "9664", "kind": "music", "label": "Music (speaker check)" },
    { "number": "9198", "kind": "tone",  "label": "Tone (always available)" } ] }

Register against ws_url with your /browser/config credential and INVITE the number like any call. 9198 is generated on the mixer (no sound files) — use it for automated probes. The turret's Audio panel drives exactly this endpoint.


Calls: ring a user

A Call is a ring pointing at a conference. Direct calls auto-create a private private_call conference for the two parties.

# ring a colleague (their browser/WS gets an incoming_call event instantly)
CALL=$(curl -s $BASE/calls -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"callee_user_id":"<USER_ID>"}')
# -> {"id":"…","status":"ringing","kind":"direct","conference_id":"…", "callee_name":"…"}

# callee answers (or /decline); caller may /cancel; unanswered rings go "missed"
curl -s -X POST $BASE/calls/<CALL_ID>/accept  -H "Authorization: Bearer $CALLEE_TOKEN"
# then the callee joins the conference normally: POST /conferences/{conference_id}/join

# my ringing calls (poll-based clients):
curl -s "$BASE/calls?status_filter=ringing" -H "Authorization: Bearer $TOKEN"

State machine: ringing → accepted | declined | cancelled | missed (all terminal; accept is idempotent). Declined/cancelled/missed rings retract the callee's invite — they lose access to the private room again.

Invite more people / transfer

The same endpoint rings someone INTO an existing conference:

# invite: rings them into your live call
curl -s $BASE/calls -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"callee_user_id":"<USER_ID>","conference_id":"<CID>","kind":"invite"}'

# transfer: same, but kind:"transfer" — when the call_accepted event arrives,
# the transferring client leaves (that's the whole blind-transfer contract)
  -d '{"callee_user_id":"<USER_ID>","conference_id":"<CID>","kind":"transfer"}'

Real-time events: one WebSocket

GET /presence/ws?token=<JWT> (browsers can't set headers on a WS handshake, so the token rides the query string). One socket carries tenant presence plus your targeted signals:

{"type":"snapshot", "subject":"6001", ...}            // initial presence dump
{"type":"update",   "subject":"6001", "available":true, ...}   // presence delta

// targeted signals (only YOU receive these):
{"type":"signal","event":"incoming_call","call_id":"…","conference_id":"…",
 "conference_name":"…","kind":"direct","from_user_id":"…","from_name":"…"}
{"type":"signal","event":"call_accepted","call_id":"…","conference_id":"…","kind":"…"}
{"type":"signal","event":"call_declined", ...}
{"type":"signal","event":"call_cancelled", ...}   // caller hung up — stop ringing
{"type":"signal","event":"call_missed", ...}      // ring timed out — sent to BOTH the
                                                  // caller AND the callee (the callee must
                                                  // stop ringing too). Treat call_missed
                                                  // like call_cancelled: retract the ring.
// chat frames (ADR 0021) — tenant channel for open rooms, per-user for private/locked:
{"type":"chat","message_id":"…","conference_id":"…","from_user_id":"…",
 "from_name":"…","body":"…","created_at":"2026-06-11T09:00:00+00:00"}

// per-LINE activity (turret bubbles): semantic, room-keyed, tenant-wide. `room`
// is the conference dial number — key your line bubble on it:
{"type":"activity","room":"7003","event":"joined","subject":"6001","user_id":"…"}
// events: joined | left | speaking | quiet | muted | unmuted

// shared-line CALL APPEARANCES (ADR 0044) — the Number /1..N lamps for a line. Sent to
// the line's watch circle whenever a slot changes (a call lands, is answered, or ends):
{"type":"line_appearances","line_id":"…","dial_number":7003,"name":"EUR Desk",
 "slots":[{"index":1,"state":"active","conference_id":"…","room":"7050"},
          {"index":2,"state":"idle","conference_id":null,"room":null}]}
// slot state: idle | ringing | active | held | locked. A busy slot carries the `room`
// you join to answer/monitor that appearance (POST /conferences/{room-conf}/join).

Every typed frame carries a per-tenant monotonic seq. Reconnect with ?last_seq=<highest seq you saw> and missed frames replay first (marked "replay":true) — and your still-RINGING calls are ALWAYS re-delivered as incoming_call on connect, so a dropped socket can never eat a ring. Refetch chat history on reconnect for anything beyond the ~500-frame replay window.

This socket is the platform's NOTIFICATION BUS — nothing needs polling: presence, rings, chat, and line activity all push. A turret UI renders a line bubble from the presence snapshot (who is in room now) + activity deltas (who joined/left/ is speaking) + chat frames for the line's thread.

Reconnect with backoff; you'll get a fresh snapshot each time.

Presence fields

Presence is driven by the mixers' own events (registration + conference activity on every mixer), so it is live truth, not self-reported. Each entry / update:

Field Meaning
subject SIP username (browser sessions get ephemeral ones)
user_id the resolved user profile — join on this, not on subject
online signed in — a live events WebSocket (any tab/turret) or SIP registration
available self-set via POST /presence/me
in_conference the ROOM (dial number) they are in right now, else null
muted, speaking live talk state from the mixer

REST: GET /presence (snapshot), POST /presence/me {"available":bool}. A client can derive "N live on this conference" by counting entries whose in_conference equals the conference's dial_number — no extra endpoint needed.


Live captions

Two paths, both in-cluster (trader audio never goes to a third party). GET /browser/config tells you which is live:

Captions are per-tenant opt-in (PATCH /tenants/{id}/policy {"live_captions":true}).


Chat: messages on any conference

Chat rides the Conference primitive: call chat, group chats, and DMs are the same API. A "group chat" is just a named conference used for text (join the audio or don't); a DM is the private conference a direct call creates.

curl -s $BASE/conferences/$CID/messages -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"body":"morning — axe in 10s30s"}'
# -> {"id":"…","sender_name":"…","body":"…","created_at":"…"}

curl -s "$BASE/conferences/$CID/messages?limit=50" -H "Authorization: Bearer $TOKEN"
# -> latest 50, oldest first

DMs: POST /conferences/dm {"user_id": "..."} opens (find-or-create) your private thread with a user — chat immediately, no ring; calling them later reuses the SAME room, so chat and voice share one thread per pair.

Access = the conference's own rules: tenant-visible rooms are readable/writable by any tenant member; visibility:"private" or status:"locked" rooms only by the call's circle (participants, invitees, creator, desk head, admins). Delivery is the type:"chat" WS frame — tenant-wide for open rooms, per-user channels for private/locked ones (nothing leaks tenant-wide).


Privacy: visibility, lock (temporary privacy), barge

Two independent dimensions:

Dimension Values Meaning
visibility tenant / private private = invite-only AND hidden from non-invitees (direct calls use this)
status activelocked locked = temporarily private: still VISIBLE to entitled users, but joining is refused — show it disabled, not hidden
# any CURRENT participant, the creator, the desk head, or an admin:
curl -s -X POST $BASE/conferences/$CID/lock   -H "Authorization: Bearer $TOKEN"
curl -s -X POST $BASE/conferences/$CID/unlock -H "Authorization: Bearer $TOKEN"
# while locked: POST /join -> 409; ringing someone into it -> 409

The lock is enforced twice: the control plane refuses /join, and the mixer itself refuses dial-ins (FreeSWITCH conference lock).

Desks & barge

Every tenant has desks; a desk has a head who supervises its calls. Conferences are stamped with their creator's desk.

# tenant admin: create a desk, designate its head, seat traders
curl -s $BASE/desks -H "Authorization: Bearer $ADMIN" -H 'content-type: application/json' \
  -d '{"tenant_id":"<TID>","name":"Rates","head_user_id":"<USER_ID>"}'
curl -s -X PATCH $BASE/desks/<DESK_ID>       -d '{"head_user_id":"<USER_ID>"}' ...
curl -s -X PATCH $BASE/desks/users/<USER_ID> -d '{"desk_id":"<DESK_ID>"}' ...

# the desk head joins ANY call on their desk — even one made private:
curl -s -X POST $BASE/conferences/$CID/barge -H "Authorization: Bearer $HEAD_TOKEN"
# -> a normal join assignment; the barger appears as a connected participant

Barge briefly opens the mixer gate for the barger and re-locks it; ordinary /join refusals never lift.


Users & onboarding

One person = one external identity = a distinct profile per tenant (own display name, role, desk, entitlements). The token's tenant picks the active profile.

# platform admin: give an EXISTING platform user a profile in another tenant
curl -s $BASE/users/assign -H "Authorization: Bearer $PLATFORM" \
  -d '{"email":"trader@bank.com","tenant_id":"<TID>","display_name":"T @ Fund","role":"member"}'

# tenant admin: onboard a trader by CLONING a template profile (copies entitlements)
curl -s $BASE/users/<TEMPLATE_USER_ID>/clone -H "Authorization: Bearer $ADMIN" \
  -d '{"email":"new.trader@bank.com"}'

# roles: PATCH /users/{id}/role {"role":"tenant_admin"} (platform admin only)

POST /users adds a fresh user; localdev logins auto-provision members on first sign-in. Entitlements: POST/GET/DELETE /entitlements (user ⇄ conference/line/hoot).

Keycloak provisioning (we operate the IdP)

When the deployment runs self-hosted Keycloak (multi-tenant OIDC), the control plane PROVISIONS the realm/SPA client/user via Keycloak's Admin API — so a new admin/trader gets a credential without any email service:

GET /auth/config advertises keycloak.provisioning so a client knows the platform mints credentials (vs a typed password). Provisioning is OFF on deployments without Keycloak admin creds (the typed admin_password path applies). Sessions in provisioned realms last a full 12h shift (short access token, silently refreshed; SSO idle/max = 12h).

Developer portal & sandbox

External developers get sandbox API access through a classic apply → approve → key flow:

# 1. apply (PUBLIC) — creates a pending application + an isolated sandbox tenant you administer
curl -s $BASE/developer/apply -H 'Content-Type: application/json' -d '{
  "email":"you@acme.io","password":"choose-a-strong-one","name":"You","company":"Acme",
  "use_case":"Embed a call button in our app."}'

# 2. a SOMVAD platform admin approves (seeds your sandbox with demo lines)
#    GET /developer/applications  ·  POST /developer/applications/{id}/approve|deny

# 3. sign in with the same email + password, then mint a key (shown ONCE)
TOKEN=$(curl -s $BASE/auth/login -d '{"email":"you@acme.io","password":"…"}' | jq -r .access_token)
curl -s $BASE/developer/keys -H "Authorization: Bearer $TOKEN" -d '{"name":"ci"}'
# -> {"key":"svk_sbx_…", ...}

# 4. call the API with the key — scoped to YOUR sandbox tenant
curl -s $BASE/conferences -H "Authorization: Bearer svk_sbx_…"

GET /developer/me returns your application status, sandbox tenant, and keys (never the secret). Keys (svk_sbx_…) are opaque bearer tokens, SHA-256 hashed at rest, capped at your sandbox tenant (never platform scope). The sandbox has no PSTN egress; media is simulated. The docs are public: this guide, the interactive explorer (/docs), the contract (/openapi.json), and the @somvad/* SDKs.

Trader overview — the full, read-only picture of one trader in a single call: identity + desk, the lines they can use (accessible_lines), their button matrix, sound profiles + assignments, recent recordings on their lines, and a presence snapshot. For the Admin Console Trader-detail view and a desk head's overview.

curl -s $BASE/users/<USER_ID>/overview -H "Authorization: Bearer $TOKEN"

Visible to a platform admin, the trader's own tenant admin, the trader themselves, or the head of the trader's desk (a desk head sees their people without needing tenant-admin rights).


Trader-voice primitives

Inbound PSTN: carrier trunks & DID routing

A line is a conference with an extension (above). To make a line reachable from the public phone network, point a DID (an E.164 phone number on a carrier SIP trunk, e.g. Twilio) at it. An inbound call then bridges into the line's room and rings the entitled members — the same ring path as an internal call. (Phase B; control-plane routing is live, the public gateway is ADR 0032.)

Architecture. One shared gateway FreeSWITCH fleet terminates every carrier; tenants are isolated by the routing lookup, not by separate processes:

Twilio (DID) --INVITE--> sbc.<domain>:5061/TLS        (firewall: carrier CIDRs only)
  -> gateway "public_inbound" profile (inbound ACL = carrier CIDRs, default-deny)
  -> mod_xml_curl asks the control plane: POST /freeswitch/dialplan {destination_number=DID}
       control plane: DID -> (tenant, line) -> the line's HOME mixer
       returns bridge XML: {sip_auth_*}sofia/internal/<room>@<home-mixer>   (ADR 0014)
  -> mixer conference -> entitled line members ring

The boundary that prevents cross-tenant routing is that a DID is globally unique and a tenant admin may only point its own DIDs at its own lines. Unknown / unpointed / disabled DIDs fail closed (the call is rejected, never misrouted).

Setup (three steps, two roles):

# 1. platform admin: allow a carrier trunk to a tenant
curl -sX POST $BASE/gateways -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"tenant_id":"<tenant>","name":"MPSC-OneControl","provider":"twilio"}'
# -> {"id":"<trunk>","tenant_id":"…","name":"…","provider":"twilio","status":"active"}

# 2. platform admin: register a DID (globally unique; digits-normalized — +44… == 44…)
curl -sX POST $BASE/gateways/dids -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"tenant_id":"<tenant>","did":"+441313815846","trunk_id":"<trunk>"}'
# -> {"id":"<route>","did":"441313815846","conference_id":null,"enabled":true,...}
#    registering a number already taken -> 409 (the cross-tenant guard)

# 3. tenant admin: point YOUR DID at YOUR line (a conference id). Pointing at another
#    tenant's line -> 404 (no leak). conference_id:null clears the route.
curl -sX PATCH $BASE/gateways/dids/<route> -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"conference_id":"<line>"}'

# list (platform admin = all; tenant admin = own tenant)
curl -s "$BASE/gateways/dids?tenant_id=<tenant>" -H "Authorization: Bearer $TOKEN"

# read-only ingress summary (Admin Console networking view; tenant admin or platform):
# static IP, FQDN, the carrier SIP URI, admitted carrier CIDRs, TLS, + trunk/DID counts
curl -s $BASE/gateways/status -H "Authorization: Bearer $TOKEN"
# -> {enabled, provider, fqdn, static_ip, sip_uri, transport, admitted_cidrs[],
#     trunk_count, did_count, routed_did_count, note}

POST /freeswitch/dialplan is the FreeSWITCH-facing xml_curl endpoint (form-encoded, not for integrators) — FS calls it on each inbound INVITE; it returns the bridge XML or fail-closed not-found. On a resolvable DID it ALSO rings the line appearance: an inbound_call activity frame (+ refreshed line_state) goes to the line's entitled traders so the appearance pulses on their turrets and one can answer by joining; the carrier leg is bridged into the room and held (hold-music) until then. When the caller gives up before a trader answers, the gateway ESL listener (leader-run, attached when GATEWAY_ESL_HOST is set) sees the carrier A-leg hang up and emits an inbound_cancelled activity frame for the same line, so the appearance stops flashing at once instead of waiting on the client's activity-window decay. The bridge authenticates as a configurable directory user (GATEWAY_BRIDGE_USER, default a always-served dev user). The DID→line decision is Redis-cached (closes deferred item H2); register/point invalidate the cache so a (re)pointed DID takes effect on the next call.

Security & infra (ADR 0032), defense in depth — fail closed at every layer: the cloud firewall admits SIP/RTP from the carrier CIDRs only (never 0.0.0.0/0); the FS inbound ACL is default-deny; TLS 5061 + SRTP on the trunk; the dialplan rejects unknown DIDs. The gateway runs on a dedicated public node pool behind a reserved static IP on an external passthrough Network LB (not the L7 — ADR 0016). Operators: see docs/runbooks/GATEWAY_DEPLOY.md (incl. scripts/gateway_probe.sh to verify the SIP-over- TLS path before pointing the carrier) and ADR 0032 for the full model and pilot caveats.

Shared-line call appearances (Number /1, /2, …)

Give one line N call appearances so several calls ride the same DID at once and several traders watch and pick them up — the classic turret shared-line model (ADR 0044). Default is 1 (a plain single-room line, unchanged).

# tenant admin: give a line 3 appearances; when all are busy, reject (or "forward" + busy_forward_did)
curl -sX PUT $BASE/conferences/<line>/appearances -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"count":3,"busy_policy":"reject"}'
# -> {"line_id":"…","max_appearances":3,"busy_policy":"reject","slots":[
#      {"index":1,"state":"idle","current_conference_id":null,...}, … ]}

# read the live state of each slot
curl -s $BASE/conferences/<line>/appearances -H "Authorization: Bearer $TOKEN"

# seize a free appearance for an OUTBOUND call (the call presents the LINE's DID as caller-ID)
curl -sX POST $BASE/conferences/<line>/appearances/2/seize -H "Authorization: Bearer $TOKEN"
# -> {"line_id":"…","appearance_index":2,"conference_id":"<call>","room":"7050"}
#    then POST /conferences/<call>/join and dial out; 409 if that appearance is already busy

An inbound call to the DID lands on the first idle appearance (each is its own room, its own recording, its own state); all busy applies the busy policy (486 reject, or an internal forward). The per-slot lamps push over the events WS as line_appearances frames (#events) — a busy slot carries the room you join to answer (ringing) or monitor (active) it. Access to any appearance = access to the line (a private line's appearances are limited to its circle). Anchors are the API — the turret renders /1..N tiles straight from these.

Outbound PSTN: egress policy + dial rules

The mirror of inbound: a trader dials an outside number (the turret dialpad ☎ or a dial-out button) and the call goes out a shared carrier trunk. PILOT-SIMPLE: one shared Twilio IP-auth trunk (no per-tenant carrier credentials); the control plane is the toll-fraud boundary. Everything is OFF until the platform flag enable_outbound AND the tenant's policy enabled are both true.

The egress decision (pure, identical in the dialpad preview and the dialplan): emergency numbers (999/112/911/…) are BLOCKED first, then the first matching dial rule strips/prepends, then default E.164 normalisation (+… pass-through, 00… international, 0… national for the tenant's default_region), length-validate, then the destination allowlist (empty ⇒ deny everything, fail-closed).

# tenant admin: tune the egress policy (caller-ID, destination allowlist, caps)
curl -sX PUT $BASE/gateways/outbound -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"enabled":true,"default_region":"GB","caller_id_number":"+441313815846","allowed_prefixes":"+44,+1","max_concurrent_calls":2}'

# add a number-transformation rule (dial 9 for an outside line, UK national)
curl -sX POST $BASE/gateways/outbound/rules -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"pattern":"9","strip":1,"prepend":"+44","priority":10,"description":"outside line"}'

# preview what a dialled string resolves to (the dialpad uses this before placing a call)
curl -sX POST $BASE/gateways/outbound/resolve -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"dialed":"02079460000"}'
# -> {"allowed":true,"e164":"+442079460000","reason":"ok","matched_rule_id":null}

# read the whole config (policy + rules); platform admins pass ?tenant_id
curl -s $BASE/gateways/outbound -H "Authorization: Bearer $TOKEN"

POST /freeswitch/outbound-dialplan is the FS-facing xml_curl endpoint (not for integrators): the mixer bridges an authenticated trader's outbound leg to the gateway with the tenant id as a channel var; this returns bridge XML to sofia/gateway/<trunk>/<e164> (with the tenant's caller-ID) or a 403 reject carrying an X-Somvad-Deny reason. A Redis concurrency counter caps simultaneous outbound calls per tenant (TTL self-heals a missed release). Live carrier wiring (the mixer outbound dialplan, the gateway sofia trunk, the egress ACK fix) is gated and documented in docs/runbooks/OUTBOUND_TRUNK.md.

Interconnect: cross-tenant on-net dialing

An Interconnect handle lets a trader in one tenant dial a desk in another tenant on-net (no PSTN), the trader-voice "inter-company line". It is a deliberate, controlled hole in tenant isolation, so it is least-privilege by construction:

# platform admin allocates a handle to an owner tenant
curl -sX POST $BASE/interconnects -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"owner_tenant_id":"<B>","label":"ACME Rates Desk"}'
# owner points it at one of THEIR lines, then grants a caller tenant
curl -sX PATCH $BASE/interconnects/<id> -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"conference_id":"<B-line>"}'
curl -sX POST $BASE/interconnects/<id>/grants -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"caller_tenant_id":"<A>"}'
# the caller tenant sees it (label only, never the line) and accepts (mutual consent)
curl -s  $BASE/interconnects/available -H "Authorization: Bearer $TOKEN"
curl -sX POST $BASE/interconnects/available/<handle>/accept -H "Authorization: Bearer $TOKEN"

Each firm records its own side (per-leg recording attributes a foreign leg to its OWN tenant). The whole feature is gated off (enable_interconnect) until the two-room recording bridge lands. Design + the security review that shaped it: docs/delivery/INTERCONNECT_PLAN.md.

Admin & tenant management

SOMVAD is multi-tenant with three roles (see Tenancy rules). Administration splits cleanly by role and lives in the Admin Console (the Admin surface in /ui, distinct from the trader turret): a tenant switcher (platform admin) scopes a section nav — Overview · Traders · Lines · Desks · Networking · Recordings · Audit · System — and every section is a thin client of the APIs below.

platform_admin — manages the whole deployment: - Tenants: POST/GET /tenants, PATCH /tenants/{id}/policy, /tenants/{id}/export|import. - Rename / remove a tenant: PATCH /tenants/{id} {"name": "..."}; DELETE /tenants/{id} hard-deletes the tenant and everything in it (users, lines, desks, chats). Refused with 409 while the tenant holds compliance recordings — pass ?force=true to delete anyway (recording rows are erased; audio objects stay in the store per retention). - Designate tenant admins: PATCH /users/{id}/role {"role":"tenant_admin"}. - Onboard an existing identity into another tenant: POST /users/assign. - See ALL networking (gateway / trunk / DID / firewall / cert) — see Inbound PSTN. - Live architecture map: GET /system/topology (platform admin) → {nodes, edges} of the running system (control plane, Redis, Neon DB, ingress, coturn, SIP gateway, carrier, and the dynamic mixer pool) with per-node status/health/role/URL + service & k8s names. Dead nodes linger then disappear after 24h. Rendered as the Admin Console's interactive diagram (ADR 0033). - Impersonate a tenant admin (ADR 0034): act-as a tenant to support/configure it. POST /auth/impersonate {"tenant_id": "..."} returns a short-lived token (default 30 min) whose principal is tenant_admin of that tenant — GET /auth/me then reports role: "tenant_admin", the target tenant_id, and impersonated_by: "<your email>" (drive an "Acting as … — exit" banner off that field). POST /auth/impersonate/stop ends it (also just drop the token). Chaining is impossible (an impersonation token can't impersonate). Every mutating action under the token is audited to the real platform admin. - Audit log (ADR 0034): GET /audit (tenant admin sees their tenant; platform admin sees all) → append-only {actor_email, actor_subject, impersonated, action, target, tenant_id, created_at}. Records impersonate.start / impersonate.stop and every METHOD route performed while impersonating. - Platform power (cost control): GET /system/power (platform admin) → the desired posture (full | minimal), who set it, and the power controller's observed per-component progress ({name, kind, desired, ready, state} — states ok | off | starting | stopping | draining). POST /system/power {"mode": "minimal"} gracefully drains the media plane to zero (live calls finish, then mixers/coturn/gateway/STT scale to 0 and the media node pools drain); "full" brings everything back (allow 2-3 min for node provisioning). The website, this API/dev portal and sign-in stay up in minimal. Poll GET /system/power to render honest progress. Deployments can also run a schedule (helm power.schedule): full during business hours (default 09:00-19:00 Europe/London, weekdays), minimal otherwise — applied at boundary crossings only, so a manual override holds until the next scheduled flip. - Live SIP logs: GET /system/sip-logs?source=&limit= (platform admin) → recent SIP dialog events captured off the FreeSWITCH ESL streams — the public gateway AND the mixers — as {t, source, kind, line, detail} (REGISTER / INVITE / answer / hangup-with-cause), newest first. On-demand inbound-call debugging without cluster log access; source=gateway filters to the carrier edge. (Mixers are always captured; the gateway needs GATEWAY_ESL_HOST set.)

tenant_admin — manages THEIR tenant only (everything a trader desk needs): - Traders/users: POST /users (with a password → the trader signs into the turret UI, /ui/turret), PATCH /users/{id}/password|role, POST /users/{id}/clone (set up a new trader like an existing one — copies entitlements, layout, sound profiles). - Unlock override: a locked line records WHO locked it (locked_by_user_id + locked_by_name on conference reads and the line_state frame). Tenant/platform admins can always POST /conferences/{id}/unlock — even for a private room they are not in — and the mixer-level gate is best-effort, so a recycled mixer can never wedge a line in LOCKED. - Edit / remove: PATCH /users/{id} {"display_name": "..."} renames a trader; DELETE /users/{id} removes them (legs, SIP credentials, entitlements, layouts and sound profiles go too; self-delete is refused with 409). DELETE /desks/{id} removes a desk (seated traders are simply unseated). DELETE /conferences/{id} removes a line — refused with 409 while the room is live, or while it holds recordings unless ?force=true; turret layout buttons pointing at the line are blanked automatically. - Sound profiles: /profiles CRUD + GET/PUT /profiles/assignments (per-user speaker+mic combos, stored by device label so they follow the trader to any turret). - Lines: a line = a conference with an extension (POST /conferences / /hoots / /lines); membership via /users/{id}/lines (the entitlement that scopes BLF + the turret button matrix). - Turret layouts: GET/PUT /users/{id}/layout (the button-matrix the strip renders). - Desks: /desks CRUD + seating + desk head. - DIDs → their lines: PATCH /gateways/dids/{id} (point a carrier number they own at one of their lines; cross-tenant is refused). - Recording policy: PATCH /tenants/{id}/policy.

member (trader) — no admin surface; signs into the turret UI and uses voice/chat.

Build-a-tenant flow: platform admin creates the tenant + a tenant admin → tenant admin creates traders (with passwords), lines, sound profiles, layouts, desks, and points DIDs → traders log into /ui/turret. The Admin Console makes this a guided surface with a tenant switcher, per-tenant nav, impersonation (act-as a tenant admin to set it up for them), and read-only gateway networking views (GET /gateways/status: static IP, FQDN, admitted carrier CIDRs, TLS, DID routes).

Recordings & transcripts (regulatory)

Recordings are compliance artifacts (MiFID II / CFTC 1.35): retention-locked, immutable, MOVED off the mixer into durable object storage (pluggable: GCS, S3, more later — the client never talks to the store directly).

Search & CDR. GET /recordings/search?q=&trader=&did=&frm=&to= searches a tenant's recordings by transcript text (Postgres full-text) plus trader / DID / date, returning enriched hits (line, parties, snippet). GET /recordings/cdr?frm=&to=&direction= is the call/recording detail record view — one row per recording with direction (line / direct / internal), DID, parties, start/end/duration, status, transcript state, integrity, plus source (mixer | edge) and signed. Both tenant-admin scoped. Long recordings are transcribed in chunks (no length limit).

Edge signed recording (A3, ADR 0040). In addition to the mixer-side WAV, a tenant can opt into a second copy captured in the trader's browser and signed server-side for non-repudiation:


Metering & usage

Per-tenant usage counters for billing. meter() increments Redis day-counters (api calls, call legs, call seconds) which the leader flushes into usage_daily. GET /metering/usage?days= returns per-tenant totals (platform admin sees all or ?tenant_id=; tenant admin sees their own); POST /metering/flush forces a flush. Values are raw counts/seconds — pricing is applied downstream. Adding a billable metric is one meter(tenant, name, n) call.


Voice quality & performance metrics

The platform measures itself (ADR 0025) — same auth, tenant-scoped:

Field bug reports & trace correlation

Every session has a trace id (the 🐞 panel shows it; the SPA sends it as X-Trace-Id on every call, and the API echoes + logs it as structured JSON).

Mixers & scaling (operators)

curl -s $BASE/mixers              -H "Authorization: Bearer $ADMIN"   # pool + load
curl -s $BASE/mixers/diagnostics  -H "Authorization: Bearer $ADMIN"   # phases/events
curl -s -X POST $BASE/mixers/<ID>/drain   ...   # no new placements; calls finish
curl -s    $BASE/mixers/<ID>/drain-status ...   # {"safe_to_terminate":bool,...}
curl -s -X POST $BASE/mixers/<ID>/resume  ...
# platform admin / the autoscaler: add or remove runtime mixers
curl -s -X POST $BASE/mixers/register -d '{"mixer_id":"mixer-2","esl_host":"…"}' ...
curl -s -X DELETE $BASE/mixers/mixer-2/register ...

Conferences are sticky to their home mixer; placement happens at /join. On GKE the autoscaler grows/shrinks the pool on utilization with drain-aware scale-down (ADR 0018).


Error contract

Status Meaning
401 missing/invalid bearer token
403 authenticated, but outside your tenant / role / privacy scope
404 resource not found (incl. cross-tenant probes — they read as not-found)
409 state conflict: duplicate, illegal lifecycle transition, locked conference, full pool
422 request body failed validation (details in the response)

Lifecycle rules are canonical (ADR 0015): conference created→starting→active⇄ held/locked/degraded→ending→ended→archived; participant invited→joining→ connected⇄muted/held→left/removed/failed; call ringing→accepted|declined| cancelled|missed. Illegal transitions are 409s, same-state moves are no-ops.


Tenancy rules (read before building)

Build your own client: a checklist

A complete SOMVAD client (human-written or AI-generated) needs exactly five loops:

  1. Auth: POST /auth/login (or your IdP token) → keep user_id/tenant_id from /auth/me.
  2. Events WS: connect /presence/ws?token=…, reconnect with backoff; maintain a presence map (subject → entry), route type:"signal" to call handling and type:"chat" to chat panes.
  3. Directory + rooms: poll GET /users and GET /conferences (5–15s is plenty; presence makes them feel live without tighter polling).
  4. Voice: the #browser-calling recipe; honest states only — show "joined" when the SIP session is Established, never before.
  5. Actions: ring (POST /calls), answer (accept → join), in-call (invite/transfer/lock/leave), chat (messages), and for supervisors barge.

Machine-readable surface for codegen/agents: /openapi.json (OpenAPI 3.1; also committed as docs/api/openapi.json + a Postman collection). The spec is versioned in info.version and regenerated on every change — diff it to detect new capabilities. There are no breaking changes without a version bump.