-
Notifications
You must be signed in to change notification settings - Fork 0
REST API
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 — read-only; it never mutates capture state.
The same dialog / RTP / diagnostic data is also exposed to AI agents over the Model Context Protocol; see MCP Server. The MCP path uses the same in-memory stores as this REST API, so one running sipnab instance can serve both surfaces simultaneously.
All API flags are catalogued in CLI Reference.
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 (read-only — it never mutates capture state).
Looking for AI-agent access? sipnab also exposes the same dialog / RTP / diagnostic data as a Model Context Protocol server. See MCP Server -- the MCP path uses the same in-memory stores as this REST API, so a running sipnab instance can serve both surfaces simultaneously.
sipnab's REST API requires the api feature flag:
cargo build --release --features apiThat 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 validation, 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 fullYou 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. The key is sent as a Bearer token in every request. Using an environment variable avoids it appearing in
psoutput.
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.
curl -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogsMore 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.
Credentials are always presented the same way — an Authorization: Bearer
header. Nothing else is accepted: 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/dialogsThere 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.
A shared secret with no expiry. Simplest to set up; 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. |
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. Any malformed token is rejected (fail-closed).
Audience binding. aud names the surface the token was minted for, so a
token minted from --api-signing-key is rejected by the HTTP MCP endpoint and
vice versa — even when both are configured with 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. The pre-aud s1 format is no longer
accepted — it carried no audience, so honoring it would have left this
binding best-effort. An s1 token now 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. |
--api-revoked-file <FILE> |
Denylist of revoked token ids, one per line (blanks and # comments ignored). |
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-1Serve with that key, and honor a denylist:
sipnab --api 127.0.0.1:8080 --api-signing-key "$KEY" \
--api-revoked-file /etc/sipnab/revoked.txtExpiry needs no server action — a token is rejected 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.txtThe file is re-read when its mtime changes, so the token stops working within the next request — no restart.
If you configure neither an API key nor a signing key, authentication is disabled and every endpoint is served without credentials. That is allowed 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 credentials are configured, 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 rate limiting is checked before authentication, so a client over
its per-IP budget receives 503 Service Unavailable even when its credentials
are invalid.
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.
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 proxyThe 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; utility endpoints (/health, /metrics) have no prefix.
--api-max-conn (default 100) caps concurrent API connections to prevent resource exhaustion. 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).
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.
Health check endpoint. Returns "ok" with no authentication required.
curl:
curl http://127.0.0.1:8080/healthPython:
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"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, Cancelled, Registered, Expired, Pending, Active, Terminated, Transferring) |
from |
string | -- | Filter by From user (regex pattern) |
limit |
int | 50 | Maximum results (capped at 1000) |
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,
"offset": 0,
"limit": 10,
"dialogs": [
{
"call_id": "12013223@203.0.113.195",
"from_user": "alice",
"to_user": "bob",
"state": "Failed",
"method": "INVITE",
"duration_sec": 0.0,
"msg_count": 4,
"timing": {
"pdd_ms": 847,
"setup_ms": null,
"retransmits": 2
},
"created_at": "2026-04-13T10:30:00Z",
"updated_at": "2026-04-13T10:30:03Z"
}
]
}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 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/12013223@203.0.113.195" | jq .Python:
import requests
from urllib.parse import quote
call_id = "12013223@203.0.113.195"
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("12013223@203.0.113.195")
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("12013223@203.0.113.195");
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": "12013223@203.0.113.195",
"from": "alice",
"to": "bob",
"from_display": "Alice Smith",
"to_display": "Bob Jones",
"state": "Completed",
"method": "INVITE",
"msg_count": 8,
"duration_sec": 45.2,
"tags": [],
"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,
"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": "12013223@203.0.113.195",
"first_seen": "2026-04-13T10:30:02Z",
"last_seen": "2026-04-13T10:30:47Z",
"quality_intervals": []
}
]
}Additional dialog fields:
-
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 nothing was detected. -
STIR/SHAKEN -- When
--stir-shakenvalidation is enabled (requires thetlsbuild feature), the attestation level, orig/dest TNs, and verification status are written to the capture log. They are not part of the REST dialog JSON: there is nostir_shakenfield, and the results do not appear indiagnosis.hints. Tokens whoseiat(issued-at) claim is more than 60 seconds from the current time are markedExpiredper RFC 8224 Section 4.4.
Returns 404 if the Call-ID is not found.
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.
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
"http://127.0.0.1:8080/v1/dialogs/12013223@203.0.113.195/report" | jq .Python:
import requests
from urllib.parse import quote
call_id = "12013223@203.0.113.195"
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 three 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("12013223@203.0.113.195")
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("12013223@203.0.113.195");
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": "12013223@203.0.113.195",
"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,
"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)
are omitted — not null — when absent.
Returns 404 if the Call-ID is not found.
List all tracked RTP streams with quality metrics.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
orphaned |
bool | -- | Filter to orphaned streams (no associated dialog) |
mos_below |
float | -- | Filter streams with MOS below this threshold |
limit |
int | 50 | Maximum results (capped at 1000) |
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": 1,
"total": 14,
"offset": 0,
"limit": 50,
"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": "12013223@203.0.113.195",
"mos": 4.2
}
]
}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.
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": 1,
"dialogs": {
"total": 1247,
"active": 23,
"completed": 1180,
"failed": 32,
"cancelled": 12
},
"streams": {
"total": 46,
"orphaned": 3
},
"timing": {
"pdd_p50_ms": 120,
"pdd_p95_ms": 850,
"pdd_p99_ms": 2100
}
}Prometheus-compatible metrics endpoint. Returns metrics in the Prometheus text exposition format (text/plain; version=0.0.4).
curl:
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
http://127.0.0.1:8080/metricsPython:
import requests
resp = requests.get(
"http://127.0.0.1:8080/metrics",
headers={"Authorization": "Bearer my-secret-token"},
)
print(resp.text) # Prometheus text formatGo:
req, _ := http.NewRequest("GET", "http://127.0.0.1:8080/metrics", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))JavaScript (Node.js):
const resp = await fetch("http://127.0.0.1:8080/metrics", {
headers: { Authorization: "Bearer my-secret-token" },
});
console.log(await resp.text()); // Prometheus text formatResponse (text/plain):
# HELP sipnab_dialogs_total Total dialogs by state
# TYPE sipnab_dialogs_total counter
sipnab_dialogs_total{state="completed"} 1180
sipnab_dialogs_total{state="failed"} 32
sipnab_dialogs_total{state="incall"} 23
# HELP sipnab_rtp_streams_total RTP streams by status
# TYPE sipnab_rtp_streams_total counter
sipnab_rtp_streams_total{status="established"} 43
sipnab_rtp_streams_total{status="orphaned"} 3
...
Metric names emitted by src/output/prometheus.rs:
| Metric | Type | Notes |
|---|---|---|
sipnab_dialogs_total{state} |
counter | Tracked dialogs grouped by DialogState (Trying, Ringing, InCall, Completed, Cancelled, Failed, Registered, Expired, Pending, Active, Terminated, Transferring). The --api server emits state values lowercased; the standalone --metrics server emits them as-cased — pick the right form for your queries. |
sipnab_messages_total{method} |
counter | SIP messages by method (INVITE, REGISTER, …). |
sipnab_rtp_streams_active |
gauge | The two servers count different things under this one name. The --api server counts streams not flagged orphaned (linked to a dialog, or not yet old enough for the sweep to flag them — an unlinked stream is only flagged once it is 30 seconds old), however long ago the last packet arrived; the standalone --metrics server counts streams whose last packet arrived within the previous 30 seconds, whatever their dialog association. A call whose media died five minutes ago is still counted by --api and is not counted by --metrics — an alert threshold tuned on one scrape target does not carry over to the other. |
sipnab_rtp_streams_total{status} |
counter | RTP streams by status: orphaned once the sweep has found a stream unlinked to a dialog for 30 seconds, established otherwise. --api only. The standalone --metrics server never populates the map, and an empty family is skipped rather than written as zero, so on --metrics the series does not exist at all — a panel built on it stays permanently blank. |
sipnab_kill_responses_sent_total{mode} |
counter | Scanner-kill responses sent, by source mode: raw (source-spoofed via a raw socket) or ephemeral (sipnab's own port). Alert on unexpected ephemeral to catch a silent spoof fallback. |
sipnab_capture_queue_depth_packets |
gauge | Packets currently queued between the capture reader and the processing thread (standalone --metrics server). |
sipnab_capture_backpressure_blocks_total |
counter | Times the capture reader blocked on a full queue (standalone --metrics server). |
sipnab_pdd_seconds |
histogram | Post-dial delay distribution (buckets at 0.5/1/2/3/5/10s). Emits sipnab_pdd_seconds_bucket{le}, _count, _sum. |
sipnab_mos |
histogram | RTP MOS distribution (buckets at 1/2/2.5/3/3.5/4/4.5). |
sipnab_jitter_ms |
histogram | RTP jitter distribution (buckets at 5/10/20/50/100/200ms). |
sipnab_loss_percent |
histogram | RTP packet-loss distribution (buckets at 0.1/0.5/1/2/5/10%). |
The following metric names are declared in source but are not yet wired to the data plane as of 0.5.57 — nothing in the capture path ever increments them. They do not all fail the same way, which matters when you write the alert. The three labelled families — sipnab_responses_total{code}, sipnab_security_alerts_total{type}, sipnab_diagnosis_total{type} — are map-backed, and an empty family is skipped, so those series are absent from the scrape and a rule over them goes no-data. The two scalars — sipnab_capture_packets_total and sipnab_reassembly_timeouts_total — are written unconditionally and therefore report a hard 0, which is indistinguishable from a capture that has genuinely stopped receiving packets. Don't depend on any of these in alerts yet, and in particular don't read sipnab_capture_packets_total 0 as evidence that capture is alive or dead.
| 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 |
503 |
Rejected by the rate limiter or the connection cap (not 429) |
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 credential -
curl -fsS "$API/v1/dialogs?state=Failed&limit=20" $H | jq— the most recent failed dialogs -
curl -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-ID -
curl -fsS "$API/v1/dialogs/abc123@host/report" $H | jq— the same dialog as a JSON call report -
curl -fsS "$API/v1/streams?orphaned=false" $H | jq— only streams already linked to a dialog -
curl -fsS "$API/v1/streams?mos_below=3.5" $H | jq— only streams below a MOS threshold -
curl -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 is silent when nothing is degraded:
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.
# prometheus.yml
scrape_configs:
- job_name: sipnab
bearer_token: your-api-key
static_configs:
- targets: ['127.0.0.1:8080']
scrape_interval: 15sThe metrics endpoint is lightweight and suitable for 5–15 second scrape intervals. A sample Grafana dashboard JSON ships in the repo at contrib/grafana/sipnab-dashboard.json.
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://www.sipnab.com/docs/api-clients/.
- 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 all listener endpoints (100 RPS per source IP)
- 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.
Website · Repository · Issues · Generated from docs/ — edit there, not here.
Getting started
Using the TUI
CLI & automation
Configuration
Integrations (API & MCP)
Development & internals