One process. All your DIDs, all your trunks.
Strowger is driven entirely over a small HTTP API: launch outbound calls, authorise and configure inbound calls, receive CDRs, manage trunks and routes. This is the external contract — payloads, status codes, and the operational traps.
- Bind: loopback by default (
127.0.0.1:7060). Never expose it publicly — put it behind a TLS reverse proxy or a tunnel if the caller is remote. - Auth: header
X-API-Keyon every endpoint except/health,/metrics,/stats,/dashboard. Empty key = auth disabled (dev only). - Idempotence:
POST /callscarries a client-suppliedcall_id; a known id returns409. Generating unique ids is the client's job. - Secrets: any
api_key/passwordin a payload is write-only — scrubbed from storage, never returned, never logged.
| Method & path | Purpose |
|---|---|
POST /calls | Launch an outbound call (one POST = one attempt) |
GET /calls | List active calls (?state=active) |
GET /calls/:id | Status + attempts for one call |
DELETE /calls/:id | Cancel (queued/ringing) or hang up (BYE + normal finalisation) |
GET /cdrs | Paginated CDR history (?from=&to=&endpoint=) |
GET·PUT·DELETE /endpoints | Hot trunk management (register / IP-auth) |
GET·PUT·DELETE /inbound_routes | Hot inbound DID routing |
GET /health · /stats · /dashboard · /metrics · /trunks | Observability (no auth on the first four) |
A POST is one attempt. There is no retry inside Strowger — the
client re-posts if it wants to, using status + sip_code from
the callback to decide.
{
"call_id": "uuid-from-the-client", // idempotency (409 if already known)
"destination": "+33612345678",
"trunk": "ovh-prod", // default: the single/default trunk
"caller_id": "+33184163651", // optional (see MAN trap below)
"agent_id": "lea", // free label, recorded to the CDR
"backend": "gemini", // "gemini" (default) | "openai" (v2)
"model": "gemini-3.1-flash-live-preview",
"api_key": "…", // optional; falls back to config key
"language": "fr",
"objectives": "…system prompt…",
"first_message": "Bonjour, …",
"tools": [ { "name": "…", "description": "…", "inputSchema": {…} } ],
"proxy_url": "http://…/proxy", // tool execution
"proxy_tools": ["send", "call_status"],
"audio": { // ambient noise (optional)
"background_noise": "office", "background_gain": 0.15,
"activity_noise": "keyboard", "activity_gain": 0.3
},
"rtp_timeout_secs": 30, // RTP watchdog (0 = off)
"recording": false,
"callback_url": "http://…/callback?token=…"
}
| Code | Meaning |
|---|---|
202 | {"call_id","status":"queued"} — accepted, the call goes out |
400 | payload validation |
409 | call_id already known (idempotency) |
422 | unknown / unregistered / disabled trunk |
503 | max_concurrent_calls reached (physical daemon limit) |
The business concurrency limit ("this client may run N calls") lives in the
consumer, not in Strowger. The 503 only signals physical saturation.
For every inbound INVITE, Strowger calls the consumer's webhook (configured per DID). The webhook is the single point of authorisation and configuration. Its HTTP status maps to a SIP response:
| Webhook reply | SIP returned | Meaning |
|---|---|---|
| HTTP 200 + config JSON | answer | authorised (session config below) |
| HTTP 404 | SIP 404 Not Found | this number does not exist |
| HTTP 403 | SIP 603 Decline | caller explicitly refused |
| HTTP 429 | SIP 486 Busy Here | max concurrency (client side) |
| HTTP 503 | SIP 480 Temporarily Unavailable | service unavailable |
| anything else (400, timeout, 5xx, bad JSON) | SIP 486 | never answer "just to see" |
Body of the 200 — same fields as POST /calls, without
destination/trunk:
{
"backend": "gemini", "model": "…",
"objectives": "…", "first_message": "…", "language": "fr",
"tools": [ … ], "proxy_url": "http://…/proxy",
"callback_url": "http://…/callback?token=…",
"recording": false
}
Answer sequencing: on a 200, the AI session is established first
(websocket + confirmed setup); the SIP 200 OK is sent only when the AI is
ready. The caller never hears dead air, and a dead AI backend never answers → zero
billing. Keep the webhook fast: it is on the critical answer path
(webhook_timeout_secs, default 5 s → 486 beyond).
DID matching (multi-format)
A route's did accepts three forms, compared after normalisation to digits
only (+, spaces, dots, dashes and URI params dropped):
| Pattern | Meaning |
|---|---|
+33184163651 | exact match (after normalisation) |
*184163651 | suffix match (≥ 6 digits); 9 digits = FR national number, presentation-invariant |
* | catch-all for the trunk |
Deterministic resolution: exact > longest suffix > *. The matching
logic is implemented once, inside Strowger; the webhook receives the raw did
plus the matched route pattern as the consumer's join key.
At the end of every call, Strowger POSTs to the supplied
callback_url. No auth header is added — put your secret in the URL. Delivery:
3 attempts with backoff; every attempt is logged (the CDR in the database is authoritative).
{
"call_id": "…", "direction": "outbound|inbound",
"status": "completed|failed|no_answer|busy|rejected",
"sip_code": 486,
"destination": "…", "caller_id": "…", "agent_id": "…",
"trunk": "ovh-prod", "backend": "gemini", "model": "…",
"cdr": { /* full CDR, see below */ },
"transcription": "…", "tool_results": [ … ],
"recording": { // if recording=true
"urls": { "mixed": "http://…/recordings/{call_id}/mixed.wav?token=…",
"in": "…", "out": "…" },
"expires_at": "…" // 24 h retention (configurable) then purge
}
}
Recording URLs are tokenised and expire — download the WAV before expires_at.
Telco metrics, four ways out
Every finished call produces a CDR available via (1) SQLite (source of truth,
GET /calls/:id), (2) the callback, (3) a daily rotating CSV
data_dir/cdr/YYYY-MM-DD.csv, and (4) GET /cdrs (paginated JSON).
| Field | Meaning |
|---|---|
setup_time | INVITE sent (out) / received (in) |
pdd_ms | Post-Dial Delay: INVITE → first provisional (18x) |
ring_secs | 18x → 200 OK (ring duration) |
answer_time · end_time | 200 OK (null if unanswered) · BYE/CANCEL/failure |
duration_secs · billsec | total · billable (answer → end) |
disposition | ANSWERED / NO ANSWER / BUSY / FAILED / REJECTED |
released_by | caller / callee / api / watchdog_rtp / watchdog_silence / session_timer / shutdown / max_duration |
codec | PCMA / PCMU negotiated |
rtp_in_packets · rtp_out_packets | media counters |
ttft_ms | house metric: first agent audio (time-to-first-token) |
An endpoint is a SIP relationship with an operator/PBX — either REGISTER
(credentials) or IP-auth (register: false, no credentials; the host
enters the allowlist, OPTIONS keepalive becomes the liveness signal). Create, inspect,
modify, drain and delete trunks hot, without a restart. Status exposes
registration, liveness, last_options_rtt_ms,
calls_in_progress. password is write-only.
The routing counterpart of /endpoints: provision inbound DID routes by
API, visible to the very next INVITE, no TOML reload. Idempotent upsert keyed on
(trunk, normalised did): 201 on create, 200 on
replace, 409 on pattern collision. When an API route and a config route
share the exact key, the API route wins (the config route is shadowed and reappears if
the API route is deleted).
The daemon serves its own observability — nothing to install:
GET /health— global state (endpoints, queues, AI backend reachable).GET /stats— structured JSON: registrations by state, active calls, TTFT p95, RTP loss, underruns, dispositions, last CDRs.GET /dashboard— standalone HTML page (auto-refresh), reads/stats.GET /metrics— Prometheus format, series prefixedsipvox_.GET /trunks— per-trunk registration, expiry, liveness, calls in progress.
- Caller-ID & MAN (France, since 2026-01): the presented CLI must be a number native to the emitting trunk. Presenting another operator's number → the call is cut before it reaches the callee (attestation C of the number-authentication mechanism, ≈ STIR/SHAKEN). On an OVH trunk, keep the default.
- Disjoint RTP ranges: if another softswitch runs on the same machine, keep
separate RTP ranges (Strowger defaults to
22000-22999). A collision disguises itself as mass "no answer". - RTP watchdog: a call with no inbound RTP for
rtp_timeout_secs(default 30 s) is hung up (released_by=watchdog_rtp). Set0to disable per call. - Session timers (RFC 4028): honoured on the UAS side; a
Session-Expires < 90gets422 Min-SE: 90;Require: 100relgets420(not supported in v1).