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=…"
}
| 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).