HTTP API reference

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.

Conventions
  • Bind. Loopback by default (daemon.http_host/ http_port, 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-Key on every endpoint except /health, /metrics, /stats and /dashboard. The key comes from STROWGER_API_KEY; an empty key disables auth (dev only).
  • JSON everywhere. Request and response bodies are JSON.
  • Idempotence. POST /calls carries a client-supplied call_id; a known id returns 409. Generating unique ids is the client's job — there is no retry inside strowger.
  • Secrets. Any api_key/password sent in a payload is write-only — scrubbed from storage, never returned, never logged.
Outbound calls
POST/calls

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=…"
}
CodeMeaning
202{"call_id", "status": "queued"} — accepted, the call goes out
400payload validation
409call_id already known (idempotency)
422unknown / unregistered / disabled trunk
503max_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.

GET/calls?state=activeGET/calls/:idDELETE/calls/:id

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).

Inbound calls
GET{webhook_url}?did=&caller=&route=

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 replySIP returnedMeaning
HTTP 200 + config JSONanswersauthorised (session config below)
HTTP 404SIP 404 Not Foundthis number does not exist
HTTP 403SIP 603 Declinecaller explicitly refused
HTTP 429SIP 486 Busy Heremax concurrency reached (client side)
HTTP 503SIP 480 Temporarily Unavailableservice unavailable
anything else (400, timeout, other 5xx, unreadable JSON, 200 without AI context)SIP 486never 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):

PatternMeaning
+33184163651exact match (after normalisation) — does not match a 0033…/0… presentation
*184163651suffix 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.

End-of-call callback

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.

CDR — telco metrics

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.

FieldMeaning
setup_timeINVITE sent (outbound) / received (inbound)
pdd_msPost-Dial Delay
ring_secsring duration
answer_timewhen the call was answered
end_timewhen the call ended
duration_secstotal call duration
billsecbillable duration
answeredwhether the call was answered
dispositionANSWERED / NO ANSWER / BUSY / FAILED / REJECTED
sip_codefinal SIP status code
released_bywho hung up — see below
codecnegotiated codec
rtp_in_packets / rtp_out_packetsmedia packet counters
ttft_mstime to first agent audio token

released_by values: caller · callee · api · watchdog_rtp · watchdog_silence · session_timer · shutdown · max_duration.

Hot trunk management — /endpoints

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).

MethodEffect
POST /endpointsCreate 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}/deactivateGraceful drain: new calls refused (outbound 422, inbound 503), calls in progress continue. {"drain":"force"} also hangs up in-progress calls.
POST /endpoints/{name}/activateRe-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.

Hot inbound routes — /inbound_routes

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).

MethodEffect
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_routesIdempotent 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_routesKey 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).

Licence registration — /license/register

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.

Built-in AI Tools & Transfer

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-closed allow/deny policy 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 hairpin mode, two linked CDRs are produced (one per leg) with correlated call group identifiers.
Observability

The daemon serves its own observability — nothing to install:

  • GET /healthliveness: the process is alive. Returns 200 with status: "ok" or "degraded" in the body (degraded = licence in refusal, or every configured trunk unusable). Returns 503 only 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 stays 200).
  • GET /readyreadiness, separate from liveness: can this node actually carry traffic. Checks four components, 200 if all pass, else 503 with per-check detail (checks[].name/ok/detail): db (background SELECT 1 probe 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 prefixed strowger_. 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_fds and strowger_rss_bytes (OS resources, Linux — compare against the systemd unit's LimitNOFILE/ MemoryMax), plus strowger_db_queue_depth, strowger_db_group_commits_total, strowger_db_group_commit_jobs_total, strowger_db_group_commit_failures_total, strowger_callback_backlog and strowger_api_keys_inflight.
  • GET /trunks — per-trunk registration, expiry, liveness, calls in progress.
Operational traps
  • 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 to 0 per 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). A Session-Expires < 90 gets 422 Min-SE: 90; Require: 100rel (PRACK) gets 420 (unsupported in v1).
  • Inbound webhook is on the critical path. Keep it under a few hundred ms; past webhook_timeout_secs the call is rejected (486).