Docs REST API
REST API
sipnab's REST API: authentication, every endpoint with its response shape, status codes, curl recipes, and the security model. Metric names and their meaning are on the Prometheus metrics page.
On this page
sipnab includes an optional REST API and Prometheus metrics endpoint, enabled with the api feature flag. The API runs as a thread inside the sipnab process, reading the same in-memory dialog/stream stores as the capture pipeline. Reads never alter the capture, with one deliberate exception: POST /v1/persistence sets the persistence gate, so a bearer token can stop and start recording to disk. Every other route only reads.
CLI Reference catalogs every API flag.
Looking for AI-agent access? sipnab also serves the dialog / RTP / diagnostic stores over the Model Context Protocol. See MCP Server – both doors read the same in-memory stores in the same process, so one running instance can serve both at once.
The two response shapes are converging deliberately and are not yet identical. Where a metric is reachable through one door and not the other, that is drift rather than design:
tests/surface_parity_test.rsfails the build on it, and the fix is always to add the metric to the missing surface.
Getting started
Step 1: Build with API support
sipnab’s REST API requires the api feature flag:
cargo build --release --features api
That is additive to the default features, so it gives you the REST API on top of the TUI, audio, and the standalone metrics server. Build full instead when you also want the MCP server, HEP forwarding, and the TLS-gated features (STIR/SHAKEN claim reporting, SRTP decryption) in the same binary — the REST API itself is identical either way, so choose on what else you need:
cargo build --release --features full
Step 2: Choose an API key
You create the API key yourself – there’s no registration. Pick any string:
export SIPNAB_API_KEY="my-secret-token-change-this"
Security: Use a strong random string in production. Every request carries the key as a Bearer token. An environment variable keeps it out of
psoutput.
Step 3: Start sipnab with the API
Live capture:
sudo sipnab --api 127.0.0.1:8080 --api-key "$SIPNAB_API_KEY"
Analyze a pcap file:
sipnab -N -I capture.pcap --api 127.0.0.1:8080 --api-key "$SIPNAB_API_KEY"
The process stays alive serving the API until you press Ctrl-C.
Step 4: Query the API
curl -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogs
More client code and integrations: ready-to-adapt clients in several languages live in API Client Examples; HEP forwarding, event hooks, fail2ban, and syslog live in Integrations.
Authentication
Credentials are always presented the same way — an Authorization: Bearer
header. sipnab takes nothing else: not an X-API-Key header, not a query
parameter, not HTTP Basic (Basic applies only to the standalone metrics server,
below).
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/v1/dialogs
There are two kinds of credential, and the server accepts either. For the
full lifecycle of the signed kind — minting with --mint-token, TTLs,
signing-key rotation, and revocation denylists — see
Bearer-token authentication.
Method 1 — static API key
A shared secret with no expiry. Simplest to set up, and you revoke it by restarting with a different key.
Both lines below are one procedure — the server reads the variable the first
line sets. Run the second on its own and $SIPNAB_API_KEY is empty, which on
this loopback bind starts an API that accepts every request unauthenticated:
# Run all of these, in order.
export SIPNAB_API_KEY="$(openssl rand -hex 32)"
sipnab --api 127.0.0.1:8080 --api-key "$SIPNAB_API_KEY"
| Setting | Purpose |
|---|---|
--api-key <KEY> / $SIPNAB_API_KEY | The static secret. Prefer the environment variable — argv is visible in ps. |
Method 2 — signed bearer tokens
Self-describing HMAC tokens that carry their own expiry and id, so you get expiry, rotation, and revocation without restarting the server. Prefer this for CI, automation, and anything multi-client.
The token format is:
s2.<base64url(payload)>.<base64url(HMAC-SHA256)>
where payload is compact JSON
{"id":"<jti>","exp":<unix_seconds>,"aud":"<api|mcp>"} and the signature is
HMAC-SHA256(signing_key, "s2." + base64url(payload)). Verification is
stateless: the server recomputes the HMAC, compares it in constant time against
every configured signing key, then requires the audience to match, exp > now,
and that id is not revoked. A malformed token loses, every time (fail-closed).
Audience binding. aud names the surface the token belongs to, so the HTTP
MCP endpoint turns away a token minted from --api-signing-key, and vice versa —
even when both carry the same signing key. The
version prefix is part of the signed input, so an s2 token cannot be rewritten
as s1 to shed its binding. sipnab rejects the pre-aud s1 format — it
carries no audience, so honoring it would leave this binding best-effort. An
s1 token returns 401. Re-mint with --mint-token. Note that static
--api-key secrets carry no audience — the binding applies to signed tokens
only.
| Setting | Purpose |
|---|---|
--api-signing-key <KEY> / $SIPNAB_API_SIGNING_KEY | HMAC signing key. Repeatable — the first key mints, all keys verify. |
--api-signing-key-file <FILE> | Read one key from a file (contents trimmed). Prepended to any --api-signing-key, so it becomes the minting key. |
--api-token-ttl <SECS> | Lifetime of a minted token. Default 3600. |
--mint-token | Sign a token with the first configured key, print it, and exit. Starts no capture and no server. |
--token-id <ID> | The token’s id (jti), used later for revocation. Defaults to a generated id. |
--token-scope <SCOPE> | full (default) or metrics. See Scope below. |
--api-revoked-file <FILE> | Denylist of revoked token ids, one per line (blanks and # comments ignored). |
Scope. --token-scope metrics mints a token that reaches GET /metrics
and nothing else. Every other route returns 401. Mint one for a scrape job.
The reason to bother: this is a TLS-decrypting capture tool, so /v1/dialogs
and /v1/streams return message bodies — the call content itself. Without a
scope split, a monitoring system that needs one counter must hold the keys to
all of it.
# scrape-only credential for Prometheus
sipnab --api-signing-key "$KEY" --mint-token --token-scope metrics
Three properties worth knowing:
fullis the default, and satisfies everything. Afulltoken still reaches/metrics, so adding this claim narrowed no existing deployment.- A token minted before the claim existed is
full. Absentscopemeansfull— the opposite ofaud, which fails closed when missing. Upgrading does not revoke credentials already in the field. - The signature covers the claim. Stripping or editing
scopeinvalidates the signature, so a holder cannot widen their own token.
Static --api-key secrets carry no claims at all and are therefore full.
Scoping requires a signed token. The scope applies to the REST API — the MCP
surface has no /metrics, so --token-scope metrics with --mcp-signing-key
fails at mint time rather than producing a token that can never
authenticate.
Mint a token. Generate the signing key first. Everything below reads it
from $KEY, including the server — mint against one key and serve with
another and every token you handed out returns 401:
KEY="$(openssl rand -hex 32)"
Then mint one token, not both. A token on the default one-hour TTL:
sipnab --mint-token --api-signing-key "$KEY"
A 24-hour token with an explicit id — give one whenever the token may need revoking before it expires, since the denylist matches on that id:
sipnab --mint-token --api-signing-key "$KEY" --api-token-ttl 86400 --token-id ci-runner-1
Serve with that key, and honor a denylist:
sipnab --api 127.0.0.1:8080 --api-signing-key "$KEY" \
--api-revoked-file /etc/sipnab/revoked.txt
Expiry needs no server action — a token stops verifying once exp <= now.
Rotation comes in two independent forms. Rotate tokens by minting a new
one before the old lapses and migrating clients. Several tokens are valid at
once. Rotate signing keys by passing --api-signing-key more than once: add
the new key alongside the old, mint with the new one, migrate clients, then
drop the old key on the next restart.
Revocation kills a still-valid token before its exp:
echo "ci-runner-1" >> /etc/sipnab/revoked.txt
The file is re-read when its mtime changes, so the token stops working within the next request — no restart.
When sipnab requires authentication
If you configure neither an API key nor a signing key, sipnab runs without authentication and serves every endpoint to anyone who asks. That is deliberate only on a loopback bind. On a non-loopback bind with no credentials configured, the server refuses to start rather than exposing an open API:
REST API refuses to start: --api 0.0.0.0:8080 is non-loopback but no
--api-key / SIPNAB_API_KEY or --api-signing-key / SIPNAB_API_SIGNING_KEY was
supplied. Bind 127.0.0.1, or configure authentication.
Once you configure credentials, every endpoint except /health requires
them. /health is always unauthenticated. Missing, malformed, non-Bearer,
expired, or revoked credentials return 401 Unauthorized. All comparisons are
constant-time, to prevent timing side channels.
Note that sipnab checks the rate limit before authentication, so a client over
its per-IP budget receives 503 Service Unavailable even when its credentials
are invalid.
Metrics endpoints use two different schemes
This catches people out, so it is worth stating plainly:
| Endpoint | Scheme |
|---|---|
/metrics on the REST API (--api) | The same Bearer credential as every other REST endpoint. |
The standalone metrics server (--metrics <ADDR>) | HTTP Basic, via --metrics-auth <user:pass> or --metrics-auth-file <FILE>. |
The standalone server applies the same fail-closed rule: a non-loopback bind
with no --metrics-auth / --metrics-auth-file refuses to start.
API TLS
Direct TLS termination on the API endpoint is not yet implemented —
supplying --api-tls-cert/--api-tls-key makes sipnab refuse to start
with an explanatory error. Terminate TLS in a reverse proxy (nginx,
Caddy, HAProxy) in front of a loopback-bound API instead:
sipnab -d eth0 --api 127.0.0.1:8080 --api-key "secret"
# then proxy https://host/ -> http://127.0.0.1:8080 in your reverse proxy
Bind address & connection limits
The base URL is whatever you pass to --api (e.g., http://127.0.0.1:8080). All network listeners bind to loopback by default. Bind a routable address (e.g. 0.0.0.0:8080) only behind a token and a reverse proxy. Data endpoints use a /v1/ prefix, and utility endpoints (/health, /metrics) have none.
--api-max-conn (default 100) caps concurrent API connections to prevent resource exhaustion. The API refuses a request body larger than 1 MiB (MAX_REQUEST_BODY_BYTES) with HTTP 413 before any handler sees it — defense in depth, since every route is GET today. Requests are additionally rate-limited to 100 per second per source IP. Requests rejected by the rate limiter or connection cap return 503 Service Unavailable (not 429).
OpenAPI specification
sipnab publishes an OpenAPI 3.1 document covering every endpoint on this page:
- The machine-readable document, at https://sipnab.com/openapi.json. This repository carries the same file at
website/static/openapi.json. - An interactive rendering of it, at https://sipnab.com/api-reference/.
Nobody writes that document by hand. A test step derives it from the #[utoipa::path] annotations on the request handlers in src/output/api.rs, so the routes the server serves and the routes the document describes are one list by construction. The same step splices in the shared JSON Schemas under tests/schemas/ rather than describing those response bodies a second time.
tests/openapi_contract_test.rs then compares three things – the document, the live router, and the ### METHOD /path headings below – and fails the build when any of them disagree. Regenerate the document after changing a handler:
SIPNAB_BLESS_OPENAPI=1 cargo test --features full --test openapi_contract_test
Two things to know before you point a tool at it. The document describes a full build: a route appears only when the feature behind it compiles, so a build without the vCon exporter serves no /v1/dialogs/{call_id}/vcon and generates a document listing none. Read the published file as the ceiling rather than as a promise about the binary you are running.
And info.version is 1 – the version in the /v1 path prefix, which moves when the wire contract does. It is not the sipnab release version, because a patch release that changes no endpoint must not invalidate a contract a client already cached.
Reading the document locally
Any OpenAPI tool takes the file as-is. To render it in a browser, or to check it:
npx --yes @redocly/cli preview-docs website/static/openapi.json
npx --yes @redocly/cli lint website/static/openapi.json
You can also paste it into https://editor.swagger.io/, or feed it to a client generator – which is the point of publishing it.
Endpoint reference
The base URL is whatever you pass to --api (e.g., http://127.0.0.1:8080). Data endpoints use a /v1/ prefix. Utility endpoints (/health, /metrics) have no prefix.
GET /health
Health check endpoint. Returns "ok" with no authentication required.
curl:
curl http://127.0.0.1:8080/health
Python:
import requests
resp = requests.get("http://127.0.0.1:8080/health")
print(resp.text) # "ok"
Go:
resp, _ := http.Get("http://127.0.0.1:8080/health")
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body)) // "ok"
JavaScript (Node.js):
const resp = await fetch("http://127.0.0.1:8080/health");
console.log(await resp.text()); // "ok"
GET /v1/dialogs
List all tracked SIP dialogs with optional filtering and pagination.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
state | string | – | Filter by dialog state (Trying, Ringing, InCall, Completed, Failed, Canceled, Redirected, Registered, Expired, Pending, Active, Terminated, Transferring) |
from | string | – | Filter by From user (regex pattern) |
limit | int | 50 | Maximum results. Ceiling is --api-max-rows (1000 by default), not a fixed limit |
offset | int | 0 | Pagination offset |
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/dialogs?state=Failed&limit=10" | jq .
Python:
import requests
resp = requests.get(
"http://127.0.0.1:8080/v1/dialogs",
headers={"Authorization": "Bearer my-secret-token"},
params={"state": "Failed", "limit": 10},
)
data = resp.json()
for d in data["dialogs"]:
print(f"{d['call_id']}: {d['state']} ({d['msg_count']} msgs)")
Go:
req, _ := http.NewRequest("GET",
"http://127.0.0.1:8080/v1/dialogs?state=Failed&limit=10", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var result struct {
Dialogs []map[string]interface{} `json:"dialogs"`
Total int `json:"total"`
}
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("%d dialogs (%d total)\n", len(result.Dialogs), result.Total)
JavaScript (Node.js):
const resp = await fetch(
"http://127.0.0.1:8080/v1/dialogs?state=Failed&limit=10",
{ headers: { Authorization: "Bearer my-secret-token" } }
);
const { dialogs, total } = await resp.json();
dialogs.forEach(d => console.log(`${d.call_id}: ${d.state}`));
Response:
{
"schema_version": 1,
"total": 47,
"by_method": [
{ "method": "INVITE", "count": 41 },
{ "method": "REGISTER", "count": 6 }
],
"offset": 0,
"limit": 10,
"dialogs": [
{
"call_id": "[email protected]",
"from_user": "alice",
"to_user": "bob",
"state": "Failed",
"method": "INVITE",
"final_status_code": 486,
"duration_sec": 0.0,
"msg_count": 4,
"timing": {
"pdd_ms": 847,
"setup_ms": null,
"retransmits": 2,
"duration_ms": null
},
"created_at": "2026-04-13T10:30:00Z",
"updated_at": "2026-04-13T10:30:03Z",
"frame": "capture.pcap#41@6f3a1c02b8d4e795"
}
]
}
by_method splits total by the method that opened each dialog. It covers
the filtered set rather than the page, and leads with the dominant class.
Whatever the deployment does most dominates a dialog list, and in the field that
is usually the keepalive plane rather than the calls – on one real capture 98 of
110 rows carried OPTIONS. Read it before you read the rows, or the page’s
composition passes for the deployment’s. The MCP list_dialogs and
find_problems tools return the same breakdown beside their own
total_matched, from the same derivation.
frame identifies the frame the dialog opened in, as
<source>#<ordinal>@<digest>: the capture it came from, the frame’s position
within that file, and a digest of the frame’s bytes. The ordinal is per source
file, so a frame keeps the same pointer whether sipnab read it on its own,
from a directory, or as one of a glob — which is what makes it usable for
comparing two runs over the same capture.
Follow one with sipnab --show-frame:
sipnab --show-frame 'capture.pcap#41@6f3a1c02b8d4e795'
The digest is what lets it tell you when the pointer no longer means what it
meant. A capture rotated, truncated or recompressed since the run yields a
mismatch, and --show-frame refuses rather than printing whatever now sits
at that position — nothing goes to stdout, so nobody can mistake a hexdump
for an answer. The short form capture.pcap#41, which is what a human types,
prints the frame and labels it UNVERIFIED, because there is nothing to check
it against.
The key is absent, not null, when the dialog has no frame: live capture has no
file to point back into. Absent means unknown, and a frame that is present is
always a real pointer.
A list row’s timing carries exactly four keys, always all four. pdd_ms,
setup_ms and duration_ms read null where the dialog never reached the
message that sets them, and retransmits is a plain count. That inverts a page
down: the single-dialog document’s timing carries six keys and drops the ones
it has no value for, so the same idea reads as null here and as an absent key
there.
Three list-row keys drop out rather than reading null: frame (above),
final_status_code (absent, never a zero, while the call has no final INVITE
response), and input_origin — wire, hep or uprobe, naming the capture
source that delivered the message that OPENED the dialog. First and never
latest, matching frame: one process can capture from an interface and a HEP
mirror at once, so a field reassigned per message would report whichever spoke
last.
The list rows carry from_user/to_user, not the from/to used by the
single-dialog and report endpoints below. The two shapes come from different
serializers – list rows are the compact DialogSummary projection shared with
MCP and TUI save, the single-dialog document is the full-fidelity one – so a
client that walks the list and then fetches a dialog has to read both spellings.
GET /v1/dialogs/:call_id
Get full details for a single dialog by Call-ID, including associated RTP streams and media diagnosis.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/dialogs/[email protected]" | jq .
Python:
import requests
from urllib.parse import quote
call_id = "[email protected]"
resp = requests.get(
f"http://127.0.0.1:8080/v1/dialogs/{quote(call_id, safe='')}",
headers={"Authorization": "Bearer my-secret-token"},
)
dialog = resp.json()
# REST returns an aggregated dialog — `msg_count`, not the messages themselves.
print(f"State: {dialog['state']}, Messages: {dialog['msg_count']}")
Go:
callID := url.PathEscape("[email protected]")
req, _ := http.NewRequest("GET",
"http://127.0.0.1:8080/v1/dialogs/"+callID, nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var dialog map[string]interface{}
json.NewDecoder(resp.Body).Decode(&dialog)
fmt.Printf("State: %s\n", dialog["state"])
JavaScript (Node.js):
const callId = encodeURIComponent("[email protected]");
const resp = await fetch(
`http://127.0.0.1:8080/v1/dialogs/${callId}`,
{ headers: { Authorization: "Bearer my-secret-token" } }
);
const dialog = await resp.json();
console.log(`State: ${dialog.state}`);
Response:
{
"schema_version": 1,
"call_id": "[email protected]",
"from": "alice",
"to": "bob",
"from_display": "Alice Smith",
"to_display": "Bob Jones",
"state": "Completed",
"final_status_code": 200,
"final_status_reason": "OK",
"method": "INVITE",
"msg_count": 8,
"duration_sec": 45.2,
"timing": {
"pdd_ms": 847,
"setup_ms": 2134,
"ring_ms": 1287,
"trying_delay_ms": 12,
"teardown_ms": 45,
"retransmits": 0
},
"sdp_timeline": [
{
"timestamp": "2026-04-13T10:30:00Z",
"direction": "offer",
"codecs": ["PCMU", "PCMA", "telephone-event"],
"media_addr": "192.0.2.1",
"media_port": 10000,
"mode": "sendrecv"
},
{
"timestamp": "2026-04-13T10:30:02Z",
"direction": "answer",
"codecs": ["PCMU", "telephone-event"],
"media_addr": "192.0.2.2",
"media_port": 20000,
"mode": "sendrecv"
}
],
"diagnosis": {
"one_way_audio": false,
"nat_mismatch": false,
"no_media": false,
"private_media_address": false,
"hints": [
"Asymmetric media may be due to comfort noise (42% CN frames)."
]
},
"streams": [
{
"schema_version": 1,
"ssrc": "0x1a2b3c4d",
"codec": "PCMU",
"payload_type": 0,
"src": "192.0.2.1:10000",
"dst": "192.0.2.2:20000",
"packets": 4820,
"octets": 771200,
"jitter_ms": 2.1,
"loss_pct": 0.0,
"orphaned": false,
"associated_dialog": "[email protected]",
"first_seen": "2026-04-13T10:30:02Z",
"last_seen": "2026-04-13T10:30:47Z",
"round_trip_ms": 96.0,
"round_trip_source": "xr_voip_metrics",
"quality_intervals": []
}
]
}
quality_intervals is empty above because the window has not closed yet. On a
call long enough to fill one, each entry carries its own mos, r_factor and
a three-state verdict — acceptable, degraded, or not_scorable for a
codec with no published impairment value. The stream-level MOS is a mean over
the whole call and hides a burst shorter than the window, which is what the
per-interval figures exist to show. The window is five seconds by default and
--quality-interval narrows it without shortening the hour of call time the
trend covers.
round_trip_ms is the third of the three numbers that decide whether a call was
acceptable, and the only one sipnab cannot measure for itself: a passive tap
sees one point on the path, and a round trip is about two. Every figure here is
an endpoint’s, and round_trip_source says which kind:
round_trip_source | What it is |
|---|---|
xr_voip_metrics | The reporting endpoint’s own round trip between the two RTP interfaces, from an RFC 3611 XR block. This is the quantity ITU-T G.114 sets its guidance against. Accurate, and rare — most stacks never emit an XR |
sender_report_echo | Derived from a receiver report’s LSR/DLSR pair per RFC 3550 section 6.4.1, anchored on when sipnab saw the report. The full round trip when the capture point sits with the sender of the SR, and a lower bound otherwise, because the leg beyond the tap is not in it. Available on almost every call |
Both keys are absent when nobody reported a round trip, and that is not the
same as zero. A stream with clean jitter, no loss and no round_trip_ms is a
stream with one unanswered question, not a healthy one — a call can be unusable
on delay alone.
Fields drop out rather than reading null. The example above is a healthy,
answered call, so it shows the fields such a call has. Anything sipnab did not
find is absent from the object, not present with a null value: tags when
empty, from_display / to_display when the headers carried no display name,
final_status_code / final_status_reason when there was no final INVITE
response, and signaling_diagnosis when the signaling detections found
nothing. Decode into a type with optional fields: a strict decoder that requires every
key above rejects most real dialogs.
A failed call adds the signaling_diagnosis object, which is where the answer
to “why” lives:
{
"call_id": "[email protected]",
"state": "Failed",
"final_status_code": 408,
"final_status_reason": "Request Timeout",
"signaling_diagnosis": {
"final_failure": {
"code": 408,
"reason_phrase": "Request Timeout",
"reason_header": null,
"warning": null,
"evidence": [2]
},
"auth_loop": null,
"retransmissions": { "method": "INVITE", "count": 7, "span_sec": 32.0, "evidence": [0, 1, 3], "icmp_cause": "port unreachable" },
"ack_missing": null,
"abandoned": null,
"post_dial_delay": null,
"registration_failure": null,
"icmp_unreachable": {
"description": "port unreachable",
"icmp_type": 3,
"icmp_code": 3,
"unreachable_endpoint": "192.0.2.10:5060",
"reported_by": "198.51.100.1",
"method": "INVITE",
"errors": 2,
"truncated": true,
"evidence": [0, 1]
},
"hints": [
"Call failed: 408 Request Timeout.",
"No response to INVITE: 7 transmissions over 32.0s with nothing received — and ICMP says why: port unreachable. The count is how hard the sender tried; the ICMP finding is the cause.",
"ICMP port unreachable: the network could not deliver the INVITE sent to 192.0.2.10:5060 (2 times), reported by 198.51.100.1. The host answered, so it is reachable — nothing was listening on that port. Check the service and the address it binds, not the network."
]
}
}
Inside signaling_diagnosis the convention inverts: the seven always-checked
detections are present as null when they found nothing, so null there means
“checked, nothing found”. Two drop out entirely instead, because neither can run
on every capture: icmp_unreachable needs a capture that holds ICMP at all, and
source_disagreement needs a run reading from two capture sources that both
carried the call. Output Formats covers every field
and the detection threshold behind each.
Additional dialog fields:
final_status_code/final_status_reason– read INVITE transactions only. AREGISTER,OPTIONSorSUBSCRIBEdialog omits both however it ended;signaling_diagnosis.final_failure.codecarries the status for any dialog.diagnosis– Four booleans and ahintsarray, all five always present.one_way_audio,nat_mismatchandno_mediaeach name a media fault.private_media_addressis a warning rather than a fault: the SDPc=line offered an RFC 1918 or link-local address to a peer that is not itself private, which stays correct inside one LAN and correct behind an SBC or media proxy that rewrites the SDP downstream. Two further keys drop out rather than reading null –stun_sdp_mismatch, the STUN evidence that settlesprivate_media_address, absent on a capture holding no STUN, andmedia_relay, the TURN relay this call’s media crossed, absent on a capture holding no relay. Output Formats covers both field by field.diagnosis.hints– Free-text diagnostic strings from the media analyzer: one-way audio, NAT mismatch (SDPc=address vs. actual RTP source), comfort-noise asymmetry (shown in the example above), codec / payload-type / ptime / duration asymmetry, and late media. Empty array when the analyzer found nothing.- STIR/SHAKEN – With
--stir-shakenactive (requires thetlsbuild feature), sipnab writes the attestation level, orig/dest TNs, and verification status to the capture log. That status isNotCheckedorExpiredand never anything stronger: sipnab decodes the PASSporT but does not fetch the referenced certificate, so it checks no signature and the attestation remains the originator’s claim rather than a confirmed fact. They are not part of the REST dialog JSON: there is nostir_shakenfield, and the results do not appear indiagnosis.hints. sipnab marks a tokenExpiredper RFC 8224 Section 4.4 when itsiat(issued-at) claim sits more than 60 seconds from the capture timestamp of the packet that carried it – not from the time you run the analysis. A capture you read a year later still reports which tokens were fresh on the wire.
Returns 404 if the Call-ID is not found.
GET /v1/dialogs/:call_id/report
Get a structured call diagnosis report for a dialog in JSON format. Includes transaction timing, media quality, one-way audio detection, NAT mismatch analysis, and SDP timeline.
On a call that said why it ended, the report also carries a termination
object — cause_code, cause_text, protocol, source_header and
frame_ref. It comes from the same assembler as the MCP answer, so this route
and get_dialog_report cannot report different causes for one call. The field
reference is in mcp-tools.md. sipnab omits
the block, and never sends null, when nothing on the wire named a cause.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/dialogs/[email protected]/report" | jq .
Python:
import requests
from urllib.parse import quote
call_id = "[email protected]"
resp = requests.get(
f"http://127.0.0.1:8080/v1/dialogs/{quote(call_id, safe='')}/report",
headers={"Authorization": "Bearer my-secret-token"},
)
report = resp.json()
# `diagnosis` carries four booleans plus `hints` — there is no `summary` field.
hints = report["diagnosis"]["hints"]
print(f"Diagnosis: {'; '.join(hints) if hints else 'no issues detected'}")
Go:
callID := url.PathEscape("[email protected]")
req, _ := http.NewRequest("GET",
"http://127.0.0.1:8080/v1/dialogs/"+callID+"/report", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var report map[string]interface{}
json.NewDecoder(resp.Body).Decode(&report)
JavaScript (Node.js):
const callId = encodeURIComponent("[email protected]");
const resp = await fetch(
`http://127.0.0.1:8080/v1/dialogs/${callId}/report`,
{ headers: { Authorization: "Bearer my-secret-token" } }
);
const report = await resp.json();
console.log(JSON.stringify(report, null, 2));
Response:
In JSON format this endpoint returns exactly the same document shape as
GET /v1/dialogs/{call_id} — both serialize through the same internal
dialog projection (generate_call_report(..., Json) delegates to the one
dialog-to-JSON serializer). The text and Markdown report layouts are only
available via the MCP get_dialog_report tool and the CLI --call-report.
{
"schema_version": 1,
"call_id": "[email protected]",
"from": "alice",
"to": "bob",
"state": "Completed",
"method": "INVITE",
"msg_count": 8,
"duration_sec": 45.2,
"timing": {
"pdd_ms": 847,
"setup_ms": 2134,
"ring_ms": 1287,
"trying_delay_ms": 12,
"teardown_ms": 45,
"retransmits": 0
},
"sdp_timeline": [],
"diagnosis": {
"one_way_audio": false,
"nat_mismatch": false,
"no_media": false,
"private_media_address": false,
"hints": []
},
"streams": []
}
For a call with negotiated media, sdp_timeline[] and streams[] carry the
same objects shown in the GET /v1/dialogs/{call_id} example above. Optional
fields (from, to, from_display, to_display, tags, per-timing values)
drop out entirely — they never appear as null — when absent.
Returns 404 if the Call-ID is not found.
GET /v1/dialogs/:call_id/correlated
The other legs of this call across a B2BUA, SBC or PBX, each with the strategy that matched it — so a program can stitch a carrier call back together.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogs/a1b2c3%40example.com/correlated | jq .
Response:
{
"schema_version": 1,
"source_call_id": "[email protected]",
"legs": [
{
"call_id": "[email protected]",
"score": 100,
"strategy": "session_id",
"identifier_match": true,
"observed_gap_ms": null
}
],
"total_matched": 1,
"heuristic_only": false
}
strategy names how each leg matched, and identifier_match says whether to
trust it. session_id (RFC 7989) and x_call_id compared identifiers built
to cross a B2BUA. timing_heuristic is a guess from matching endpoints and a
close creation time, and observed_gap_ms carries the gap it saw so a reader
can judge it — a 15 ms gap on a quiet box and a 1,900 ms gap on a busy SBC score
the same and mean different things. heuristic_only is true when every leg is
such a guess.
One hop. This route answers the legs one step from the Call-ID asked about.
To walk a whole tree, follow each leg’s call_id back into this route. The MCP
tool find_correlated returns the same shape from the same
CorrelationResult::strategy_and_gap derivation, so the two surfaces cannot
disagree about whether a strategy is an identifier match.
Returns 404 if the Call-ID is not found.
GET /v1/dialogs/:call_id/tree
The whole tree of legs reachable from this call, walked transitively across a
B2BUA, SBC or PBX. Where /correlated answers one hop, this follows every
identifier match to the end.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogs/a1b2c3%40example.com/tree | jq .
Response:
{
"schema_version": 1,
"root_call_id": "[email protected]",
"legs": [
{ "call_id": "[email protected]", "depth": 0, "parent_call_id": null, "score": null, "strategy": null, "identifier_match": null, "followed": true },
{ "call_id": "[email protected]", "depth": 1, "parent_call_id": "[email protected]", "score": 100, "strategy": "session_id", "identifier_match": true, "followed": true }
],
"total_legs": 2,
"max_depth": 1,
"truncated": false,
"heuristic_edges": 0,
"total_messages": 12,
"first_activity": "2026-09-15T12:00:00+00:00",
"last_activity": "2026-09-15T12:00:41+00:00"
}
A timing guess is a leaf. followed is false on a leg reached by
timing_heuristic and the walk does not search its subtree, because a guess is
not firm enough to walk through. heuristic_edges counts the guesses in the
tree, and truncated is true when the row cap stopped the walk early. Follow
each leg’s call_id to /v1/dialogs/{call_id} for its detail.
The walk is symmetric. Naming any leg returns the same tree, rooted at what
you named. The MCP tool get_call_tree renders the same walk (the shared
DialogStore::correlation_tree), embedding each leg’s dialog summary rather than
leaving it to a follow-up fetch.
Returns 404 if the Call-ID is not found.
GET /v1/dialogs/:call_id/lint
The RFC-conformance defects this dialog trips, each with its rule, severity,
basis and the RFC section it reads from — the same checks the CLI --lint runs.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogs/a1b2c3%40example.com/lint | jq .
Response:
{
"schema_version": 1,
"call_id": "[email protected]",
"finding_count": 1,
"findings": [
{
"rule_id": "SIP-3261-8.1.1.6-MAX-FORWARDS-MISSING",
"severity": "warning",
"basis": "should",
"rfc": 3261,
"section": "8.1.1.6",
"message_index": 0,
"observed": "no Max-Forwards header",
"expected": "a Max-Forwards header on the request",
"explanation": "A request with no Max-Forwards can loop indefinitely across proxies."
}
]
}
basis says how firm each finding is. must is an RFC breach, should a
recommendation deviated from, interop a wart deployed equipment mishandles,
observation a promise the wire contradicts. Reporting them under one word
teaches a reader to discount the breach, so the field keeps them apart. The
media-derived rules run too, from the dialog’s RTP streams.
The MCP tool lint_dialog reports the same findings from the same linter, adding
the frame pointers and suppression accounting an agent uses.
Returns 404 if the Call-ID is not found.
GET /v1/dialogs/:call_id/vcon
Export one observed dialog as a vCon container — the IETF interchange format for a conversation record.
This route exists only in a build that carries the vcon feature, which
--features full includes and the default build does not. A build without it
has no route at that path, so the server answers 404 for every Call-ID. Check
sipnab --version, which lists the features compiled in.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/dialogs/test-call-1%40192.0.2.1/vcon" | jq .
Percent-encode the @. A raw @ in the path works in most clients, and the
escaped form works in all of them.
Response (trimmed — the sip-message-trace attachment carries one entry
per SIP message and grows with the call):
{
"vcon": "0.4.0",
"uuid": "018bcfe5-6800-8795-a667-78f1c5213800",
"created_at": "2026-08-24T22:03:38.989752721+00:00",
"extensions": ["sip-signaling", "CC"],
"parties": [
{
"sip": "sip:[email protected]",
"validation": "none",
"sip_contact": "<sip:[email protected]:5060>",
"sip_user_agent": "sipnab-test/1.0"
},
{ "sip": "sip:[email protected]", "validation": "none" },
{
"validation": "none",
"role": "observer",
"sip_user_agent": "sipnab/0.5.124 (observer; node capture-01)"
}
],
"dialog": [{ "sip_call_id": "[email protected]" }],
"attachments": [
{ "purpose": "sip-message-trace", "party": 2, "mediatype": "application/json", "encoding": "json", "body": { "schema_version": 1, "messages": ["..."] } },
{
"purpose": "sipnab-capture-completeness",
"party": 2,
"mediatype": "application/json",
"encoding": "json",
"body": {
"note": "Produced by sipnab 0.5.127 on node capture-01. sipnab OBSERVED this dialog and took no part in it: the parties below are what the From and To headers said, not identities anyone established, and nothing here is signed. This container carries SIGNALING ONLY — no media, and no reference to media held elsewhere. sipnab read 7 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. A capture-level analysis ran and ranked no blind spots.",
"node": "capture-01",
"sipnab_version": "0.5.124",
"frames_read": 7,
"undecodable_frames": 0,
"sip_discarded_by_port_gate": 0,
"sip_discarded_by_websocket_gate": 0,
"messages_evicted": 0,
"dialogs_refused": 0,
"dialogs_rotated": 0,
"blind_spots": []
}
}
],
"analysis": [
{
"type": "report",
"dialog": 0,
"vendor": "sipnab",
"product": "sipnab 0.5.160 (passive observer; not a recording system)",
"schema": "sipnab-dialog-diagnosis/1",
"mediatype": "application/json",
"encoding": "json",
"body": { "schema_version": 1, "sip_call_id": "[email protected]", "final_status_code": 200, "capture_completeness": { "...": "the same object as the attachment above" } }
}
]
}
The container arrives as a JSON object, so jq reads straight into it. No
field holds the whole container as a string for a client to parse a second
time.
What you must not conclude from this container
A sipnab vCon records an observation, never a recording. sipnab watched these packets go past. It did not place the call, record it, or ask anyone for permission to keep the result, and four absences follow from that:
- No media by default — but
--retain-audiochanges that. Without it the container carries signaling only, and an emptystreamslist elsewhere in this API means “no RTP reached this capture” while a vCon says nothing at all about audio. With--retain-audiothe export decodes the dialog’s RTP and emits the WAV inline as base64, up to a 5 MiB budget, refusing with a note above it rather than truncating. That is call CONTENT, not metadata, and the operator opted into it: treat such a container as a recording for every retention and disclosure purpose. vCon documents the same behavior. - No signature. Nothing here carries a JWS. A signature would say sipnab vouches for the contents of the conversation, and sipnab vouches only for what it saw.
- No party name sipnab vouches for. sipnab EMITS a
namewhen the wire carried a display name, besidesip_display_name, and every party carriesvalidation: "none"— a claim by whoever sent the request, not an identity anyone checked. This entry used to read “No partyname, ever”, which was wrong in the direction that matters: a display name is personal data, so a redaction step must covernameas well assip_display_name. - No consent record. Nobody gave sipnab permission for anything, and an empty consent field would read as “none recorded” rather than “never asked”.
Read capture_completeness.note before you treat a container as a record of
the call. vCon has no field for “this container is an incomplete record”, so
sipnab writes the caveat into two places a consumer walks past: the analysis
body and the sipnab-capture-completeness attachment. Both come from one
value, so they cannot disagree. The note names what this run read and what it
dropped — messages idle compaction discarded, SIP a port gate refused, blind
spots the capture analysis ranked — which is the difference between a short
call and a capture that missed most of one.
Two details worth knowing before you build on the output:
dialog[0].typereaches"incomplete"only when sipnab observed a final failure response. sipnab never sets it because the capture missed part of the call, since that would turn a limitation of the tap into an accusation against the traffic.uuidstays the same for one dialog however many times you ask for it, so a consumer can deduplicate on it.created_atrecords when sipnab wrote the container and moves on every request. Two captures of the same Call-ID on one node share auuid, because this door knows the call and not the file it came from.
Returns 404 if the Call-ID is not found, and 404 for every Call-ID in a
build without the vcon feature.
GET /v1/dialogs/:call_id/audio
The call’s decoded RTP audio as a standalone audio/wav file — mono for one
direction, stereo for two. The same bytes the MCP export_audio tool writes and
the vCon exporter inlines, from one decode, so a .wav saved here verifies
against a container’s content_hash. Before this route, audio reached REST only
inline inside a vCon.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogs/[email protected]/audio -o call.wav
Response: the WAV bytes, Content-Type: audio/wav, with a
Content-Disposition naming the download after a sanitized Call-ID. An
X-Sipnab-Audio-Partial header carries one bit — true when the file falls
short of the call — so a program branches on it without parsing the RIFF chunks.
A provenance note travels inside the file, not only in a header. It names the
mechanism (sipnab-capture), the version that wrote it, and — when the file is
partial — how: a wrapped payload ring, a stream sipnab could not decode, a
direction the capture never saw. So a .wav forwarded and played months later
still says what it is and what it leaves out. The audio carries only what the
capture point saw and what retention kept. It is not a recording the endpoints
made.
sipnab must have retained the payload (start the server with --retain-audio)
for there to be anything to decode. A Call-ID no dialog carries is a 404. A
dialog that exists but carries only undecodable codecs, or whose payload this run
did not keep, is a 422 whose body names which — never a silent empty file.
GET /v1/persistence
Report whether this capture is still writing call content to disk.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/persistence | jq .
{
"enabled": true,
"authorized": true
}
Two fields, because “not writing” has two causes an operator needs to tell apart:
authorized— whether the command line asked this run to write content at all. Fixed when sipnab starts, and nothing over the network changes it.enabled— whether content is reaching disk right now.
authorized: false means this run was never going to write anything.
authorized: true, enabled: false means somebody switched it off.
POST /v1/persistence
Stop writing content, or start again up to what the command line allowed.
curl -s -X POST -H "Authorization: Bearer $SIPNAB_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"enabled":false}' \
http://127.0.0.1:8080/v1/persistence | jq .
{
"enabled": false,
"authorized": true
}
The answer carries the same two fields GET does, and it reports the gate
rather than the request. A caller that asked to enable content on a run started
without persistence flags reads back enabled: false, authorized: false, which
says the request landed and changed nothing.
This control only narrows. {"enabled": true} restores writing as far as
the command line allowed and no further. Starting sipnab without a persistence
flag is the way to guarantee a run writes no content, and no API key changes
that. Anyone holding a token can switch recording off. Nobody can switch it on.
Body shape: a JSON object with exactly the key enabled, carrying true
or false. Anything else — a missing key, an extra key, a string, an array —
answers 400 and leaves the gate where it was. A body the server cannot read
never counts as permission to write.
Both routes sit behind the same authentication as every other /v1/ route. A
control that stops call content reaching disk is not public, and neither is the
answer to whether a capture is recording.
The peer this needs. These read TFPS through its
tfps_ctlprogram in JSON mode, the--jsonflag. TFPS gained that mode in sippulse/tfps#6, merged on 2026-09-18, and no tagged release carries it yet: v0.2.1, the newest, rejects--json. Until the next release, build TFPS from itsmasterbranch. To check thetfps_ctlyou have, runtfps_ctl status --json. One line of JSON means it is ready, andunknown option: --jsonmeans it predates the mode. Against an oldertfps_ctlthese answer with that error and name what to install.
GET /v1/tfps/droppedis the exception: it needs adroppedsubcommand that no TFPS build has, released or onmaster, so it answers with TFPS’s ownunknown command: droppedand says so.
GET /v1/tfps/status
Ask whether the toll-fraud prevention system (TFPS) runs on this host, and what it reports about itself.
TFPS is optional peer software: it condemns sources and enforces that decision
in the firewall, and sipnab never bans anything. Point sipnab at it with
--tfps-ctl /path/to/tfps_ctl or [tfps] ctl in the config file, or leave
tfps_ctl on PATH. sipnab looks for it only when one of these routes runs —
it probes nothing at startup, and a machine without TFPS logs nothing.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/tfps/status | jq .
On a machine without TFPS, every /v1/tfps/ route answers 200 with the same
two fields and nothing else — a result, not a failure of this server:
{
"installed": false,
"reason": "tfps_ctl not found on PATH; pass --tfps-ctl or [tfps] ctl"
}
With TFPS installed, the answer names the executable that answered and carries its status:
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"status": {
"enforcement": "active",
"mode": null,
"interface": null,
"map": "own map id 7",
"blocked_now": 2,
"pairs": 4,
"peers": 4,
"last_checkpoint": 1758200500,
"db": "/var/lib/tfps/tfps.db",
"version": "0.2.1"
}
}
Every field is present on every answer and null when TFPS does not know it.
map names the block map TFPS read: own map id N for the one its daemon
loaded, pinned map <path> for a pinned one. It is null, with enforcement
inactive and blocked_now 0, when TFPS could open none. mode and
interface are always null: TFPS’s JSON mode does not fill them yet.
pairs and peers count what TFPS has learned and are null when TFPS
cannot read its database. TFPS currently reports the same number for both.
last_checkpoint is Unix seconds, null before the first. A tfps_ctl that
exits non-zero, hangs past ten seconds, or prints something other than the
agreed JSON answers 502 Bad Gateway as application/problem+json, with its
standard error verbatim in detail. When the cause is a tfps_ctl without
JSON mode, detail also says where --json is.
GET /v1/tfps/banned
List every source TFPS holds condemned right now.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/tfps/banned | jq .
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"rows": [
{
"ip": "198.51.100.10",
"reason": "user-agent",
"detail": "pplsip",
"first_seen": 1758200000,
"expires": 1789748119,
"enforced": true
},
{
"ip": "198.51.100.12",
"reason": null,
"detail": null,
"first_seen": null,
"expires": null,
"enforced": true
}
],
"total": 2,
"returned": 2,
"truncated": false
}
rows holds at most --api-max-rows entries. total is how many TFPS
returned, and truncated says whether the cap withheld any. reason is the
rule that condemned the source and detail is what it saw — for
user-agent, the User-Agent the scanner sent, verbatim. reason, detail
and first_seen are null for a block no audit row explains, such as one an
operator made by hand. first_seen and expires are Unix seconds, and
expires is null for a ban that does not lapse.
GET /v1/tfps/dropped
Read what the TFPS enforcement has dropped, per condemned source.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/tfps/dropped | jq .
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"rows": [
{
"ip": "198.51.100.10",
"dropped": 30,
"events": 4,
"last_seen": "2026-09-03T16:41:00Z",
"rule": "user-agent",
"last_request": "OPTIONS sip:[email protected] SIP/2.0"
}
],
"total": 1,
"returned": 1,
"truncated": false
}
last_request is the last request line the source sent, verbatim. It and
rule are null when TFPS recorded none. Bounded by --api-max-rows like
/v1/tfps/banned.
GET /v1/tfps/labels
Read the TFPS verdict log: one row per decision it reached about a source. This is the export the label corpus harness scores sipnab’s scanner detector against.
| Query parameter | Meaning |
|---|---|
limit | Rows TFPS returns, newest first, passed through as --limit N when a page holds them. 0 or absent is one page of --api-max-rows, which also bounds a larger limit. |
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/tfps/labels?limit=250" | jq .
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"rows": [
{
"ip": "198.51.100.11",
"reason": "scanner",
"detail": "sipvicious",
"first_seen": 1758200100,
"expires": null,
"unbanned_at": null,
"enforced": true,
"disposition": "block"
}
],
"total": 1,
"returned": 1,
"truncated": false
}
sipnab never asks TFPS for more than a page: the page and one row more, which
is how truncated knows TFPS held more. So total here is how many TFPS
returned, at most a page and one row, not the size of the log. For the whole
log, run tfps_ctl log --json --limit N on the TFPS host, or raise
--api-max-rows on purpose.
TFPS’s audit log records only the blocks it enforced, so today every row has
disposition block and enforced true. The log has no columns for a ban’s
end yet, so expires and unbanned_at are always null. first_seen is
Unix seconds.
POST /v1/tfps/ban
Ask TFPS to condemn one source.
An operator action relayed through sipnab, not a decision sipnab makes.
TFPS refuses its host’s own addresses and anything in its ignoreip,
answers with what it did, and sipnab reports that answer as given, refusal
included. The automated path — sipnab’s own findings reaching TFPS as they
happen — is a separate channel, and nothing sipnab detects ever comes through
this route.
curl -s -X POST -H "Authorization: Bearer $SIPNAB_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"ip":"198.51.100.20","ttl_secs":3600}' \
http://127.0.0.1:8080/v1/tfps/ban | jq .
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"action": {
"ip": "198.51.100.20",
"action": "ban",
"applied": true,
"refused": null,
"expires": 1789746010,
"source": "operator"
}
}
Body shape: a JSON object with ip, required and an IPv4 address. The
TFPS block map is IPv4, and tfps_ctl fails outright on an IPv6 address
rather than refusing it, so sipnab answers 400 for one and never asks. ttl_secs is optional: seconds the ban lasts, 0 for forever, and the
TFPS default of an hour when absent. The TFPS ban command records no
free-text reason, so the body carries none. Anything else — a missing or malformed
ip, an unknown key, an array — answers 400 and TFPS is never asked. The
address and the duration reach tfps_ctl as arguments and never through a
shell.
A ban TFPS refuses is 200 with applied: false and refused saying why in
the words TFPS uses, even though tfps_ctl signals the refusal with exit 1:
local for one of the host’s own addresses, declared for one its
ignoreip exempts, and kernel when it could not write the block map. That
is the answer TFPS gave, not an error:
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"action": {
"ip": "127.0.0.1",
"action": "ban",
"applied": false,
"refused": "local",
"expires": null,
"source": "operator"
}
}
POST /v1/tfps/unban
Ask TFPS to release one condemned source. The same operator action in the other direction, reported as given.
curl -s -X POST -H "Authorization: Bearer $SIPNAB_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"ip":"198.51.100.20"}' \
http://127.0.0.1:8080/v1/tfps/unban | jq .
{
"installed": true,
"tfps_ctl": "/usr/local/bin/tfps_ctl",
"action": {
"ip": "198.51.100.20",
"action": "unban",
"applied": true,
"refused": null,
"expires": null,
"source": "operator"
}
}
Body shape: a JSON object with exactly ip, an IPv4 address. Anything
else answers 400. A source that was not blocked comes back 200 with
applied: false and refused: "not-blocked".
All six /v1/tfps/ routes sit behind the same authentication as every other
/v1/ route. What a firewall is dropping is not a public fact, and a route
that can ask for a ban is not one an unauthenticated caller reaches.
GET /v1/streams
List all tracked RTP streams with quality metrics.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
orphaned | bool | – | true keeps only streams no dialog claims, false only those one does. The test is the stream’s dialog association, applied from the stream’s first packet, so orphaned=true catches short unclaimed streams as well as long ones |
mos_below | float | – | Keep only streams whose grounded estimated MOS is strictly below this threshold. Streams whose codec has no impairment value score a placeholder meaning “unknown”, not a low score, and are never selected by this filter – ungrounded_excluded in the response counts what the filter skipped |
limit | int | 50 | Maximum results. Ceiling is --api-max-rows (1000 by default), not a fixed limit |
offset | int | 0 | Pagination offset |
curl — every tracked stream:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/streams | jq .
Or narrow it to the streams that are actually degraded, by asking for those whose estimated MOS is below a threshold:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/streams?mos_below=3.0" | jq .
Python:
import requests
resp = requests.get(
"http://127.0.0.1:8080/v1/streams",
headers={"Authorization": "Bearer my-secret-token"},
params={"mos_below": 3.0},
)
data = resp.json()
for s in data["streams"]:
print(f"SSRC {s['ssrc']}: MOS={s['mos']:.1f}, loss={s['loss_pct']:.1f}%")
Go:
req, _ := http.NewRequest("GET",
"http://127.0.0.1:8080/v1/streams?mos_below=3.0", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var result struct {
Streams []map[string]interface{} `json:"streams"`
Total int `json:"total"`
}
json.NewDecoder(resp.Body).Decode(&result)
for _, s := range result.Streams {
fmt.Printf("SSRC %s: MOS=%.1f\n", s["ssrc"], s["mos"])
}
JavaScript (Node.js):
const resp = await fetch(
"http://127.0.0.1:8080/v1/streams?mos_below=3.0",
{ headers: { Authorization: "Bearer my-secret-token" } }
);
const { streams } = await resp.json();
streams.forEach(s =>
console.log(`SSRC ${s.ssrc}: MOS=${s.mos.toFixed(1)}, loss=${s.loss_pct.toFixed(1)}%`)
);
Response:
{
"schema_version": 2,
"total": 14,
"offset": 0,
"limit": 50,
"ungrounded_excluded": 0,
"streams": [
{
"ssrc": "0x1a2b3c4d",
"codec": "PCMU",
"src": "192.0.2.1:10000",
"dst": "192.0.2.2:20000",
"packets": 4820,
"jitter_ms": 2.1,
"loss_pct": 0.0,
"orphaned": false,
"associated_dialog": "[email protected]",
"mos": 4.2,
"mos_grounded": true,
"mos_grounding": "published"
}
]
}
What the MOS is worth
mos is always a number, on every stream. It is not always a measurement.
sipnab scores MOS with the ITU-T G.107 E-model, which needs an equipment
impairment factor (Ie) for the codec. G.113 publishes one for some codecs and
not for others, and for a codec with none the score falls back to a placeholder
that means “unknown” – not “about 4.2”, even though 4.2 is roughly what it
prints. A placeholder and a genuine G.711 estimate are byte-identical in the
mos field.
Three keys keep them apart:
| Key | Type | Meaning |
|---|---|---|
mos_grounded | bool | true when mos rests on a real impairment value. Always present – every stream has a grounding, and an absent key would read as “unknown”, which is a different claim |
mos_grounding | string | Which one: published (ITU-T G.113), operator_declared (this deployment’s [media.codec_ie]), or unpublished (the placeholder) |
mos_note | string | The caveat, when there is one. Absent for a published score, which has nothing to disclose |
sipnab keeps published and operator_declared apart because the remedies
differ: a published score that looks wrong means suspecting sipnab’s vantage
point, a declared one means suspecting a file on your own disk.
AMR-WB gets a second MOS, on its own scale
mos is the narrowband E-model, and it cannot score a wideband codec. Feeding
it a wideband impairment is not an approximation but a 35.8-point scale error,
because the two models anchor at different points. So an AMR-WB stream whose
mode sipnab could read gets a second figure instead:
| Key | Type | Meaning |
|---|---|---|
mos_wideband | number | MOS_CQEW on the ITU-T G.107.1 wideband scale |
mos_wideband_context | string | monotic (handset or monaural headset) or diotic (stereo headset or speakerphone) |
mos_wideband_unavailable | string | Why there is no wideband score: unpublished_mode or loss_not_computable |
Do not compare mos_wideband with mos. They are different scales.
Averaging them, plotting them on one axis, or applying one threshold to both
produces a number that means nothing.
The listening context travels with the figure because ITU-T G.113 tabulates
the two separately, and at the slowest mode they differ by about 0.59 MOS. A
capture cannot tell which the far end used, so [media] listening_context
declares it and the default is monotic.
Two things stop a score existing, and they are different answers. G.113 publishes no impairment for that mode in that context, which is a gap in the tables: three of the nine modes have no diotic value at all. Or the stream lost packets and G.113 publishes no robustness factor for its mode, which makes this one stream impossible to score rather than the tables silent. AMR-WB under loss on a handset is not computable from published data, and sipnab says so rather than substituting the figure from the other listening context.
A stream that is not AMR-WB carries none of these keys, including no reason. A call nobody tried to score on the wideband scale is not a call that failed to.
AMR and AMR-WB: which mode the sender used
AMR chooses a bitrate per frame and changes it under congestion. The codec name does not say which one, and the nine AMR-WB modes are about a full MOS point apart, so “AMR-WB” on its own leaves the score ambiguous. sipnab reads the mode out of the RTP payload header and reports what it found:
| Key | Type | Meaning |
|---|---|---|
amr_mode_kbps | number | The one mode every readable frame used, in kbit/s. Absent when the sender switched mode during the stream |
amr_modes_observed | number | How many distinct speech modes the payloads carried. Absent, never zero, when sipnab read no mode at all |
Read the two together. Both absent means sipnab could not read the payloads.
An amr_modes_observed above 1 with no amr_mode_kbps means it read them and
the sender moved.
Three things stop sipnab reading a mode, and each is a real answer rather than
a gap. The codec is not AMR or AMR-WB. No SDP for the stream reached this
process, so the packing is unknown – RFC 4867 defines two, and they put the
frame type in different bits, so guessing gives a plausible wrong mode rather
than an error. Or the session negotiated interleaving, which moves every
offset in the payload.
Comfort noise carries no mode and sipnab does not count it. A stream in discontinuous transmission that sends nothing but silence descriptors reports no modes observed, which is the honest answer: the sender described no speech.
Act on mos_grounded before acting on mos. Otherwise a dashboard that sorts
by MOS and shows the worst ten fills with streams nobody ever scored.
schema_version2. Version 1 servedmoswith no grounding beside it, and itsmos_belowfilter selected placeholders. A client that reads only the fields it knows sees no change; a client asserting on the exact key set does.
The MCP rtp_stats tool carries the same three keys, and the TUI’s stream
detail view draws the same distinction: an ungrounded score renders muted and
annotated rather than in a quality band color.
Who named the dialog
dialog_assertion says who asserted the SDP media endpoint that tied this
stream to its Call-ID:
| Value | Who said it | What the address is |
|---|---|---|
signaled | a negotiating party, in its own SDP | that party’s endpoint |
media-relay | an rtpengine relay, about a port it allocated | the leg’s midpoint |
A relay’s answer is authoritative about the port — rtpengine cannot be wrong
about which socket it opened — and at the same time it is not an endpoint. An
operator tracing one-way audio to 192.0.2.40:38664 needs to know whether that
address is the far end or the box in the middle, and the two lead to opposite
next steps.
sipnab emits the key whenever it knows the answer, signaled included. Absent means
nobody recorded who asserted it, never “a party did” — that is a claim, and
keeping the two apart is why the field exists.
It answers a different question from dialog_origin, which names the capture
source that delivered the assertion rather than its author. See
rtpengine relay attribution for how a relay comes to name a
call whose signaling sipnab never saw.
GET /v1/streams/:id
Get a single RTP stream by SSRC hex string (e.g., 0x1a2b3c4d or 1a2b3c4d).
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/streams/0x1a2b3c4d | jq .
Python:
import requests
resp = requests.get(
"http://127.0.0.1:8080/v1/streams/0x1a2b3c4d",
headers={"Authorization": "Bearer my-secret-token"},
)
stream = resp.json()
print(f"Codec: {stream['codec']}, Packets: {stream['packets']}")
Go:
req, _ := http.NewRequest("GET",
"http://127.0.0.1:8080/v1/streams/0x1a2b3c4d", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var stream map[string]interface{}
json.NewDecoder(resp.Body).Decode(&stream)
fmt.Printf("Codec: %s, Packets: %.0f\n", stream["codec"], stream["packets"])
JavaScript (Node.js):
const resp = await fetch("http://127.0.0.1:8080/v1/streams/0x1a2b3c4d", {
headers: { Authorization: "Bearer my-secret-token" },
});
const stream = await resp.json();
console.log(`Codec: ${stream.codec}, Packets: ${stream.packets}`);
Response: full RTP stream JSON including codec, packet counts, jitter, loss, MOS estimate, and associated dialog. Returns 400 for invalid SSRC format, 404 if not found.
GET /v1/report
The whole-capture analysis: findings across every dialog and stream, orphaned media, STUN and ICMP evidence, and what the retention caps shed.
GET /v1/dialogs/{call_id}/report answers for one call. This answers for the
capture, and it is the only REST route that can — orphaned media and shed
retention belong to no single dialog, so every other route is blind to them.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/report | jq .
Read complete before the findings. It is false when the capture lost
packets, hit a retention cap, or held frames no decoder could read — and a findings list built from such a capture is a floor, not a
total. A reader who takes the list at face value concludes the capture is
clean when it is merely partial.
dialogs_examined and streams_examined are the denominators behind the
findings. frames_read comes from the same process-global counter the
Prometheus scrape reports, so every other figure in the run shares that
denominator.
The MCP get_capture_report tool answers the same question. So does
sipnab --report, which predates both servers.
GET /v1/runtime
What sipnab is doing, and what it is costing the host it runs on.
sipnab exports 32 Prometheus metrics, and the listener that serves them is off by default — so on most deployments those numbers exist inside the process and nothing can read them. This endpoint answers the same questions without one, and adds two things that did not exist anywhere: sipnab’s own resource use, and its share of the machine.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/runtime | jq .
Response:
{
"schema_version": 1,
"process": {
"rss_bytes": 34471936,
"virtual_bytes": 1276837888,
"threads": 2,
"open_fds": 12,
"cpu_seconds": 0.01
},
"host": {
"memory_total_bytes": 131881889792,
"memory_available_bytes": 121499242496,
"cpus": 14,
"basis": "host"
},
"impact": {
"memory_pct": 0.026,
"significant": false,
"note": "sipnab holds 0.0% of the host memory total; the threshold for load-bearing is 10.0%"
},
"interfaces": [],
"dialogs": { "used": 2, "capacity": 100000, "pct": 0.002 },
"streams": { "used": 2, "capacity": 10000, "pct": 0.02 },
"capture_packets_total": 852,
"capture_queue_depth_packets": 0,
"capture_backpressure_blocks_total": 0,
"uptime_seconds": 41
}
An absent field means “not readable here”, never zero. Every value under
process and host is optional because the sources are platform-specific. A
field reported as 0 on a platform where it was never read is worse than one
that says it does not know.
host.basis names the denominator. Inside a container the real limits are
the control group’s limits rather than the machine’s, so the basis reads
cgroup and the totals
come from memory.max. A percentage computed against the wrong total is worse
than no percentage, because a reader believes it.
impact.significant is a verdict, not arithmetic. A capture that is itself
the reason a proxy started dropping calls is the worst failure this tool can
have, and it used to be invisible. note carries the threshold, so you can disagree with the setting rather
than with the finding.
interfaces reads the interface, not the capture handle. sipnab’s own
counters — ps_recv, ps_drop, ps_ifdrop — describe what reached sipnab.
These describe what reached the NIC. The pair is what separates the two
remedies. ps_ifdrop climbing alongside rx_missed_errors is hardware that
cannot keep up, and the fix is ring size, coalescing or RSS. ps_drop climbing
alone is sipnab’s read loop falling behind, and the fix is --buffer or a
tighter filter. The handle counter on its own cannot tell you which.
The capture-queue fields are absent without a capture. A run replaying a
file through the API owns no capture meter, so capture_queue_depth_packets
and capture_backpressure_blocks_total do not appear at all, rather than
appearing as 0.
Zero there would read as “the queue is clear”, which is the one thing a
saturated pipeline must never say.
Occupancy, not just counts. dialogs.used alone is a number. Beside
capacity it is a decision. An operator who cannot see occupancy learns about
eviction by noticing that calls have gone missing.
Rates are opt-in, because measuring one costs a wait. Every counter above
is cumulative, and “1,284,301 messages” answers a different question from “312
messages/second, of which 190 are OPTIONS”. Add ?sample_seconds=N to read the
counters twice across an N-second window:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/runtime?sample_seconds=5" | jq .rates
{
"window_seconds": 5,
"packets_per_second": 412.6,
"calls_per_second": 3.4,
"calls_per_second_by_method": [
["OPTIONS", 2.8],
["INVITE", 0.6]
]
}
The breakdown is the point: a message rate that does not separate INVITE from OPTIONS describes whatever the deployment does most, which in the field is the keepalive plane rather than the calls.
window_seconds is the window sipnab applied, not the one you asked for.
The request waits out the window before answering, so sipnab narrows a window
longer than this route can answer inside its request timeout, and
window_seconds tells you that happened. sipnab rejects sample_seconds=0
rather than answering it: an empty window returns zero deltas, and zero deltas
is exactly what a healthy quiet capture looks like.
The MCP tool runtime_stats returns the same envelope from the same
derivation — including the same refusal and the same clamp — so the two
surfaces cannot disagree about one process.
GET /v1/capabilities
What this build can do and what the operator turned on — the machine contract a program reads before it asks, so a refusal it could have predicted does not read as a dead end.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/capabilities | jq .
Response:
{
"schema_version": 1,
"version": "0.5.175",
"features": ["api", "audio", "bpf", "hep", "mcp", "metrics", "native", "tls", "tui", "vcon"],
"can_decrypt": true,
"can_hep": true,
"can_plugins": false,
"runtime": {
"api_allow_relay_query": false
}
}
features is the one canonical list. The same set --version prints and
the MCP server_capabilities tool returns, read from cfg! so it cannot name a
feature the binary lacks. A capability absent here is one this build cannot do,
which is a different fact from one this run did not turn on.
runtime names what the operator turned on. A route that answers
not_configured because --api-allow-relay-query was not passed is not a
missing feature, and a program that blurs the two retries something that can
never work. api_allow_relay_query is off unless this run may transmit a relay
query.
The MCP tool server_capabilities returns the same feature set from the same
compiled_features list, so the two surfaces cannot claim different builds of
one binary.
GET /v1/stats
Aggregate statistics across all dialogs and streams, including PDD percentiles.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/stats | jq .
Python:
import requests
resp = requests.get(
"http://127.0.0.1:8080/v1/stats",
headers={"Authorization": "Bearer my-secret-token"},
)
stats = resp.json()
d = stats["dialogs"]
print(f"Dialogs: {d['total']} total, {d['active']} active, {d['failed']} failed")
t = stats["timing"]
print(f"PDD: p50={t['pdd_p50_ms']}ms, p95={t['pdd_p95_ms']}ms")
Go:
req, _ := http.NewRequest("GET", "http://127.0.0.1:8080/v1/stats", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var stats map[string]interface{}
json.NewDecoder(resp.Body).Decode(&stats)
dialogs := stats["dialogs"].(map[string]interface{})
fmt.Printf("Total: %.0f, Active: %.0f\n", dialogs["total"], dialogs["active"])
JavaScript (Node.js):
const resp = await fetch("http://127.0.0.1:8080/v1/stats", {
headers: { Authorization: "Bearer my-secret-token" },
});
const stats = await resp.json();
const { dialogs, timing } = stats;
console.log(`Dialogs: ${dialogs.total} total, ${dialogs.active} active`);
console.log(`PDD p50: ${timing.pdd_p50_ms}ms, p95: ${timing.pdd_p95_ms}ms`);
Response:
{
"schema_version": 2,
"dialogs": {
"total": 1247,
"active": 23,
"in_call": 9,
"completed": 1180,
"failed": 32,
"canceled": 12
},
"streams": {
"total": 46,
"orphaned": 3
},
"caveats": {
"media_creating_commands": 0
},
"capture_identity": {
"node": "capture01",
"instance": "1f4a17c8e2b91d40-1",
"dialog_generation": 412,
"stream_generation": 96
},
"source": "live",
"capture_name": "eth0",
"uptime_sec": 3600,
"source_exhausted": false,
"writing_to": null,
"unsaved": true,
"unanalysed_sip_messages": 0,
"unanalysed_busiest_ports": [],
"unanalysed_websocket_messages": 0,
"unanalysed_websocket_ports": [],
"timing": {
"pdd_p50_ms": 120,
"pdd_p95_ms": 850,
"pdd_p99_ms": 2100
},
"capture_quality": {
"kernel_dropped_packets": 0,
"interface_dropped_packets": 0,
"invalid_timestamps": 0,
"undecodable_frames": 0,
"snapped_frames": 0,
"unanswered_nat_requests": 0,
"lapsed_turn_allocations": 0,
"lapsed_turn_allocation_streams": 0,
"ice_role_conflicts": 0,
"degraded": false
}
}
caveats is the other half of that question, and it reads the opposite way.
capture_quality counts what the capture lost. caveats counts what
sipnab declined.
| Key | What it counts |
|---|---|
media_creating_commands | rtpengine subscribe, publish and start recording commands seen and deliberately not attributed |
tls | TLS decryption state — absent unless a decryptor ran |
Declining is the right call. Those commands create media belonging to a call without being one of its two legs, and decoding one as an ordinary leg makes a two-party call report three streams — after which the analysis that judges one-way audio and asymmetry answers a question nobody asked.
Which makes the count the disclosure. A run that saw start recording and said
nothing would be a tool that cannot say what it did not attribute. The key is
always present and zero is a real answer — a field that appears only once
something has happened is a field no client learns exists.
caveats.tls — is decryption working?
Absent when nobody supplied keys. That is not a decryption failure, and flattening it into a report of zeroes would make it look like one.
When a decryptor ran:
"tls": {
"keylog_entries": 4,
"sessions_with_keys": 1,
"app_data_records": 900,
"decrypted_records": 895,
"undecrypted_records": 5,
"late_recovered": 0,
"late_evicted": 0,
"read_nothing": false
}
Read read_nothing, not the arithmetic. A TLS handshake carries records
sipnab never loads keys for, so app_data_records > decrypted_records is not by
itself a failure — deriving a verdict from the two counts sends an operator
after keys that already work. read_nothing is true only for the unambiguous
case: application data arrived and none of it opened.
That case is the reason this block exists. A capture holding ciphertext nobody can open produces a dialog listing identical to one from a quiet network, and without this number there is nothing on the page to tell them apart.
late_recovered and late_evicted stay separate because “we never had the
keys” and “we had them and had already discarded the ciphertext” are different
problems. A larger hold fixes the second. Supplying keys fixes the first.
The counts are cumulative for the process, and sipnab publishes them as decryption happens — so a client can read them mid-run rather than waiting for the end-of-run summary the command line prints.
The MCP capture_status tool carries the same block under the same name, so
this endpoint and an agent never disagree about it.
Which capture these counts came from
capture_identity pairs the capture’s instance with both store generations.
Compare it across calls:
- higher generation, same instance — the capture grew
- different instance — something swapped the file, and every count you were holding describes a different capture
It is the same identity the MCP capture_status tool stamps its answers
with, read from the same object. An agent and an HTTP client polling one
process can therefore tell they are describing one capture — and both see a
swap the moment it happens.
sipnab reads the instance and both generations under one set of locks, so the identity names a single moment rather than three.
null when nobody told this server what capture it holds.
| Key | Meaning |
|---|---|
source | live, file, or unknown |
capture_name | interface name when live, file path when replaying |
uptime_sec | seconds since capture began |
source_exhausted | true once sipnab reaches the end of a file source |
writing_to | path sipnab saves packets to, if any |
unsaved | true only for a live capture with no output file — packets held in memory and nowhere else |
unknown is a real answer, not a default. It is what you consult before
deciding whether stopping a capture is destructive, and a wrong "live" would
be worse than an admission of ignorance.
unsaved is the field that matters for that decision. A file replay is already
on disk, so it is never unsaved. A live capture with an output file is safe to
stop.
SIP the port gate never analyzed
unanalysed_sip_messages is the largest loss this project has measured, and it
is the one capture_quality cannot see. No packet went missing and no decoder gave up — sipnab read the
bytes, recognized them as SIP, and set them aside because both ports fell
outside --portrange.
On the corpus that was 2,311 dialogs against 3,712 real: 37.7% gone, because
a third of the SIP never touches 5060/5061. dialogs.total alone reads as “how
much was there”, and a capture missing a third of its calls renders identically
to one that only had two-thirds.
| Key | Meaning |
|---|---|
unanalysed_sip_messages | plain SIP with both ports outside --portrange |
unanalysed_busiest_ports | the top five ports carrying it, busiest first |
unanalysed_websocket_messages | SIP-over-WebSocket (RFC 7118) outside the WebSocket port set |
unanalysed_websocket_ports | the top five ports carrying that |
The ports travel with the counts because the answer has to name its own remedy —
they are what you write into --portrange. A bare number says something is
wrong without saying where to look.
The two counts stay apart because the remedies differ. Widening
--portrange recovers none of the WebSocket half. That needs --ws-portrange.
This is the common case behind a WSS listener on a reverse proxy, and on
Kamailio, OpenSIPS and Janus, which all default outside sipnab’s shipped
80/443/8080/8443.
Both are zero on a live capture, where BPF filtered before the pipeline saw anything and there is nothing to under-report.
MCP capture_status carries all four under the same names.
capture_quality says how much of the wire the rest of the response draws
from. Read it before the counts, not after: with degraded true, every number
above it is a floor rather than a total, and the timing percentiles may rest
on substituted clock readings.
The three counters stay apart because their remedies disagree:
kernel_dropped_packets— the capture ring was full when the packet arrived. Raise-B/--buffer, narrow the BPF filter, or cut--snaplen.interface_dropped_packets— the NIC or its driver discarded the packet before libpcap saw it. Look at the NIC, the driver or the mirror: a bigger buffer cannot recover these.invalid_timestamps— the pcap timestamp was unusable, so the packet carries the wall clock instead. Nothing went missing, but treat post-dial delay, jitter, MOS and duration for this run as unreliable.
Summing them would name one problem where there are three, and “raise the buffer” is the wrong answer to two of them.
undecodable_frames is a fourth channel and the only one that is about sipnab
rather than the host. Nothing dropped it and no byte is missing: the frames
arrived intact and no decoder here could read them, so the analysis saw none of
their contents. It is what separates this capture holds no SIP from sipnab
could not read this capture — both of which otherwise report dialogs.total
as 0. Which link types, EtherTypes and IP protocols this covers appears in
sipnab_capture_undecodable_frames_total{reason} on /metrics, and the
proportion is sipnab_capture_undecoded_fraction.
It is not part of degraded, on purpose: ARP is an undecodable frame by
definition and is present on nearly every Ethernet capture, so a flag that
included it would be true always and useful never.
snapped_frames is a fifth channel, and neither loss nor a decode failure: the
frames arrived, most of them decoded, and what is missing is payload — which is
exactly what a signaling-only capture sets out to discard. It matters because
the --snaplen warnings fire once per run and cannot say how MUCH of a capture
came in truncated. A run that decoded every packet and snapped 94% of them is
not a clean capture, and no other key here says so. Raise --snaplen when you
need RTP payload, audio export, or a faithful -O re-emit.
The last four keys are the only ones in this block about the network rather than about the capture. Those frames arrived perfectly, and what each key describes went wrong on the wire:
| Key | What it counts, and what to do about it |
|---|---|
unanswered_nat_requests | STUN and TURN transactions that went out and never came back — the signal behind a one-way-audio complaint. An endpoint that cannot learn its reflexive address (the public address:port a NAT gives it, which STUN exists to discover) advertises its private address in SDP, and the far end then sends media somewhere the internet cannot route, while the signaling looks healthy. Silence rather than a refusal points at something in the path discarding UDP it does not recognize, most often a firewall, IPS or secure web gateway. A refusal counts as answered: the server was reachable and said no, which is a different fault |
lapsed_turn_allocations | TURN allocations still carrying traffic past the lifetime the server last granted them, with no Refresh seen in between. The one fault here with no other symptom anywhere — the relay tears the allocation down the moment its lifetime lapses, the relayed media stops with it mid-call, and no SIP message says why. A deliberate release (a Refresh with LIFETIME 0) never counts, because the client asked for the teardown |
lapsed_turn_allocation_streams | Media streams crossing an allocation that had already lapsed. The scale beside the key above, and the reason that key is worth paging on: an allocation that lapsed with nothing on it cost nobody a call, and one carrying four streams cut off four conversations mid-sentence |
ice_role_conflicts | Candidate pairs where both ICE agents claimed the same role, or where one answered 487 Role Conflict (RFC 8445 section 7.3.1.1). ICE resolves this itself, so a single conflict is not always fatal — which is why it belongs on a dashboard rather than only in an alert. Where no pair between the two ever won nomination, the conflict is a candidate cause of media that never started |
All four fall as well as rise. A late answer, a Refresh that arrives afterwards or a later nomination each removes one, so read them as current readings rather than as running totals. Prometheus Metrics publishes the same four numbers as gauges for a scrape.
Neither snapped_frames nor any of those four counts toward degraded, which
stays a statement about packets this host lost.
degraded is true when any of the three is non-zero. false means nothing
was observed to go wrong — not that the capture provably saw every packet.
Loss upstream of the capture point (an oversubscribed SPAN port, a tap
mirroring one direction, a filter that excluded the traffic) is invisible to
all three counters.
GET /v1/aggregate
How many dialogs, grouped by one dimension — the “how many, by what” question, answered over the store rather than the model.
Query parameters:
by(required) — the dimension: one ofstate,response_code,method,from.user,to.user,ua,src.ip,dst.ip,rtp.codec. A key outside that set is a400.filter(optional) — a DSL expression narrowing which dialogs the count includes, the same language/v1/dialogs?filter=compiles. A malformed expression is a400.top_n(optional) — keep the largest N buckets; the rest fold intoother_count.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/aggregate?by=response_code" | jq .
Response:
{
"schema_version": 1,
"group_by": "response_code",
"buckets": [
{ "value": "200", "count": 412 },
{ "value": "486", "count": 19 },
{ "value": "(none)", "count": 7 }
],
"other_count": 0,
"distinct_values": 3,
"total_matched": 438
}
One dimension at a time. Narrow with filter rather than asking for a
second. The buckets are largest first, ties broken by value so the same store
always gives the same answer, and other_count carries everything past top_n
so the buckets plus it sum to total_matched. A (none) bucket counts the
dialogs with no value for the dimension — “how many carry no User-Agent” is a
real question, and dropping them would break the sum.
The MCP tool aggregate_dialogs answers from the same GROUPABLE dimensions and
the same bucketing rule (dialog_group_value_raw), fencing the sender-controlled
values for a model where this route returns them raw for a program.
GET /v1/timeline
How many calls opened over time, in fixed-width buckets — the volume series a dashboard polls.
Query parameters:
bucket_seconds(optional) — the interval width, default 60. Zero is a400.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/timeline?bucket_seconds=300" | jq .
Response:
{
"schema_version": 1,
"buckets": [
{ "start": "2026-09-15T12:00:00+00:00", "bucket_seconds": 300, "dialogs": 42 },
{ "start": "2026-09-15T12:05:00+00:00", "bucket_seconds": 300, "dialogs": 0 },
{ "start": "2026-09-15T12:10:00+00:00", "bucket_seconds": 300, "dialogs": 51 }
],
"returned": 3,
"bucket_seconds": 300
}
Buckets align to the epoch, not to the first call, so two captures line up
on the same boundaries. The series keeps every empty interval rather than
discarding it — an empty bucket is exactly what an outage looks like, and
discarding one would hide the gap. The MCP tool timeline buckets the same way.
GET /v1/dialogs/compare
Two calls side by side, with the fields that differ named for you — the same
comparison the MCP compare_dialogs tool answers.
Query parameters:
a(required) — Call-ID of the first call. A Call-ID with no dialog is a404.b(required) — Call-ID of the second call. A Call-ID with no dialog is a404.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/dialogs/[email protected]&[email protected]" | jq .
Response:
{
"schema_version": 1,
"a": {
"call_id": "[email protected]",
"state": "InCall",
"final_status_code": 200,
"msg_count": 7,
"methods": ["ACK", "BYE", "INVITE"],
"hints": []
},
"b": {
"call_id": "[email protected]",
"state": "Failed",
"final_status_code": 486,
"msg_count": 3,
"methods": ["INVITE"],
"hints": ["the callee was busy"]
},
"differences": ["state", "final_status_code", "msg_count", "methods"]
}
differences names the fields that moved — a subset of state,
final_status_code, msg_count and methods, in that order, empty when the
two calls match on all four. A client that diffs the two itself sometimes
reports a difference that is not there, so the route names them. hints rides
along per side but stays out of the diff.
GET /v1/dialogs/tail
The dialogs that changed since your last poll — cursor-based change tracking,
the pattern a monitoring system reaches for, and one that the /v1/dialogs
offset pagination cannot express. The same question the MCP tail_dialogs tool
answers.
Query parameters:
since(optional) — the previous response’snext_cursor, passed back verbatim. The route returns only dialogs updated strictly after it. Omit it on the first poll. A cursor whose timestamp half is not RFC 3339 is a400.limit(optional) — the most rows to return, clamped to the server’s row cap.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/dialogs/tail" | jq .
Response:
{
"schema_version": 1,
"dialogs": [
{ "call_id": "[email protected]", "state": "InCall", "updated_at": "2026-09-15T12:00:03Z" }
],
"returned": 1,
"next_cursor": "2026-09-15T12:00:03Z|[email protected]"
}
The rows are the same summaries /v1/dialogs returns, oldest update first.
Pass next_cursor back as since on the next poll and only later changes come
back. The cursor renders its timestamp as Z rather than +00:00, so it drops
straight into the since query — a raw + there decodes to a space. A tie
group sharing one update instant resumes on the (timestamp, Call-ID) pair, so
a page boundary inside it neither repeats nor skips a row. next_cursor is null
when nothing changed.
GET /v1/dialogs/rates
Carrier metrics per group — ASR, NER, ACD, post-dial-delay percentiles, MOS p10
and a retransmit rate — grouped by one dimension. The scorecard a monitoring
system polls, which /v1/aggregate bare counts cannot express. The same figures
the MCP group_dialogs tool computes.
Query parameters:
by(required) — the ONE dimension to group by: the fields/v1/aggregatecounts by (state,response_code,method,from.user,to.user,ua,src.ip,dst.ip,rtp.codec) plusto_domain,hourandnext_hop. A key outside that set is a400.metrics(optional) — a comma-separated subset ofcount,asr,ner,acd,pdd_p50,pdd_p95,mos_p10,retransmit_rate. Defaults to all. An unknown name is a400.filter(optional) — a DSL expression narrowing which dialogs the route groups, the language/v1/dialogs?filter=compiles. A malformed expression is a400.top_n(optional) — keep the largest N groups by dialog count; the rest fold intoother_count. Clamped to the server’s row cap.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/dialogs/rates?by=next_hop&metrics=asr,ner,acd" | jq .
Response:
{
"schema_version": 1,
"group_by": "next_hop",
"metrics": ["acd", "asr", "ner"],
"units": { "acd": "seconds", "asr": "percent", "ner": "percent" },
"groups": [
{
"value": "203.0.113.9:5060",
"count": 120,
"metrics": { "asr": 71.67, "ner": 95.0, "acd": 182.4 },
"not_grounded": {},
"population": {
"dialogs": 120, "seizures": 120, "answered": 86, "delivered": 114,
"completed_calls": 86, "pdd_measured": 118, "mos_grounded_dialogs": 0,
"retransmits": 4
}
}
],
"other_count": 0,
"distinct_values": 1,
"total_matched": 120
}
Every figure carries the population behind it, and a metric its
population cannot support comes back null with the reason in not_grounded
rather than as a zero — an ASR of zero over a group of registrations is not a
failing trunk. Groups come back largest-first by dialog count, and other_count
carries everything past top_n, so the groups plus it account for
total_matched. NER credits a far-end decline (a busy or an explicit reject)
that ASR does not, per ITU-T E.411.
GET /v1/talkers
The busiest participants, ranked largest first by ip, ua or prefix (the
dialed number’s leading digits) — the volume-and-abuse view a dashboard polls,
which no other route exposes. The same ranking the MCP top_talkers tool
answers.
Query parameters:
by(required) —ip,ua, orprefix. A key outside that set is a400.filter(optional) — a DSL expression narrowing which dialogs count, the language/v1/dialogs?filter=compiles. A malformed expression is a400.limit(optional) — the most rows to return, clamped to the server’s row cap.prefix_digits(optional) — leading digits that make oneprefixbucket (default 4). Zero is a400. Ignored for every otherby.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/talkers?by=ip&limit=10" | jq .
Response:
{
"schema_version": 1,
"by": "ip",
"talkers": [
{ "key": "203.0.113.9", "dialogs": 812, "messages": 4123, "invites": 812, "answered": 640, "failed": 121, "share_pct": 67.66 }
],
"truncated": true,
"distinct_talkers": 47,
"total_matched": 1200
}
A dialog counts for every participant that took part in it, so ip and ua
shares sum above 100% — /v1/aggregate answers the one-bucket-per-dialog
question instead. Rows rank by dialogs, then messages, then the key, so one
store always answers in the same order. distinct_talkers counts every talker,
so a limit-bounded page never reads as the whole ranking. The ip key is the
message SENDER, so a proxy does not top the ranking for calls it only forwarded.
A ua key is a banner a stranger typed and comes back verbatim, so a program
that renders it treats it as untrusted text.
GET /v1/endpoints
Everything one endpoint did, selected by ip or user (exactly one) — the
single-participant profile you reach for when a complaint names one phone or one
extension. Dialog counts by method and state, INVITE outcomes with a failure
rate, REGISTER state, the User-Agent and Server banners it sent, the
signaling-stack fingerprint read off its request syntax, a private-Contact
rewrite check, its RTP streams, and a bounded page of its most recent dialogs.
The same facets the MCP describe_endpoint tool reports.
Query parameters:
ip(one ofip/user) — the endpoint’s IP address. An address that does not parse is a400.user(one ofip/user) — a SIP URI user part, e.g.alice. Matched case-sensitively, per RFC 3261 section 19.1.4.limit(optional) — the most recent-dialog summaries to return, clamped to the server’s row cap. The counts always describe every match, not this page.
Neither selector, or both, is a 400: an endpoint is either an address or a URI
user part, and one never implies the other.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/endpoints?ip=203.0.113.9&limit=10" | jq .
Response:
{
"schema_version": 1,
"endpoint_kind": "ip",
"endpoint": "203.0.113.9",
"dialogs": 42,
"by_method": { "INVITE": 40, "REGISTER": 2 },
"by_state": { "Completed": 38, "Failed": 4 },
"messages_sent": 210,
"messages_received": 198,
"calls": {
"invites": 40,
"with_final_status": 38,
"failed": 4,
"failure_rate_pct": 10.53,
"by_final_status": { "200": 34, "486": 3, "603": 1 }
},
"registration": {
"applicable": true,
"dialogs": 2,
"succeeded": 2,
"failed": 0,
"auth_loops": 0,
"problem_call_ids": []
},
"user_agents": [
{ "header": "User-Agent", "value": "Grandstream GXP2140 1.0.11.3", "count": 208 }
],
"stack": { "vendor": "grandstream", "confidence": "high" },
"contact_rewrite": null,
"streams": {
"count": 38,
"orphaned": 0,
"packets": 152000,
"lost_packets": 12,
"max_jitter_ms": 4.7,
"codecs": ["PCMU", "telephone-event"]
},
"recent_dialogs": [
{ "call_id": "[email protected]", "method": "INVITE", "state": "Completed" }
],
"truncated": true
}
The counts cover every match. Only recent_dialogs is a page. truncated is
true when the endpoint’s dialog total exceeds what that page carries. An ip
selector reads a socket, so messages_sent/messages_received count what the
address sent and received. A user selector names a party with no socket of its
own, so both are 0, and its streams are the ones linked to its dialogs. Banner
values and codec tokens come back raw — the values a program keys on, unlike
the MCP surface which fences them. contact_rewrite is null unless the
endpoint sent a REGISTER. Security findings are not here: the alert engine
files them against a source address in a ring this route does not hold, so they
are the separate security_findings capability.
GET /v1/security/findings
What sipnab’s own armed detectors — scanner, fraud, digest, reg_flood —
recorded, newest first. The poll a SOC dashboard makes: before this route,
sipnab’s detections went only to syslog and stderr, so nothing could query them.
The same ring the MCP security_findings tool reads.
Query parameters:
kinds(optional) — a comma-separated subset ofscanner,fraud,digest,reg_flood. Omitted returns every kind. Any other name is a400naming the four. A URL query cannot repeat a key into a list, so the kinds ride in one comma-separated value (kinds=scanner,fraud).since(optional) — an RFC 3339 timestamp; the route returns only findings recorded strictly after it. A malformed value is a400.limit(optional) — the most findings to return, clamped to the server’s row cap.total_matchedstill counts every match.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/security/findings?kinds=scanner,fraud&limit=100" | jq .
Response:
{
"schema_version": 1,
"findings": [
{ "rule_name": "scanner", "src_ip": "203.0.113.9", "detail": "method=OPTIONS ua=sipvicious detection=scanner", "timestamp": "2026-09-16T12:00:00+00:00" }
],
"returned": 1,
"total_matched": 1,
"truncated": false,
"armed_kinds": ["scanner"],
"detection_armed": true
}
An empty findings list is two different states, and detection_armed tells
them apart. When it is false, this run armed no detector, so nothing could
have fired — the response then carries a note saying exactly that, and a
reader must NOT take an empty list for a clean bill of health. When it is
true, an empty list means the armed detectors saw nothing to report. Arm a
detector with --kill-scanner, --fraud-detect, --digest-leak or
--reg-flood. total_matched counts every finding the filter admits across the
whole retained ring (bounded by --findings-history), so a limit-bounded page
is never mistaken for the whole history. The detail line is a string the
detector built from observed traffic — its ua= half is a banner a stranger
typed — and it comes back raw, the value a program keys on, unlike the MCP
tool which fences it.
GET /v1/captures/compare
Diff two capture files by aggregate: per dimension, how many dialogs fell in
each bucket in each capture and how far that moved, ranked so “today is worse
than yesterday, and here is where” is the first row — the query a monitoring
system polls, and the same diff the MCP compare_captures tool answers. Before
this, no capture-vs-capture view existed on REST.
Query parameters:
a(required) — the baseline capture, a bare filename inside--api-file-root.b(required) — the capture held against it, a bare filename in the same root. A name that resolves to the same file asais a400.dimensions(optional) — a comma-separated subset of the aggregate vocabulary (state,response_code,method,from.user,to.user,ua,src.ip,dst.ip,rtp.codec). Omitted takesstateandresponse_code.top_n(optional) — rows per dimension, clamped to the server’s row cap. Everything past it sums into an(other)bucket.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/captures/compare?a=yesterday.pcap&b=today.pcap&dimensions=response_code" | jq .
Response:
{
"schema_version": 1,
"a": { "filename": "yesterday.pcap", "packets": 41003, "dialogs": 812, "streams": 640, "dialogs_dropped": 0 },
"b": { "filename": "today.pcap", "packets": 52110, "dialogs": 941, "streams": 733, "dialogs_dropped": 0 },
"dimensions": [
{
"dimension": "response_code",
"buckets": [
{ "value": "503", "a": 1, "b": 60, "delta": 59 },
{ "value": "200", "a": 700, "b": 690, "delta": -10 }
],
"other": { "value": "(other)", "a": 111, "b": 191, "delta": 80 },
"distinct_values": 7
}
],
"summary": "'yesterday.pcap' (812 dialogs) is the baseline; 'today.pcap' (941 dialogs) is held against it, so delta is b minus a. Neither is the capture this server holds."
}
Buckets rank by how far they MOVED, not by how big they are — the largest
bucket is usually the one that changed least, so ranking by movement puts the
answer first. A value present in one capture only reads as zero on the other
side, because “this appeared today” is the finding. dialogs_dropped above zero
means that side hit the dialog ceiling and its counts are a floor. Bucket values
come back raw: a ua or from.user bucket is a banner a stranger typed, and
this route hands a program the value it keys on, unlike the MCP tool which fences
those dimensions.
This route reads files off disk, so it is opt-in. Each name is a bare
FILENAME, never a path — the route refuses a separator, a .., or a symlink
that resolves out of the root. A dialog-free file that reported why it read
nothing is a 422, not a diff whose every bucket collapsed to zero. The route answers 503
until an operator starts the server with --api-file-root <DIR>.
POST /v1/vcon/validate
Check a vCon container against sipnab’s vendored schema — the producer-and- conserver boundary, where a store that would refuse a container can warn whoever built it first, before it reaches the store. The one route that validates input a caller holds rather than reading the capture.
curl:
curl -s -X POST -H "Authorization: Bearer $SIPNAB_API_KEY" -H "Content-Type: application/json" \
--data-binary @container.json http://127.0.0.1:8080/v1/vcon/validate | jq .
Response:
{
"schema_version": 1,
"verdict": "invalid",
"schema_id": "https://sipnab.com/schemas/vcon.schema.json",
"schema_path": "schemas/vcon.schema.json",
"errors": [
{ "instance_path": "", "keyword": "required", "detail": "the container is missing the required `vcon` version property" }
],
"deviations": [],
"explanations": []
}
verdict is one of three. valid is a clean pass. invalid carries real
errors. valid-except-documented-deviation names a shape sipnab emits on
purpose that the schema rejects on purpose — those sit in deviations, each with
a paragraph in explanations, kept apart from the errors so a producer does not
treat a deliberate shape as a defect.
The body must be the JSON object itself, not a string holding it. A non-object
is a 400. The MCP tool validate_vcon runs the same vcon_schema::validate.
GET /v1/relay/stats
The relay’s own global counters, tiered relay_reported. Transmits once to the
address --rtpengine-control names, behind --api-allow-relay-query on a live
run. Every response is HTTP 200 with a top-level outcome: ok with the
counters, or one of the five classifications (not_configured,
not_permitted, unreachable, refused, suspect) when no clean answer came
back. A refusal is 200, not a 4xx: the route exists, the relay is what did not
answer.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/relay/stats | jq .
GET /v1/relay/stats/names
Which statistics the relay knows, obtained by asking it rather than from a table
built into sipnab, so a caller learns what to ask for before a request fails on
a name this build lacks. Names only, no values. Same outcome envelope and
same live-source gate as /v1/relay/stats.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/v1/relay/stats/names | jq .
GET /v1/relay/stats/call/:call_id
The relay’s own counters for one call, by Call-ID, tiered relay_reported. A
relay that does not hold the call answers in its own words, reported as
outcome: refused with the relay’s reason rather than rendered as counters.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/relay/stats/call/[email protected]" | jq .
GET /v1/relay/compare/:call_id
The relay’s totals.RTP.packets for one call beside sipnab’s own measured
count, both tiers named, with a word verdict and a note — never summed. A call
this capture measured no RTP for reads outcome: not_configured naming the
capture, and a relay that does not hold the call reads refused. Polling on an
interval is not offered over REST at all — it is a standing instruction to
transmit that belongs to the operator who owns the host, i.e. the CLI.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/relay/compare/[email protected]" | jq .
GET /v1/relay/holdings
Every Call-ID the live relay is holding right now. This TRANSMITS one control
request, so it sits behind --api-allow-relay-query on a live source and reads
not_permitted/not_configured otherwise, exactly as the stats routes do. It
closes the gap a passive decoder cannot: a call already in progress when sipnab
started left no control exchange to read, which is the case during incident
response. The same holdings the MCP query_relay tool lists. ?max_calls=<N>
caps the list, and the relay marks the rest truncated.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/relay/holdings?max_calls=500" | jq .
The body carries outcome: ok with call_ids and truncated, or one of the
five classifications. outcome is the field a client branches on first.
GET /v1/relay/holdings/:call_id
What the relay holds for one call: each tag it bridges, that tag’s negotiated
ports and SSRCs, and the codec. Same gate and 200-classification rule as the
list route, and it TRANSMITS one control request. The same per-call view the
MCP query_relay tool returns.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/relay/holdings/[email protected]" | jq .
The call_ids, tags and ports are the relay’s own words and come back raw, the
values a program keys on.
Status codes
| Code | When |
|---|---|
200 | Success |
400 | Malformed request (e.g. invalid SSRC on /v1/streams/{id}) |
401 | Missing/invalid/expired/revoked bearer token |
404 | Unknown call_id or stream id — and every call_id on /v1/dialogs/{call_id}/vcon in a build without the vcon feature, which registers no route at that path |
408 | The handler ran past the 30-second per-request cap. Every route sits behind it, so no slow client holds a connection slot open |
500 | The answer would not serialize. Documented on /v1/dialogs/{call_id}, its /report and /vcon variants, /v1/streams/{id} and /v1/report |
502 | The toll-fraud prevention peer a /v1/tfps/ route asked exited non-zero, hung, or answered off the contract; detail carries its standard error |
503 | Rejected by the rate limiter or the connection cap (not 429) |
Recipes (curl + jq)
Every recipe here reads $API, and every one but the health check also reads
$H, so set them once first. The two lines are one unit — $H interpolates
$KEY, so running the second on its own builds an Authorization header with
no token in it and every authenticated recipe then returns 401:
# Run all of these, in order.
API="http://127.0.0.1:8080"; KEY="my-secret-token"
H="-H 'Authorization: Bearer $KEY'"
Each recipe below is a complete command in its own right, and they are alternatives rather than a sequence — run the one that answers your question:
curl -fsS $API/health— health check;/healthis the one endpoint that takes no credentialcurl -fsS "$API/v1/dialogs?state=Failed&limit=20" $H | jq— the most recent failed dialogscurl -fsS "$API/v1/dialogs?from=alice&limit=20" $H | jq— dialogs from one user (from=is a regex)curl -fsS "$API/v1/dialogs/abc123@host" $H | jq— one aggregated dialog by Call-IDcurl -fsS "$API/v1/dialogs/abc123@host/report" $H | jq— the same dialog as a JSON call reportcurl -fsS "$API/v1/streams?orphaned=false" $H | jq— only streams already linked to a dialogcurl -fsS "$API/v1/streams?mos_below=3.5" $H | jq— only streams below a MOS thresholdcurl -fsS "$API/v1/stats" $H | jq— the aggregate counters and PDD percentiles
Two recipes are pipelines rather than one-liners. Export every dialog to CSV, for a spreadsheet or a diff against the switch’s own CDR:
curl -fsS "$API/v1/dialogs?limit=1000" $H \
| jq -r '.dialogs[] | [.call_id, .method, .state, .from_user, .to_user, .duration_sec] | @csv'
Or print one line per poor-MOS stream, which is the shape to feed an alerting hook — it stays silent while every stream is healthy:
curl -fsS "$API/v1/streams?mos_below=3.0" $H \
| jq -r '.streams[] | "LOW MOS: SSRC=\(.ssrc) MOS=\(.mos) call=\(.associated_dialog)"'
For per-call response-code histograms (not exposed over REST), use the CLI NDJSON mode — sipnab -N --json emits one record per message. See Output Formats.
Client examples
Full end-to-end clients (bearer auth, pagination, /metrics scraping, error handling) in curl, Python (sync + async), Node/TypeScript, Rust, and Go are on the website’s API Client Examples page: https://sipnab.com/docs/api-clients/.
Security model
- The API thread only reads dialog/stream metadata: no capture fd access, no key material exposure
- All network listeners bind to localhost by default
- Rate limiting on every guarded endpoint (100 RPS per source IP by default). Two exceptions worth knowing:
/healthsits outside the guard entirely — no auth, no rate limit — and--api-rate-limit-per-peer 0turns the cap off altogether - Bearer token authentication required on every REST endpoint except
/health—/metricson the--apiserver sits on the same guarded router and takes the same credential (the standalone--metricsserver is the one that uses HTTP Basic instead) - Constant-time key comparison prevents timing attacks
- TLS not terminated in-process; run behind a reverse proxy (see API TLS)
- Connection limits prevent resource exhaustion
Note: The API runs as a thread in the sipnab process, sharing the in-memory dialog/stream stores read-only. It never touches capture file descriptors or TLS key material, and exposes only dialog/stream metadata — but it is not a separate OS process; treat the API bind address and key accordingly.
GET /metrics
Prometheus-compatible metrics endpoint, documented on its own page: Prometheus Metrics.
It lives apart from the endpoints above because it answers a different
question for a different reader. Everything else here returns what sipnab
SAW — dialogs, streams, messages. /metrics returns numbers ABOUT sipnab,
in a format a scraper polls on a timer, and it authenticates differently from
the rest of the API. Someone wiring a scrape target needs none of the endpoint
schemas above, and this page was 1,195 lines before the split, with the metric
table 86% of the way down it.