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. This is the external contract — payloads, status codes, and the operational traps.

Conventions
  • 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-Key on every endpoint except /health, /metrics, /stats, /dashboard. Empty key = auth disabled (dev only).
  • Idempotence: POST /calls carries a client-supplied call_id; a known id returns 409. Generating unique ids is the client's job.
  • Secrets: any api_key/password in a payload is write-only — scrubbed from storage, never returned, never logged.
Endpoints
Method & pathPurpose
POST /callsLaunch an outbound call (one POST = one attempt)
GET /callsList active calls (?state=active)
GET /calls/:idStatus + attempts for one call
DELETE /calls/:idCancel (queued/ringing) or hang up (BYE + normal finalisation)
GET /cdrsPaginated CDR history (?from=&to=&endpoint=)
GET·PUT·DELETE /endpointsHot trunk management (register / IP-auth)
GET·PUT·DELETE /inbound_routesHot inbound DID routing
GET /health · /stats · /dashboard · /metrics · /trunksObservability (no auth on the first four)
Outbound
POST/calls

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=…"
}
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") lives in the consumer, not in Strowger. The 503 only signals physical saturation.

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

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

PatternMeaning
+33184163651exact match (after normalisation)
*184163651suffix 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.

End-of-call callback

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.

CDR

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

FieldMeaning
setup_timeINVITE sent (out) / received (in)
pdd_msPost-Dial Delay: INVITE → first provisional (18x)
ring_secs18x → 200 OK (ring duration)
answer_time · end_time200 OK (null if unanswered) · BYE/CANCEL/failure
duration_secs · billsectotal · billable (answer → end)
dispositionANSWERED / NO ANSWER / BUSY / FAILED / REJECTED
released_bycaller / callee / api / watchdog_rtp / watchdog_silence / session_timer / shutdown / max_duration
codecPCMA / PCMU negotiated
rtp_in_packets · rtp_out_packetsmedia counters
ttft_mshouse metric: first agent audio (time-to-first-token)
Trunks & routes
POSTGETPUTDELETE/endpoints

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.

GETPUTDELETE/inbound_routes

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

Observability

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 prefixed sipvox_.
  • GET /trunks — per-trunk registration, expiry, liveness, calls in progress.
Operational traps
  • 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). Set 0 to disable per call.
  • Session timers (RFC 4028): honoured on the UAS side; a Session-Expires < 90 gets 422 Min-SE: 90; Require: 100rel gets 420 (not supported in v1).

A pilot deploys in one morning: one binary, one config file, your trunk.

STROWGER — CARRIER-READY VOICE AGENTS · POWERED BY THE SIPVOX ENGINE · WORKING DOCUMENT, INDICATIVE PRICING · NAME PENDING TRADEMARK CHECK (EUIPO/USPTO)