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:
/docs · raw spec /openapi.jsondocs/api/openapi.json + docs/api/postman_collection.jsonAuthorization: Bearer … or ?token=… on /devguide, /docs and /openapi.json
(the console's links carry it automatically). HTTP Basic remains as fallback.Every request below uses
Authorization: Bearer $TOKENunless marked public. SetBASEto 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 | /ui → Admin |
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) |
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.
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":"…"}
# 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.
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 } ]
}
POST /conferences/{id}/join → take ws_url + room from the response (each
mixer has its OWN wss endpoint — always register where /join points you).sip:<username>@<domain>, transport ws_url,
auth username/password from /browser/config, and the ICE servers + policy.sip:<room>@<domain> with an audio-only offer. The mixer
is ICE-lite and answers without client-side candidates beyond the relay.POST /conferences/{id}/leave.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.
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.
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.
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"}'
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 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.
Two paths, both in-cluster (trader audio never goes to a third party). GET
/browser/config tells you which is live:
streaming_captions is true, open
WS /captions/stream/{conference_id}?token=<JWT> and stream the call audio as
16 kHz mono PCM (int16 LE) binary frames. You get back partial/final results
that update in place — a partial REPLACES the last partial, a final COMMITS it:
jsonc
{"type":"partial","text":"sell ten"} // still changing — replace in place
{"type":"final","text":"sell ten lots of gilts"} // settled — commit, partial resets
Send the text frame "eof" (or just close) to flush the trailing utterance. The
relay proxies to the in-cluster Vosk service; you never hold its address.live_captions is true, POST short
complete audio chunks to /conferences/{id}/captions/audio; text fans back as a
{"type":"caption"} frame on the events WS. ~3-5s behind (structural).Captions are per-tenant opt-in (PATCH /tenants/{id}/policy {"live_captions":true}).
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).
Two independent dimensions:
| Dimension | Values | Meaning |
|---|---|---|
visibility |
tenant / private |
private = invite-only AND hidden from non-invitees (direct calls use this) |
status |
active ⇄ locked |
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).
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.
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).
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:
POST /tenants mints the realm + SPA client + admin user. The response carries a
provisioning block with the admin's one-time temp password (shown once); the user
is forced to change it on first login (and may register a passkey).POST /users / /users/assign / /users/{id}/clone return a provisioning block with
the new user's temp password when the tenant has an organization code (realm).POST /users/{id}/reset-keycloak (tenant admin) → a NEW one-time temp password — the
no-email "forgot password" replacement.POST /tenants/{id}/provision (platform admin) reconciles a realm (applies the 12h
trading-day session) and backfills Keycloak users for any DB users missing one. Idempotent.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).
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).
POST /hoots (dial 8xxx), GET
/hoots/{id}/members, supervisor POST /hoots/{id}/supervise
(monitor|barge|whisper on a live member).POST /lines, appearances at /lines/{id}/appearances.POST /private-wires, POST
/private-wires/{id}/connect rings both ends with auto-answer.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.
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.
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.
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.
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 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).
PATCH /tenants/{id}/policy
{"auto_record":true,"auto_transcribe":true,"retention_days":2555} — every call
in the tenant records from the moment it activates. Manual control remains:
POST /recordings {conference_id} / POST /recordings/{id}/stop.upload_state: local -> pending_upload -> stored | upload_failed.
On stop, the recording mixer's uploader moves the WAV to the blob store and the
row gains blob_uri (gs://… or s3://…).GET /recordings/{id}/audio → the control plane streams the WAV
from the store (tenant-scoped; 409 until stored).auto_transcribe, a stored recording queues a transcript
(pending -> processing -> completed|failed); fetch via
GET /recordings/{id}/transcript → {text, segments_json:[{t0,t1,text}]}.recordings_visibility in the tenant policy —
tenant_admin (default), desk_head (heads see THEIR desk's calls), or
member. Enforced on list, playback, and transcripts.DELETE /recordings/{id} is
tenant-admin only and refused (409, "retention hold") before retention_until;
blob deletion is best-effort and reported honestly (blob_deleted — a
retention-locked bucket refuses, by design).POST /recordings / POST /recordings/{id}/stop.safe_to_terminate includes recordings).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:
PATCH /tenants/{id}/policy {"edge_recording":true}. GET /browser/config
then returns edge_recording:true, telling the UI to capture.POST /recordings/edge?conference_id=…&started_at=…&ended_at=…&duration_seconds=… with the
raw recorded bytes as the request body and the Content-Type set to the recording mime
(e.g. audio/webm). Only a participant of the call (or a tenant admin) may upload.somvad-edge-rec-v1|id|tenant|conference|signed_by|started_at|sha256|size, then
stores both on the Recording (source:"edge", signature, signature_alg, signed_by,
signed_at). The row is immediately stored (no mixer uploader involved).GET /recordings/{id}/verify (tenant admin) re-fetches the stored bytes,
recomputes the hash + HMAC, and returns {sha256_match, signature_valid, manifest, …} — the
compliance proof that the artefact has not been tampered with or substituted.GET /recordings/{id}/audio (streamed with their
own content-type) and are searchable/CDR-listed like any recording.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.
The platform measures itself (ADR 0025) — same auth, tenant-scoped:
GET /metrics/quality?minutes=1440 — per-LEG voice QoS straight from the
mixers' RTP statistics: MOS (≥4.0 good / ≥3.6 fair), jitter (p95 ms),
packet loss %, call setup ms, codec, duration; aggregated, per mixer,
plus the recent legs. Tenant admins are scoped to their tenant; platform
admins see the platform (optional tenant_id=). Rows appear as calls end.GET /metrics/api?minutes=60 — per-route latency p50/p95, rpm, error rate
(chat sends, rings, joins — everything). Platform admins.GET /metrics/prometheus — the same numbers in Prometheus text format, so
Grafana / Managed Prometheus can scrape later (bearer token) without
re-instrumentation.POST /metrics/client — report your own leg from the browser (ADR 0027):
at hangup, summarise RTCPeerConnection.getStats() into {room, codec,
duration_s, mos, jitter_max_ms, loss_rate, rtt_ms, packets_in, packets_out}.
Rows land beside the mixer legs tagged source:"client" (the client block in
/metrics/quality; never mixed into the mixer aggregates). Any signed-in user
may post; the reference SPA does this automatically and shows a live in-call
meter (MOS/rtt/jitter/loss) from the same samples.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).
POST /bugs. Copy logs puts the same
bundle on the clipboard.GET /bugs, GET /bugs/{id}/logs|screenshot,
PATCH /bugs/{id} status new/seen/fixed/wontfix). Server-side view of the same
session: Cloud Logging filter jsonPayload.trace_id="<id>".GITHUB_TOKEN + GITHUB_REPO and every report
also opens an issue.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).
| 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.
tenant_id
decides which profile a request acts as (log in again to switch desks).member (use voice/chat, create conferences), tenant_admin (manage ITS
tenant's users/desks), platform_admin (env allow-list; create tenants, cross
boundaries). Desk head is supervision scope, not a role.A complete SOMVAD client (human-written or AI-generated) needs exactly five loops:
POST /auth/login (or your IdP token) → keep user_id/tenant_id
from /auth/me./presence/ws?token=…, reconnect with backoff; maintain
a presence map (subject → entry), route type:"signal" to call handling and
type:"chat" to chat panes.GET /users and GET /conferences (5–15s is
plenty; presence makes them feel live without tighter polling).#browser-calling recipe; honest states only — show "joined"
when the SIP session is Established, never before.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.