Skip to content

REST API

github-actions[bot] edited this page Jul 29, 2026 · 1 revision

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.

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 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 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. The key is sent as a Bearer token in every request. Using an environment variable avoids it appearing in ps output.

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. 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/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; 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. 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.
--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 has to be trusted with all of it.

# scrape-only credential for Prometheus
sipnab --api-signing-key "$KEY" --mint-token --token-scope metrics

Three properties worth knowing:

  • full is the default, and satisfies everything. A full token still reaches /metrics, so adding this claim narrowed no existing deployment.
  • A token minted before the claim existed is full. Absent scope means full — the opposite of aud, which fails closed when missing. Upgrading does not revoke credentials already in the field.
  • The claim is signed. Stripping or editing scope invalidates 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 is refused 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 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.txt

The file is re-read when its mtime changes, so the token stops working within the next request — no restart.

When authentication is required

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.

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

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, 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 /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/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 (SDP c= 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-shaken validation is enabled (requires the tls build 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 no stir_shaken field, and the results do not appear in diagnosis.hints. Tokens whose iat (issued-at) claim is more than 60 seconds from the current time are marked Expired per RFC 8224 Section 4.4.

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.

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.


GET /v1/streams

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 /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/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": 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
  }
}

GET /metrics

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/metrics

Python:

import requests

resp = requests.get(
    "http://127.0.0.1:8080/metrics",
    headers={"Authorization": "Bearer my-secret-token"},
)
print(resp.text)  # Prometheus text format

Go:

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 format

Response (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.62 — 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.

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
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; /health is 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 scrape config

# prometheus.yml
scrape_configs:
  - job_name: sipnab
    bearer_token: your-api-key
    static_configs:
      - targets: ['127.0.0.1:8080']
    scrape_interval: 15s

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

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://www.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 all listener endpoints (100 RPS per source IP)
  • Bearer token authentication required on every REST endpoint except /health/metrics on the --api server sits on the same guarded router and takes the same credential (the standalone --metrics server 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.

Clone this wiki locally