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 hot. This is the external contract — payloads, status codes, and the operational traps.
- Bind. Loopback by default (
daemon.http_host/http_port, default127.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,/statsand/dashboard. The key comes fromSTROWGER_API_KEY; an empty key disables auth (dev only). - JSON everywhere. Request and response bodies are JSON.
- Idempotence.
POST /callscarries a client-suppliedcall_id; a known id returns409. Generating unique ids is the client's job — there is no retry inside strowger. - Secrets. Any
api_key/passwordsent in a payload is write-only — scrubbed from storage, never returned, never logged.
A POST is one attempt. There is no retry inside strowger — the
client re-posts if it wants to, using status and sip_code
from the end-of-call callback to decide.
{
"call_id": "uuid-generated-by-the-client", // idempotency (409 if already known)
"destination": "+33612345678",
"trunk": "ovh-prod", // default: the single/default trunk
"caller_id": "+33184163651", // optional (see Operational traps)
"agent_id": "lea", // free label, recorded to the CDR
"backend": "gemini", // "gemini" (default) | "openai" (v2)
"model": "gemini-3.1-flash-live-preview", // override for this call
"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"], // filters the tool listing
"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 = disabled)
"recording": false,
"callback_url": "http://…/callback?token=…"
}
RC6 model selection: "backend": "gemini", "model": "gemini-3.8-live" selects the qualified standard Gemini 3.8 Live model for this call. The same fields work in an inbound admission response. Without an override, Strowger uses the configured backend model. See qualification scope and global configuration. Extended Thinking and GPT-Live require separate integration.
| 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 at
once") lives in the consumer, not in strowger — the 503 only signals
physical saturation of the daemon.
List active calls, fetch one active call's status, or cancel/hang it up
(DELETE cancels a ringing call or sends BYE to an answered one).
GET /calls/:id only ever returns active calls — it answers
404 as soon as the call has finished; there is no HTTP endpoint that
serves finished-call history (see CDR below for how to get that data instead).
strowger is registered (REGISTER) or IP-authed on its trunks: it receives inbound
INVITEs on its DIDs. For every inbound call, it makes this GET to the consumer
service configured per DID ([[inbound_route]] of type
webhook, or provisioned hot via /inbound_routes). Query
params (URL-encoded):
did— the called number, raw as presented by the carrier (can be+33…,0033…,0…, sometimes with URI params like;user=phone).caller— the calling number.route— the matched route's pattern, as configured (may contain*/+). This is the consumer's join key: the multi-format matching logic is implemented once, inside strowger.
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 | answers | 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 reached (client side) |
| HTTP 503 | SIP 480 Temporarily Unavailable | service unavailable |
| anything else (400, timeout, other 5xx, unreadable JSON, 200 without AI context) | 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",
"proxy_tools": [ … ],
"callback_url": "http://…/callback?token=…",
"recording": false,
"agent_id": "…"
}
Answer sequencing. On a 200, strowger establishes the AI session
first (websocket + confirmed setup) and only sends the SIP 200 OK
once the AI is ready — never before answer_delay_ms since the 180
Ringing. Consequences: the caller never hears dead air at answer, and a dead
AI backend simply never answers (SIP 480) — zero billing. Keep the webhook
fast: it sits on the critical answer path
(inbound.webhook_timeout_secs, default 5 s — beyond that, 486).
Callbacks. There is no local callback/return-call logic —
the consumer sees caller in the webhook and recognises a return call on
its own side. The end-of-call callback's direction is only ever
outbound or inbound.
DID matching (multi-format)
A route's did accepts three forms, compared after normalising to digits
only (+, spaces, dots, dashes and URI params dropped):
| Pattern | Meaning |
|---|---|
+33184163651 | exact match (after
normalisation) — does not match a 0033…/0…
presentation |
*184163651 | suffix match (≥ 6 digits); 9 digits = a French national number, presentation-invariant — the sensible default |
* | catch-all for the trunk |
Deterministic resolution: exact > longest suffix > *. Two patterns
identical after normalisation are rejected at validation time.
At the end of every call (outbound, or inbound that was answered), strowger
POSTs to the supplied callback_url. No auth header is added —
put your secret in the URL. Delivery is 10 attempts with backoff; every attempt
is logged regardless of outcome, and the callback is replayed from its append-only
journal on failure. The CDR audit trail is kept independently in its own on-disk
journal, so a failed callback never loses the CDR.
{
"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": { // present 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; past that, the file is purged. Tokens are loaded once from
SQLite at startup and authenticated from an in-memory projection afterwards.
Every finished call produces a CDR, available through two channels: the
end-of-call callback above (recommended), and a daily journal —
.journal files on disk, JSON Lines format, under
data_dir/cdr/. Newer CDRs are not stored in SQLite and no CSV export
is produced (the historical SQLite tables/functions remain only for migration
compatibility). There is no HTTP endpoint to read historical CDRs —
GET /calls/:id only ever returns active calls and answers
404 once the call has ended.
| Field | Meaning |
|---|---|
setup_time | INVITE sent (outbound) / received (inbound) |
pdd_ms | Post-Dial Delay |
ring_secs | ring duration |
answer_time | when the call was answered |
end_time | when the call ended |
duration_secs | total call duration |
billsec | billable duration |
answered | whether the call was answered |
disposition | ANSWERED / NO ANSWER / BUSY / FAILED / REJECTED |
sip_code | final SIP status code |
released_by | who hung up — see below |
codec | negotiated codec |
rtp_in_packets / rtp_out_packets | media packet counters |
ttft_ms | time to first agent audio token |
released_by values: caller ·
callee · api · watchdog_rtp ·
watchdog_silence · session_timer · shutdown ·
max_duration.
An endpoint is a SIP relationship with an operator or PBX. Two modes:
REGISTER (credentials) or IP-auth (register: false — no
credentials, the host enters the allowlist and the OPTIONS keepalive becomes the
liveness signal).
| Method | Effect |
|---|---|
POST /endpoints | Create and start hot (REGISTER if
needed, allowlist refreshed). 409 name taken, 400
validation. |
GET /endpoints[/{name}] | Status:
registration (registered /
rejected(code) / connecting / none),
registration_expires_in, liveness
(alive/timeout/unknown),
last_options_rtt_ms, last_seen,
calls_in_progress, source
(api/config). |
PUT /endpoints/{name} | Modify (re-REGISTER if SIP
fields changed; omitting password leaves it unchanged). |
POST /endpoints/{name}/deactivate | Graceful drain: new
calls refused (outbound 422, inbound 503), calls in
progress continue. {"drain":"force"} also hangs up in-progress
calls. |
POST /endpoints/{name}/activate | Re-activate. |
DELETE /endpoints/{name} | Requires
active=false and calls_in_progress=0 (otherwise
409). Un-REGISTERs and removes from the allowlist. |
password is write-only: never returned, never logged.
[[trunk]] entries from the TOML coexist with API-created ones
(source: "config", modifiable only by editing the file); a name conflict
between the two is a startup error.
The routing counterpart of /endpoints: provision inbound DID routes by
API, with no TOML reload and no restart — an upsert or delete is visible to the
very next inbound INVITE. API routes persist in strowger.db and reload at
boot; [[inbound_route]] entries from the file coexist
(source: "config", immutable via the API).
| Method | Effect |
|---|---|
GET /inbound_routes |
{"inbound_routes": [{trunk, did, source, handler, shadowed?}]} — every
route (config + API). Query strings inside handler URLs are masked
(?…), since a token can live there. |
PUT /inbound_routes | Idempotent upsert, keyed on
(trunk, normalised did): 201 on create, 200
on replace. Body:
{"trunk": "ovh", "did": "*184163651", "handler": {"type": "webhook", "url": "http://…"}}.
Only type: "webhook" is provisionable via the API.
400 validation (DID pattern, suffix ≥ 6 digits, http(s) URL),
409 pattern collision. |
DELETE /inbound_routes | Key in the query
(?trunk=…&did=…) or JSON body ({"trunk", "did"} —
safer for a did containing +). 204 deleted,
404 unknown, 409 if the key names a config-file
route. |
Config/API merge. On an exact (trunk, normalised did)
collision, the API route wins — the config route is hidden
(shadowed: true in the GET) and reappears if the API route is deleted. Any
other pattern collision (different trunks) is refused 409 at write time;
if it only appears at boot (file edited afterwards), the API route still wins, with a
warning logged. A route's trunk does not need to exist yet at
PUT time; it only matters for the 503 refusal when that
endpoint is deactivated (drain).
The endpoint behind strowger-cli register (see the
CLI reference). Two payload shapes:
// Commercial licence issued in advance
{ "key": "…" }
// Demo — light identification, keyless
{ "company": "…", "contact_name": "…", "email": "…", "phone": "…" }
On success, the daemon returns the activated licence: license_id,
customer, state, expires_at,
max_concurrent_calls, max_trunks,
max_call_seconds.
Strowger provides built-in tools natively intercepted by the daemon:
end_call({"reason": "string"}) — Cleanly hangs up the active call with a reason.transfer_call({"destination": "string"}) — Initiates an agent call transfer to the specified destination (E.164 phone number or SIP URI). Destination is checked against the fail-closedallow/denypolicy configured in[transfer].
Transfer CDR Fields: When a call is transferred, the end-of-call callback and CDR reflect the outcome:
released_by: Set to"transferred"on successful transfer.transfer_mode:"refer"or"hairpin".transfer_target: The sanitized destination URI or phone number.- In
hairpinmode, two linked CDRs are produced (one per leg) with correlated call group identifiers.
The daemon serves its own observability — nothing to install:
GET /health— liveness: the process is alive. Returns200withstatus: "ok"or"degraded"in the body (degraded = licence in refusal, or every configured trunk unusable). Returns503only when the whole SIP plane is down (trunks configured, none usable) — a binary signal for a load balancer, distinct from a mere licence degradation (which stays200).GET /ready— readiness, separate from liveness: can this node actually carry traffic. Checks four components,200if all pass, else503with per-check detail (checks[].name/ok/detail):db(backgroundSELECT 1probe every 2 s),disk(recording volume has ≥ 500 MiB free, skipped if recording is off),rtp_ports(at least one free RTP port pair),sip_registration(at least one usable trunk, if trunks are configured). Use this to gate load-balancer/k8s rotation, not/health.GET /stats— structured JSON: registrations by state, active calls, TTFT (p95), RTP loss, playout underruns, dispositions.GET /dashboard— a standalone, auto-refreshing HTML page reading/stats.GET /metrics— Prometheus format, series prefixedstrowger_. Beyond business metrics, it exposes saturation signals that precede collapse:strowger_rtp_ports_available/_total(media capacity),strowger_http_requests_active+strowger_http_overload_rejections_total(HTTP pressure),strowger_event_loop_lag_ms_last/_max(Tokio event-loop lag),strowger_open_fdsandstrowger_rss_bytes(OS resources, Linux — compare against the systemd unit'sLimitNOFILE/MemoryMax), plusstrowger_db_queue_depth,strowger_db_group_commits_total,strowger_db_group_commit_jobs_total,strowger_db_group_commit_failures_total,strowger_callback_backlogandstrowger_api_keys_inflight.GET /trunks— per-trunk registration, expiry, liveness, calls in progress.
- Caller-ID and the French MAN mechanism (since 2026-01). The presented CLI must be a number native to the emitting trunk. Presenting another operator's number gets the call cut before it reaches the callee (attestation C of the number authentication mechanism, ≈ STIR/SHAKEN). On an OVH trunk, keep the default (the identity is the trunk's own number) unless you have a validated native CLI.
- Disjoint RTP ranges. If another softswitch runs on the same box, keep
separate RTP port ranges (strowger defaults to
20002-40001). 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). Set it to0per call to disable — e.g. for a destination that never sends media. - Session timers (RFC 4028). If the carrier requires
Session-Expires, strowger honours it UAS-side (refresh + BYE at expiry if not renewed,released_by=session_timer). ASession-Expires < 90gets422 Min-SE: 90;Require: 100rel(PRACK) gets420(unsupported in v1). - Inbound webhook is on the critical path. Keep it under a few hundred ms;
past
webhook_timeout_secsthe call is rejected (486).