Docs Query the REST API

Query the 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. The api feature flag enables them.

  • The API runs as a thread inside the sipnab process.
  • It reads the same in-memory dialog/stream stores as the capture pipeline.

Most routes only read. These change state:

  • POST /v1/persistence sets the persistence gate. So a bearer token can stop and start recording to disk.
  • POST /v1/tfps/ban, POST /v1/tfps/unban and POST /v1/actions/revert ask TFPS to change its block list. They are off unless the server runs with --allow-action tfps:rest.

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 read the same in-memory stores in the same process.
  • So one running instance can serve both at once.

The REST and MCP response shapes are not identical. A metric that one surface reports and the other does not is a defect: report it as an issue.

Getting started

Check your binary has the API

The REST API is the api feature.

  • Every release binary and package includes it.
  • A cargo install or source build without --features api or --features full does not.

The first line of --version lists the features:

sipnab --version

If api is missing from that list, build a binary that has it:

cargo build --release --features full

Choose an API key

You create the API key yourself. There is no registration. Generate a random one and keep it in the environment. sipnab reads it from SIPNAB_API_KEY:

export SIPNAB_API_KEY="$(openssl rand -hex 32)"

Security: every request carries the key as a Bearer token. Keep it in the environment rather than on the command line. That keeps it out of ps output. This is why the commands below never pass --api-key.

Start sipnab with the API

To analyze a pcap file, run it in the same shell. Then it inherits the variable:

sipnab -N -I capture.pcap --api 127.0.0.1:8080

For live capture under sudo, pass the variable through.

  • sudo drops the caller’s environment by default.
  • Without the key, an API on a loopback address accepts every request unauthenticated.
sudo --preserve-env=SIPNAB_API_KEY sipnab -N -d eth0 --api 127.0.0.1:8080

The process stays alive serving the API until you press Ctrl-C.

Query the API

curl -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogs

Check that it answers

/health needs no credential. Any other route needs the key:

# Run all of these, in order.
curl -s http://127.0.0.1:8080/health; echo
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogs | jq -c '{total, first: .dialogs[0].call_id}'

Against tests/pcap-samples/sip-rtp-g711.pcap, the two calls print:

ok
{"total":2,"first":"1-1966@10.0.2.20"}

When it does not answer

What you seeCause and fix
curl exits 7, Failed to connectNothing listens there. sipnab is not running, or it stopped at startup: read its standard error.
{"status":401,"title":"Unauthorized","type":"https://sipnab.com/problems/unauthorized"}The request carries no key, or not the one sipnab holds. Under sudo, pass the variable with --preserve-env=SIPNAB_API_KEY.
403 with Forbidden: Host header is not allowed: <name>. If clients reach this server by that name, start sipnab with --api-allowed-host <name>.The client reaches the server by a name sipnab was not given. Add --api-allowed-host <name> (Which Host names the API answers).
sipnab prints server error: failed to bind API to 127.0.0.1:8080: Address already in use (os error 98) and exits 1Another program holds the port. Choose another port for --api.
sipnab prints server error: 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. and exits 2A routable bind needs a credential. Set SIPNAB_API_KEY, or a signing key (Set up authentication).
503Over the per-address rate or the in-flight cap, whatever the credential (Bind address & connection limits).

More client code and 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. Authenticate API and MCP clients covers the full lifecycle of the signed kind:

  • minting with --mint-token,
  • TTLs,
  • signing-key rotation,
  • revocation denylists.

Static API key

A shared secret with no expiry. It is the simplest to set up. You revoke it by restarting with a different key.

Both lines below are one procedure. The server reads SIPNAB_API_KEY from the environment the first line sets. Run the second on its own and the variable is unset. On this loopback bind, that starts an API that accepts every request unauthenticated:

  • Generate a key and export it:

    export SIPNAB_API_KEY="$(openssl rand -hex 32)"
    
  • Start the API in the same shell:

    sipnab --api 127.0.0.1:8080
    
SettingPurpose
--api-key <KEY> / $SIPNAB_API_KEYThe static secret. Prefer the environment variable — argv is visible in ps.

Signed bearer tokens

Self-describing HMAC tokens that carry their own expiry and id. With them 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)>

Here:

  • payload is compact JSON {"id":"<jti>","exp":<unix_seconds>,"aud":"<api|mcp>"}.
  • 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,
    • 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.
  • This holds 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. 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.

SettingPurpose
--api-signing-key <KEY> / $SIPNAB_API_SIGNING_KEYHMAC 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-tokenSign 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), metrics, actions, admin, audio or inventory for the REST API. See Scope below.
--api-revoked-file <FILE>Denylist of revoked token ids, one per line (blanks and # comments ignored).

Scope. Mint each client a token for the routes it needs:

ScopeReachesMint it for
full (default)Every read route and /metrics. Not the action routes (/v1/tfps/ban, /v1/tfps/unban, /v1/actions/revert) and not the live-audio routes (/v1/calls/live).A dashboard or script that reads calls and streams.
metricsGET /metrics only. Every other route returns 401.A Prometheus scrape job.
actionsEverything full reaches, plus the action routes that ask TFPS to ban or unban a source. The run must also enable them with --allow-action tfps:rest. It lifts only the bans it placed.The system that decides to block a source.
adminEverything actions reaches, and it also lifts a ban another caller placed.An operator who must be able to lift any ban.
audioThe live-audio routes a live_audio vCon rule serves, and nothing else.A speech-to-text consumer.
inventoryGET /v1/inventory only. Every other route returns 401. full reaches the inventory too.A fleet controller running sipnab fleet inventory.

The read scope is for MCP tokens. Minting it with --api-signing-key refuses.

Mint a scrape-only credential for Prometheus, from the signing key in $KEY (see Mint a token below):

TOKEN="$(sipnab --api-signing-key "$KEY" --mint-token --token-scope metrics)"

A metrics token cannot read who called whom, and a full token cannot ban a source or listen to a call.

  • A payload with no scope claim is full. A missing aud, by contrast, fails verification.
  • The signature covers the claim. Stripping or editing scope invalidates the signature. So a holder cannot widen their own token.

To check the scope of a token you minted, decode its payload, the part between the two dots:

python3 - "$TOKEN" <<'PY'
import base64, sys
p = sys.argv[1].split(".")[1]
print(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4)).decode())
PY

A token minted with --token-scope metrics prints:

{"id":"tok-1792151388671087","exp":1792154988,"aud":"api","scope":"metrics"}

Against a server holding the same signing key, that token gets 200 on /metrics and 401 on every /v1/ route:

curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/v1/dialogs
{"status":401,"title":"Unauthorized","type":"https://sipnab.com/problems/unauthorized"}

Static --api-key secrets carry no claims at all. They 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. It does not produce a token that can never authenticate.

Mint a token. Generate the signing key first. Everything below reads it from $KEY, including the server. If you mint against one key and serve with another, 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. 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

sipnab re-reads the file when its mtime changes. So the token stops working within the next request. It needs no restart.

When sipnab requires authentication

If you configure neither an API key nor a signing key, sipnab runs without authentication. It 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. It does not expose 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.

Which Host names the API answers

An API on loopback with no key is safe from other machines. It is not safe from a web page in your own browser.

  • A page at http://evil.example:8080/ can point evil.example at 127.0.0.1 after it loads. This is a trick called DNS rebinding.
  • The browser then treats the loopback API as the page’s own site.
  • So the page could read /v1/dialogs or send POST /v1/persistence.
  • The one thing the page cannot change is the name the browser sends in the Host header.
  • So sipnab checks it before anything else runs, the rate limiter included.

sipnab answers a request only when its Host names one of these, on any port:

  • localhost, 127.0.0.1 or ::1 ([::1] in a Host header);
  • the address --api binds;
  • any IP address, when --api binds every interface (0.0.0.0 or ::). Rebinding needs a DNS name, so an address typed as an address is safe;
  • a name you add with --api-allowed-host or [api] allowed_hosts. Write name:port to accept that port only.

Anything else gets 403 Forbidden. The problem body names the rejected host and the flag that would add it.

  • A request with no Host at all, from an HTTP/1.0 client, gets 400 Bad Request.
  • So does a Host value that does not parse.

The MCP HTTP transport applies the same rule with --mcp-allowed-host.

Behind a reverse proxy, add the public name clients use. Skip this if the proxy rewrites Host to 127.0.0.1:

sipnab -N -d eth0 --api 127.0.0.1:8080 --api-key "secret" --api-allowed-host sipnab.example.com

--api-allowed-host '*' turns the check off. Do that only when something in front of sipnab already checks Host.

Metrics endpoints use two different schemes

The two metrics endpoints take different credentials:

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

Secure every listener makes the certificate, serves all four listeners over TLS, and scrapes the result from Prometheus.

Give sipnab a certificate and its private key. Then the REST API serves HTTPS instead of plain HTTP on the --api port:

sudo sipnab -N -d eth0 --user sipnab --api 0.0.0.0:8443 --api-key "secret" \
  --api-allowed-host capture.example.net \
  --api-tls-cert /etc/sipnab/api.pem --api-tls-key /etc/sipnab/api.key

--api-allowed-host names the host clients use in the URL. A root run reads the certificate and key after it drops to --user, so make them readable by that account (chown root:sipnab, chmod 640). See Make a certificate.

  • On a 0.0.0.0 bind, sipnab answers any IP address but no name it was not given.
  • Without it, sipnab refuses the request below with 403 (Which Host names the API answers).

A client then trusts the CA that issued the certificate. With curl:

curl --cacert /etc/sipnab/ca.pem -H "Authorization: Bearer secret" https://capture.example.net:8443/v1/dialogs
  • --api-tls-cert is a PEM certificate chain, the server’s own certificate first. --api-tls-key is its PEM private key (PRIVATE KEY, RSA PRIVATE KEY or EC PRIVATE KEY).
  • The port speaks HTTPS only. A plain-HTTP request to it fails the TLS handshake and is not served.
  • TLS 1.2 and TLS 1.3 only. The server offers http/1.1 by ALPN (application-layer protocol negotiation) and does not offer HTTP/2.
  • No client certificates. Clients authenticate with the bearer credential, as over plain HTTP.
  • [api] tls_cert and [api] tls_key in sipnab.toml set the same files. Each flag replaces its own key.

sipnab checks both files at startup. It refuses to start, and names the file, when:

  • you pass only one of the two flags. sipnab refuses to serve plain HTTP on a port you meant for HTTPS.
  • sipnab cannot read either file
  • the certificate file holds no certificate, or the key file no private key
  • any other user on the host can read the key file.
    • chmod 600 it.
    • Group-readable stays allowed, for a root:sipnab 0640 service account.
  • the key does not belong to the certificate

A client that fails its handshake, or connects and sends nothing, does not affect anyone else:

  • Each handshake runs on its own.
  • Each gets 10 seconds to finish.
  • At most 256 run at once.
  • sipnab closes a connection that arrives while 256 handshakes are in progress straight away.

--api-max-conn applies over HTTPS as it does over plain HTTP.

A non-loopback --api bind still needs --api-key or --api-signing-key, with or without TLS.

  • Without TLS, sipnab also logs a warning that the bind is non-loopback and plain HTTP.
  • With TLS it does not.

sipnab reads the certificate once, at startup. To pick up a renewed certificate, restart sipnab.

Terminating TLS in a reverse proxy instead

You can put a reverse proxy (nginx, Caddy, HAProxy) in front of a loopback-bound API. This remains a good choice when the proxy already manages certificates for the host, for example with automatic renewal:

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 with a token, and with TLS. Use either:
  • Data endpoints use a /v1/ prefix. Utility endpoints (/health, /metrics) have none.

--api-max-conn (default 100) caps the API requests handled at once. A request past it gets 503. 0 removes the cap.

The API refuses a request body larger than 1 MiB with HTTP 413. This applies on every route that reads one:

  • POST /v1/persistence
  • POST /v1/tfps/ban
  • POST /v1/tfps/unban
  • POST /v1/actions/revert
  • POST /v1/vcon/validate
  • POST /v1/vcon/analyze

A body over the limit, here 1.1 MB sent to POST /v1/persistence, answers:

{"status":413,"title":"Payload Too Large","type":"https://sipnab.com/problems/payload-too-large"}

A body under the limit that does not parse is a 400 instead.

Requests are additionally rate-limited to 100 per second per source IP (--api-rate-limit-per-peer, 0 for no limit). 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:

Before you point a tool at it:

  • The document describes a full build. A route appears only when the feature behind it compiles.
    • A build without the vcon feature serves no /v1/dialogs/{call_id}/vcon.
    • sipnab --version lists the features your binary has.
  • info.version is 1, the version in the /v1 path prefix. It is not the sipnab release version, and a release that changes no endpoint leaves it unchanged.

Contributor walkthroughs describes how to regenerate and check the document.

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

Each Python, Go and JavaScript example below is the core of a complete program in clients/python/, clients/go/ or clients/javascript/.

  • The full programs add the imports and the error reporting.
  • They read the base URL (BASE, baseURL, base) from SIPNAB_URL, default http://127.0.0.1:8080.
  • They read the token (TOKEN, token) from SIPNAB_API_KEY, default my-secret-token.
  • The Call-ID or SSRC (CALL_ID, callID, callId, SSRC, ssrc) is the first argument. It defaults to the value the curl example uses.

GET /health

Health check endpoint. Returns "ok" with no authentication required.

curl:

curl http://127.0.0.1:8080/health

Python:

with urlopen(f"{BASE}/health", timeout=10) as resp:
    print(resp.read().decode())  # "ok"

Go:

resp, err := http.Get(baseURL + "/health")
if err != nil {
	return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("GET /health: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
	return err
}
fmt.Println(string(body)) // "ok"

JavaScript (Node.js):

const resp = await fetch(`${base}/health`);
if (!resp.ok) throw new Error(`GET /health: ${resp.status} ${resp.statusText}`);
console.log(await resp.text()); // "ok"

GET /v1/dialogs

List all tracked SIP dialogs with optional filtering and pagination.

Query parameters:

ParameterTypeDefaultDescription
statestring–Filter by dialog state (Trying, Ringing, InCall, Completed, Failed, Canceled, Redirected, Registered, Expired, Pending, Active, Terminated, Transferring)
fromstring–Filter by From user (regex pattern). sipnab ignores a pattern that is not a valid regular expression: the page is the one you get without from. Check the total against a run without it
filterstring–A filter DSL expression, the language --filter and the TUI filter dialog use, for example method == INVITE or rtp.loss > 2.0, or a named alias such as problems or slow-setup. A value may be bare or quoted, so method == 'INVITE' is the same filter. URL-encode it. A value that is neither is a 400 naming the position, never an unfiltered page. ANDed with state and from
limitint50Maximum results. Ceiling is --api-max-rows (1000 by default), not a fixed limit
offsetint0Pagination offset
afterstring–Only dialogs whose first message is at or after this RFC 3339 instant, for example 2026-09-15T12:00:00Z. An instant that does not parse is a 400
beforestring–Only dialogs whose first message is strictly before this RFC 3339 instant. With after it forms the half-open window [after, before), the window the MCP search_by_time tool uses, so consecutive windows never count a dialog twice. An instant that does not parse is a 400

state, from, filter, after and before combine with AND. total counts the dialogs that pass all of them, before offset and limit page them.

curl:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
  "http://127.0.0.1:8080/v1/dialogs?state=Failed&limit=10" | jq .

Filter aliases: filter accepts the named aliases that --filter and the MCP filter argument accept.

  • It expands each one to the same expression with the same thresholds.
  • Those are the thresholds the server’s flags, such as --pdd-threshold, and its config file set.
  • filter=problems and filter=<the problems expansion> return the same dialogs.
  • The same rule applies to filter on /v1/aggregate, /v1/dialogs/rates and /v1/talkers.

Take tests/pcap-samples/sip-problem-call.pcap, served with sipnab -N -I tests/pcap-samples/sip-problem-call.pcap --api 127.0.0.1:8080. Against it, the alias selects the four failed calls out of five dialogs:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
  "http://127.0.0.1:8080/v1/dialogs?filter=problems" |
  jq '{total, dialogs: [.dialogs[] | {call_id, state, final_status_code}]}'
{
  "total": 4,
  "dialogs": [
    {
      "call_id": "busy-3a2b1c@192.0.2.30",
      "state": "Failed",
      "final_status_code": 486
    },
    {
      "call_id": "decline-7c6d5e@198.51.100.30",
      "state": "Failed",
      "final_status_code": 603
    },
    {
      "call_id": "notfound-1b2c3d@203.0.113.30",
      "state": "Failed",
      "final_status_code": 404
    },
    {
      "call_id": "unavail-4e5f60@192.0.2.50",
      "state": "Failed",
      "final_status_code": 503
    }
  ]
}

A named filter from the run’s [filters] table is filter=@name (%40name when encoded). A name the run does not define is a 400 that lists the names it does.

sipnab parses a name that is not an alias as an expression, and it fails as one. So a misspelled alias is a 400 that names the text. filter=slow_setup (underscore instead of hyphen) answers:

filter: unexpected input at position 0: 'slow_setup'
  slow_setup
  ^
valid operators: ==, !=, <, <=, >, >=, =~ (regex)
see docs/filter-dsl.md for fields, values, and diagnostic aliases

Count the INVITE dialogs. -G with --data-urlencode encodes the expression into the query string:

curl -s -G -H "Authorization: Bearer $SIPNAB_API_KEY" \
  "http://127.0.0.1:8080/v1/dialogs" \
  --data-urlencode "filter=method == 'INVITE'" | jq '.total'

List when each dialog in one ten-second window opened:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
  "http://127.0.0.1:8080/v1/dialogs?after=2016-05-18T18:23:10Z&before=2016-05-18T18:23:20Z" \
  | jq '[.dialogs[].created_at]'

Run them against a server reading tests/pcap-samples/b2bua-asterisk.pcapng.

  • The first prints 2.
  • The second prints the seven instants between 18:23:11 and 18:23:17.

Python:

query = urlencode({"state": "Failed", "limit": 10})
req = Request(
    f"{BASE}/v1/dialogs?{query}",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
with urlopen(req, timeout=10) as resp:
    data = json.load(resp)
for d in data["dialogs"]:
    print(f"{d['call_id']}: {d['state']} ({d['msg_count']} msgs)")

Go:

req, err := http.NewRequest(http.MethodGet,
	baseURL+"/v1/dialogs?state=Failed&limit=10", nil)
if err != nil {
	return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("GET /v1/dialogs: %s", resp.Status)
}

var result struct {
	Dialogs []struct {
		CallID   string `json:"call_id"`
		State    string `json:"state"`
		MsgCount int    `json:"msg_count"`
	} `json:"dialogs"`
	Total int `json:"total"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
	return err
}
for _, d := range result.Dialogs {
	fmt.Printf("%s: %s (%d msgs)\n", d.CallID, d.State, d.MsgCount)
}
fmt.Printf("%d dialogs (%d total)\n", len(result.Dialogs), result.Total)

JavaScript (Node.js):

const resp = await fetch(`${base}/v1/dialogs?state=Failed&limit=10`, {
  headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(`GET /v1/dialogs: ${resp.status} ${resp.statusText}`);
const { dialogs, total } = await resp.json();
dialogs.forEach(d => console.log(`${d.call_id}: ${d.state}`));
console.log(`${dialogs.length} dialogs (${total} total)`);

Response:

{
  "schema_version": 1,
  "total": 47,
  "by_method": [
    { "method": "INVITE", "count": 41 },
    { "method": "REGISTER", "count": 6 }
  ],
  "offset": 0,
  "limit": 10,
  "dialogs": [
    {
      "call_id": "12013223@203.0.113.195",
      "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"
    }
  ],
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

by_method splits total by the method that opened each dialog.

  • It covers the filtered set rather than the page.
  • It leads with the most frequent method.
  • On a capture with OPTIONS keepalives, OPTIONS dialogs can outnumber the calls. Read by_method before the rows, then narrow with filter=method == 'INVITE' if you want the calls only.

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>. It holds:

  • the capture it came from,
  • the frame’s position within that file,
  • 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.
  • That 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 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.
  • Then --show-frame refuses. It does not print 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 is what a human types. It 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. 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.
  • retransmits is a plain count.

That inverts a page down.

  • The single-dialog document’s timing carries six keys. It drops the ones it has no value for.
  • So the same idea reads as null here and as an absent key there.

Four list-row keys drop out rather than reading null:

  • frame (above).
  • final_status_code. It is absent, never a zero, while the dialog’s INVITE, or the request that opened a dialog without one, has no final response.
  • input_origin: wire, hep or uprobe. It names the capture source that delivered the message that OPENED the dialog.
    • It is the source of the first message, as frame is, even when one process captures from an interface and a HEP mirror at once.
  • tags: the labels --tag gave this run’s dialogs, absent when the run set none.

The list rows carry from_user/to_user. The single-dialog and report endpoints below carry from/to instead. A client that walks the list and then fetches a dialog reads 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:

req = Request(
    f"{BASE}/v1/dialogs/{quote(CALL_ID, safe='')}",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
with urlopen(req, timeout=10) as resp:
    dialog = json.load(resp)
# REST returns an aggregated dialog — `msg_count`, not the messages themselves.
print(f"State: {dialog['state']}, Messages: {dialog['msg_count']}")

Go:

req, err := http.NewRequest(http.MethodGet,
	baseURL+"/v1/dialogs/"+url.PathEscape(callID), nil)
if err != nil {
	return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("GET /v1/dialogs/%s: %s", callID, resp.Status)
}

// REST returns an aggregated dialog: `msg_count`, not the messages themselves.
var dialog struct {
	State    string `json:"state"`
	MsgCount int    `json:"msg_count"`
}
if err := json.NewDecoder(resp.Body).Decode(&dialog); err != nil {
	return err
}
fmt.Printf("State: %s, Messages: %d\n", dialog.State, dialog.MsgCount)

JavaScript (Node.js):

const resp = await fetch(`${base}/v1/dialogs/${encodeURIComponent(callId)}`, {
  headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(`GET /v1/dialogs/${callId}: ${resp.status} ${resp.statusText}`);
const dialog = await resp.json();
console.log(`State: ${dialog.state}, Messages: ${dialog.msg_count}`);

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",
  "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": "12013223@203.0.113.195",
      "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": []
    }
  ],
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

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,
  • its own r_factor,
  • 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. It hides a burst shorter than the window. The per-interval figures exist to show that burst.

  • The window is five seconds by default.
  • --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.

  • It is 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. round_trip_source says which kind:
round_trip_sourceWhat it is
xr_voip_metricsThe 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_echoDerived 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. 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. It is 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 the dialog’s INVITE, or the request that opened a dialog without one, had no final response,
  • 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. That is where the answer to “why” lives:

{
  "call_id": "abc123@203.0.113.195",
  "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.
    • 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 – the final response to the dialog’s INVITE.

    • In a dialog without one, it is the final response to the REGISTER, PUBLISH, SUBSCRIBE, MESSAGE or OPTIONS that opened it.
    • Responses to other methods in the dialog (BYE, CANCEL, NOTIFY) do not count.
    • signaling_diagnosis.final_failure.code also carries the status for any dialog.
  • diagnosis – Four booleans and a hints array, all five always present.

    • one_way_audio, nat_mismatch and no_media each name a media fault.
    • private_media_address is a warning rather than a fault.
      • The SDP c= line offered an RFC 1918 or link-local address to a peer that is not itself private.
      • That stays correct inside one LAN.
      • It also stays 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 settles private_media_address. It is absent on a capture holding no STUN.
      • media_relay, the TURN relay this call’s media crossed. It is absent on a capture holding no relay.
    • Output Formats covers both field by field.
  • diagnosis.hints – Free-text diagnostic strings from the media analyzer. Empty array when the analyzer found nothing. They cover:

    • 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,
    • late media.
  • STIR/SHAKEN – With --stir-shaken active (requires the tls build feature), sipnab writes these to the capture log:

    • the attestation level,
    • orig/dest TNs,
    • verification status.

    That status is NotChecked or Expired and never anything stronger.

    • sipnab decodes the PASSporT but does not fetch the referenced certificate.
    • So it checks no signature.
    • The attestation remains the originator’s claim rather than a confirmed fact.

    They are not part of the REST dialog JSON.

    • There is no stir_shaken field.
    • The results do not appear in diagnosis.hints.

    sipnab marks a token Expired per RFC 8224 Section 4.4 in one case. That is when its iat (issued-at) claim sits more than 60 seconds from the capture timestamp of the packet that carried it.

    • It is not measured 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. It includes:

  • transaction timing,
  • media quality,
  • one-way audio detection,
  • NAT mismatch analysis,
  • SDP timeline.

On a call that said why it ended, the report also carries a termination object. It holds 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 Why the call ended.
  • 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/12013223@203.0.113.195/report" | jq .

Python:

req = Request(
    f"{BASE}/v1/dialogs/{quote(CALL_ID, safe='')}/report",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
with urlopen(req, timeout=10) as resp:
    report = json.load(resp)
# `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:

req, err := http.NewRequest(http.MethodGet,
	baseURL+"/v1/dialogs/"+url.PathEscape(callID)+"/report", nil)
if err != nil {
	return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("GET /v1/dialogs/%s/report: %s", callID, resp.Status)
}

// `diagnosis` carries four booleans plus `hints`; there is no `summary` field.
var report struct {
	Diagnosis struct {
		Hints []string `json:"hints"`
	} `json:"diagnosis"`
}
if err := json.NewDecoder(resp.Body).Decode(&report); err != nil {
	return err
}
if hints := report.Diagnosis.Hints; len(hints) > 0 {
	fmt.Println("Diagnosis: " + strings.Join(hints, "; "))
} else {
	fmt.Println("Diagnosis: no issues detected")
}

JavaScript (Node.js):

const resp = await fetch(`${base}/v1/dialogs/${encodeURIComponent(callId)}/report`, {
  headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(`GET /v1/dialogs/${callId}/report: ${resp.status} ${resp.statusText}`);
const report = await resp.json();
console.log(JSON.stringify(report, null, 2));

Response:

This endpoint returns exactly the same document shape as GET /v1/dialogs/{call_id}. For the text and Markdown report layouts, use the MCP get_dialog_report tool or 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,
    "private_media_address": false,
    "hints": []
  },
  "streams": [],
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

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 drop out entirely when absent. They never appear as null. These are from, to, from_display, to_display, tags, and per-timing values.

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. With this, 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": "a1b2c3@example.com",
  "legs": [
    {
      "call_id": "d4e5f6@sbc.example.com",
      "score": 100,
      "strategy": "session_id",
      "method": "header",
      "identifier_match": true,
      "observed_gap_ms": null,
      "th_callid": null
    }
  ],
  "total_matched": 1,
  "heuristic_only": false,
  "th_callid": null,
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

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.
    • 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. They mean different things.
  • heuristic_only is true when every leg is such a guess.
  • th_callid_decoded joined an OpenSIPS topology_hiding("C") leg by decoding its Call-ID with the configured secret, and th_callid_mapping joined one because an OpenSIPS E_SIPNAB_TH_MAP event named both legs. See Calls through topology hiding.

method says what sipnab compared the legs on: decoded, mapping (an E_SIPNAB_TH_MAP event), header (a SIP header both carry), sdp (the SDP origin) or timing (the guess).

th_callid has a value only for the callee-side leg of an OpenSIPS topology-hiding call: decoded, no_secret, undecodable, unmatched, mapped or conflict. The top-level th_callid describes the source, and each leg’s th_callid describes that leg, so an answer asked from the caller side still says which leg OpenSIPS hid and whether it joined.

One hop. This route answers the legs one step from the Call-ID asked about.

  • To walk a whole tree, use GET /v1/dialogs/:call_id/tree (below), or follow each leg’s call_id back into this route.
  • The MCP tool find_correlated returns the same shape.

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. /correlated answers one hop. This route 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": "a1b2c3@example.com",
  "legs": [
    { "call_id": "a1b2c3@example.com", "depth": 0, "parent_call_id": null, "score": null, "strategy": null, "method": null, "identifier_match": null, "followed": true, "th_callid": null },
    { "call_id": "d4e5f6@sbc.example.com", "depth": 1, "parent_call_id": "a1b2c3@example.com", "score": 100, "strategy": "session_id", "method": "header", "identifier_match": true, "followed": true, "th_callid": null }
  ],
  "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",
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

A timing guess is a leaf.

  • followed is false on a leg reached by timing_heuristic.
  • 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.
  • truncated is true when the row cap stopped the walk early.
  • th_callid is each leg’s own topology-hiding status, as on /correlated.
  • 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 returns the same walk, with each leg’s dialog summary embedded.

Returns 404 if the Call-ID is not found.


GET /v1/dialogs/:call_id/lint

The RFC-conformance defects this dialog trips. Each comes with its rule, severity, basis and the RFC section it reads from. These are 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": "a1b2c3@example.com",
  "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."
    }
  ],
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

basis says how firm each finding is.

  • must is an RFC breach.
  • should is a recommendation deviated from.
  • interop is a wart deployed equipment mishandles.
  • observation is a promise the wire contradicts.

Act on must findings first. 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. It adds 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. vCon is the IETF interchange format for a conversation record.

This route exists only in a build that carries the vcon feature.

  • --features full includes it. 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.

A run started with --content-deny-header NAME answers 403 for a dialog whose messages carry that header. It names the header in the problem detail. The batch export does not write that dialog either. See When a container is missing on purpose.

Response: the container. Started with --node-name capture-01 on tests/fixtures/sip_call.pcap, the server answers this request with the same members and values as the container that Read what came out shows in full for the same Call-ID, except uuid and created_at, which differ on every export. The server writes the members of each object in alphabetical order.

The sip-message-trace attachment carries one entry per SIP message and grows with the call.

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,
  • ask anyone for permission to keep the result.

Four absences follow from that:

  • No media by default — but --retain-audio changes that.
    • Without it, the container carries signaling only.
    • An empty streams list elsewhere in this API means “no RTP reached this capture”. A vCon says nothing at all about audio.
    • With --retain-audio, the export decodes the dialog’s RTP. It emits the WAV inline as base64, up to a 5 MiB budget.
    • Above that budget it refuses with a note. It does not truncate.
    • 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.
    • sipnab vouches only for what it saw.
  • No party name sipnab vouches for.
    • sipnab EMITS a name when the wire carried a display name, beside sip_display_name.
    • Every party carries validation: "none". That is a claim by whoever sent the request, not an identity anyone checked.
    • A display name is personal data. A redaction step must cover name as well as sip_display_name.
  • No consent record. Nobody gave sipnab permission for anything. 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,
    • 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.

That 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].type reaches "incomplete" only when sipnab observed a final failure response.
    • sipnab never sets it because the capture missed part of the call.
    • That would turn a limitation of the tap into an accusation against the traffic.
  • uuid stays the same for one dialog however many times you ask for it. So a consumer can deduplicate on it.
    • created_at records when sipnab wrote the container. It moves on every request.
    • Two captures of the same Call-ID on one node share a uuid. This door knows the call and not the file it came from.

Returns 404 if the Call-ID is not found. Returns 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. It is mono for one direction, stereo for two. In stereo, each channel starts at the capture time of its party’s first retained packet, with silence before it, so the two parties line up as they spoke.

  • These are the same bytes the MCP export_audio tool writes and the vCon exporter inlines.
  • So a .wav saved here verifies against a container’s content_hash.

curl:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/dialogs/abc123@203.0.113.9/audio -o call.wav

Response: the WAV bytes, Content-Type: audio/wav.

  • A Content-Disposition names the download after a sanitized Call-ID.
  • An Sipnab-Audio-Partial header carries one bit: true when the file falls short of the call. A program branches on it without parsing the RIFF chunks.
  • A Sipnab-Dialog-Seen header says whether this capture holds the call’s SIP dialog.

On a media-relay host the dialog is not required.

  • A relay sees the RTP and its own control traffic, never the SIP.
  • So its streams carry the Call-ID the relay named while the capture holds no dialog.
  • The route exports those streams’ audio and answers Sipnab-Dialog-Seen: false.

A provenance note travels inside the file, not only in a header. It names:

  • the mechanism (sipnab-capture),
  • the version that wrote it,
  • when the file is partial, how:
    • a wrapped payload ring,
    • a stream sipnab could not decode,
    • a direction the capture never saw,
    • silence where the RTP timestamps show no audio arrived (lost packets, or the sender pausing), with its length in milliseconds,
    • repeated packets sipnab dropped, and timestamp jumps longer than the stream that sipnab did not fill.

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). Otherwise there is nothing to decode.

  • A Call-ID that neither a dialog nor any RTP stream carries is a 404. Its body names both searches. For the Call-ID does-not-exist@nowhere it reads no SIP dialog and no RTP stream associated with call_id 'does-not-exist@nowhere' in this capture.

  • A call this capture holds is a 422 when:

    • it carries only codecs sipnab does not decode, or G.729 without its tables (sipnab decodes PCMU, PCMA, G.722, Opus, and G.729 once sipnab g729 install has run), or
    • this run did not keep its payload.

    The body names which. It is never a silent empty file.


GET /v1/calls/live

The calls in progress whose audio this server serves. Each such call:

  • matches a [[vcon.rule]] with live_audio = true,
  • has not ended,
  • is not withheld by --content-deny-header.

See Hear a call while it is in progress.

curl -s http://127.0.0.1:8080/v1/calls/live

Response:

{"calls": [{"audio": "/v1/calls/1-4412@192.0.2.10/audio",
            "call_id": "1-4412@192.0.2.10",
            "started": "2026-10-13T09:14:02.120194+00:00",
            "state": "InCall", "streams": 2}]}

A server with no live_audio rule answers 404.

When the server authenticates, these routes need a token minted with --token-scope audio. No other scope reaches them, full and the static --api-key included.


GET /v1/calls/live/events

The same calls as server-sent events:

  • on connecting, one call event for each call GET /v1/calls/live lists,
  • then one for each call that appears.

Each event’s data is the call as GET /v1/calls/live gives it. A transcriber watches this and opens each call’s audio socket, without polling.

curl -s -N http://127.0.0.1:8080/v1/calls/live/events
event: call
data: {"audio":"/v1/calls/1-4412@192.0.2.10/audio","call_id":"1-4412@192.0.2.10","started":"2026-10-13T09:14:02.120194+00:00","state":"InCall","streams":2}

GET /v1/calls/:call_id/audio

The call’s audio as sipnab captures it, over a WebSocket (RFC 6455). call_id is the call’s Call-ID, as GET /v1/calls/live lists it. The request must be a WebSocket upgrade. Any other request is a 400.

The server sends:

MessageContent
Text, "type": "stream"A stream, before its first packet: stream (its index), ssrc, src, dst, codec, rate (samples per second) and format (s16le); for a G.729.1 stream also decoded_as, saying the audio is the G.729.1 core layer decoded as G.729 at 8 kHz and naming the bit rates of the frames decoded before the announcement (see The core layer of G.729.1)
BinaryOne RTP packet’s audio, or silence of the length of audio that did not arrive: where a packet’s RTP timestamp is ahead of where the previous packet’s audio ended, a message of zero samples covering the difference comes first. The first byte is the stream’s index; the rest is 16-bit little-endian PCM at the stream’s rate. The server sends a packet that repeats a timestamp once
Text, "type": "skipped"A stream whose codec sipnab does not decode: ssrc, codec, reason. The server sends none of its packets
Text, "type": "gap"packets of stream stream left retention before the server sent them, so the audio has a gap there
Text, "type": "end"The call ended (reason); a close with code 1000 follows

For a call that has already ended, the server sends it from its first retained packet. It then ends and closes the socket.

  • sipnab decodes the audio from what it retained. So sipnab must retain it. A live_audio rule turns retention on.
  • Audio a key could not decrypt is never sent.

The server answers:

  • a ping with a pong,
  • a close with a close,
  • a frame that breaks RFC 6455 with a close carrying code 1002.
    • It does the same for a client frame longer than 4096 bytes.
    • A client sends only control frames, which RFC 6455 caps at 125 bytes.

A Call-ID no live_audio rule matches, or that --content-deny-header withholds, is a 404.

The handshake, with curl:

curl -s -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  "http://127.0.0.1:8080/v1/calls/1-4412@192.0.2.10/audio" --output - | head -4
HTTP/1.1 101 Switching Protocols
upgrade: websocket
connection: Upgrade
sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

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
}

There are two fields. “Not writing” has two causes an operator needs to tell apart:

  • authorized — whether the operator started this run to write call content to disk at all: one of the writers below, on the command line or as a [[vcon.rule]] in the config file. 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.

What the gate covers. Every writer that puts call content on disk while the run captures:

  • -O/--output: sipnab writes no packet it captures while you have the gate off, so the pcap has a gap. At the end of the run sipnab says how many packets it left out.
  • vCon containers: --export-vcon, --export-vcon-when and every [[vcon.rule]].
  • --export-audio: sipnab writes no WAV at the end of the run.
  • The MCP tools that write into --mcp-file-root: export_capture, export_audio, build_evidence_package, generate_repro and shutdown_server’s save_to. sipnab refuses each while the gate is off.

It does not cover:

  • what leaves the host over the network: --hep-send, a rule’s live_audio and live_rtp;
  • the save command of the terminal UI, which the operator at the terminal runs;
  • --evidence-out, which writes findings rather than call content;
  • the audit and journal files.

POST /v1/persistence

Stop writing content, or start again up to what the command line allowed. Stop writing call content partway through a run walks through it on a live run, and says what happens to the calls that end while writing is off.

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. It reports the gate rather than the request.

  • Take a caller that asked to enable content on a run started without persistence flags.
  • It reads back enabled: false, authorized: false.
  • That 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 any of the writers above, and without a [[vcon.rule]], is the way to guarantee a run writes no content. 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 answers 400 and leaves the gate where it was. That includes:
    • a missing key,
    • an extra key,
    • a string,
    • an array.
  • 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.
  • Neither is the answer to whether a capture is recording.

The peer this needs. These routes read TFPS through its tfps_ctl program in JSON mode, the --json flag. TFPS v0.2.1 does not have that mode. Build TFPS from its master branch (sippulse/tfps#6 added it).

Check the tfps_ctl you have:

tfps_ctl status --json
  • One line of JSON means it is ready.
  • unknown option: --json means it has no JSON mode. Against that tfps_ctl, these routes answer with the same error and name what to install.

GET /v1/tfps/dropped needs a dropped subcommand that TFPS does not provide. It answers with TFPS’s own unknown command: dropped and 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.

  • sipnab never decides a ban itself.
  • It asks TFPS to ban only when an operator asks, and only where --allow-action enables it.

Point sipnab at it in one of these ways:

  • --tfps-ctl /path/to/tfps_ctl,
  • [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. 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. That is 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,
  • 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. They 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 can fail in these ways:

  • it exits non-zero,
  • it hangs past ten seconds,
  • it prints something other than the agreed JSON.

Each 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. truncated says whether the cap withheld any.
  • reason is the rule that condemned the source.
  • detail is what it saw. For user-agent, that is the User-Agent the scanner sent, verbatim.
  • reason, detail and first_seen are null for a block no audit row explains. One example is a block an operator made by hand.
  • first_seen and expires are Unix seconds. 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:100@198.51.100.1 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.

Query parameterMeaning
limitRows 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.

  • That is how truncated knows TFPS held more.
  • So total here is how many TFPS returned, at most a page and one row. It is not the size of the log.
  • For the whole log, do one of these:
    • run tfps_ctl log --json --limit N on the TFPS host,
    • raise --api-max-rows on purpose.

TFPS’s audit log records only the blocks it enforced.

  • So every row has disposition block and enforced true.
  • The log has no columns for a ban’s end. So expires and unbanned_at are always null.
  • first_seen is Unix seconds.

POST /v1/tfps/ban

Ask TFPS to condemn one source.

Off by default. Banning changes another system. sipnab changes no system outside itself unless you turn that on. This route acts only when you start the server with --allow-action tfps:rest (or [actions] tfps = ["rest"]) and the caller holds a token minted with --token-scope actions.

A full token or a static --api-key reads every route and acts on none. They get 401 here. Enabled but not for REST, it answers 403 naming the setting.

An operator action relayed through sipnab, not a decision sipnab makes. Nothing sipnab detects ever comes through this route. Before it asks TFPS, sipnab:

  • checks the request against fixed rules and rate limits,
  • writes it to the actions journal.

The rules and limits:

  • sipnab never bans 0.0.0.0, the broadcast address, or a loopback or multicast address (422);
  • every ban expires: ttl_secs, or an hour when absent, and 7 days at most.
    • sipnab refuses 0, TFPS’s “forever”, and anything longer (422). It does not shorten them.
    • [action_limits] changes the default and the maximum;
  • at most 10 actions a minute for the server, 5 for one caller, and one per address a minute (429 with Retry-After).
curl -s -X POST -H "Authorization: Bearer $SIPNAB_ACTIONS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"ip":"198.51.100.20","ttl_secs":3600}' \
  http://127.0.0.1:8080/v1/tfps/ban | jq .
{
  "id": "a-r-68d9c1f2-4412-3",
  "applied": true,
  "refused": null
}

id names the action in the journal.

  • sipnab --journal-show lists it.
  • POST /v1/actions/revert or sipnab --revert-actions takes it.

Body shape: a JSON object with ip, required and an IPv4 address, and optionally ttl_secs.

  • The TFPS block map is IPv4. tfps_ctl fails outright on an IPv6 address rather than refusing it. So sipnab answers 400 for one and never asks.
  • The TFPS ban command records no free-text reason. So the body carries none.
  • Anything else answers 400 and TFPS is never asked. That includes:
    • a missing or malformed ip,
    • an unknown key,
    • an array.
  • The address and the duration reach tfps_ctl as arguments and never through a shell.

A ban TFPS refuses is 200 with applied: false. refused says why, in the words TFPS uses:

  • local for one of the host’s own addresses,
  • declared for one its ignoreip exempts,
  • kernel when it could not write the block map.

That is the answer TFPS gave, not an error:

{
  "id": "a-r-68d9c1f2-4412-4",
  "applied": false,
  "refused": "local"
}

tfps_ctl missing or failing is 502, with its reason in detail.

  • If tfps_ctl could not start, TFPS did nothing, and the journal records the ban as failed.
  • If it ran and sipnab could not read its answer, TFPS may have banned the address. That covers a timeout, a non-zero exit with no result, and an answer that is not the JSON TFPS has given since e7ee57a. In that case:
    • the journal holds the ban in doubt, and detail says so;
    • sipnab --journal-show lists it with its address, its caller and its time;
    • the caller who asked, or a token with scope admin, can lift it with POST /v1/tfps/unban, and TFPS’s answer settles it.

503 means no action may run just now. Either:

  • sipnab cannot use the journal, or
  • actions are in doubt: sipnab stopped before TFPS answered for them, or sipnab asked TFPS and could not read its answer. They wait for TFPS to answer for them, or for an unban of their address.

POST /v1/tfps/unban

Ask TFPS to release a source sipnab banned. Off by default, behind the same two locks, rules and limits as the ban.

sipnab lifts only a ban it placed and still holds, as its journal records, or one it asked for and holds in doubt.

  • A ban TFPS placed itself, or anyone else did, answers 409. TFPS is not asked.
  • So a stolen token cannot use sipnab to lift TFPS’s own bans.
  • A ban belongs to the token that placed it. Another token answers 403, and detail names the owner, unless the token holds scope admin.
  • An address rests for a minute after an action on it. So an unban straight after the ban answers 429 with Retry-After.
curl -s -X POST -H "Authorization: Bearer $SIPNAB_ACTIONS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"ip":"198.51.100.20"}' \
  http://127.0.0.1:8080/v1/tfps/unban | jq .
{
  "id": "a-r-68d9c1f2-4412-5",
  "applied": true,
  "refused": null
}

Body shape: a JSON object with exactly ip, an IPv4 address. Anything else answers 400.

TFPS may no longer hold the ban. It forgets manual bans when it restarts. Then:

  • the answer is 200 with applied: false and refused: "not-blocked",
  • sipnab no longer counts the ban as its own.

POST /v1/actions/revert

Back out what sipnab did. That is either:

  • the ban one action placed, or
  • every ban sipnab placed that is still in force, newest first.

It is off by default, behind the same two locks as the ban. Each unban counts against the same rate limits. So a stolen token cannot use it to act more often.

Without scope admin, a token backs out only the bans it placed: all covers those, and another token’s id answers 403 naming the owner.

curl -s -X POST -H "Authorization: Bearer $SIPNAB_ACTIONS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"all":true}' \
  http://127.0.0.1:8080/v1/actions/revert | jq .
{
  "reverted": ["a-r-68d9c1f2-4412-3"],
  "lapsed": [],
  "skipped_unknown": [],
  "failed": [],
  "left": []
}

Body shape: exactly one of {"id": "<action id>"}, such as {"id": "a-r-68d9c1f2-4412-3"} for the ban above, or {"all": true}. Anything else answers 400. An id sipnab holds no ban for answers 409.

sipnab first asks TFPS what it holds.

  • reverted lists the actions backed out.
  • lapsed lists addresses whose ban TFPS had already dropped. There was nothing to lift.
  • skipped_unknown lists bans TFPS shows that sipnab cannot prove it placed (a crash between asking and hearing back, and TFPS reporting a different expiry). sipnab never lifts these.
  • failed lists reverts TFPS refused or sipnab could not ask it for.
  • left lists the ones a rate limit stopped before sipnab reached them.

On the TFPS host, sipnab --revert-actions all does the same without a server. It works with actions switched off.

All seven action and TFPS routes sit behind the same authentication as every other /v1/ route.

  • What a firewall is dropping is not a public fact.
  • 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:

ParameterTypeDefaultDescription
orphanedbool–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_belowfloat–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
limitint50Maximum results. Ceiling is --api-max-rows (1000 by default), not a fixed limit
offsetint0Pagination 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. Ask 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:

query = urlencode({"mos_below": 3.0})
req = Request(
    f"{BASE}/v1/streams?{query}",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
with urlopen(req, timeout=10) as resp:
    data = json.load(resp)
for s in data["streams"]:
    print(f"SSRC {s['ssrc']}: MOS={s['mos']:.1f}, loss={s['loss_pct']:.1f}%")

Go:

req, err := http.NewRequest(http.MethodGet,
	baseURL+"/v1/streams?mos_below=3.0", nil)
if err != nil {
	return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("GET /v1/streams: %s", resp.Status)
}

var result struct {
	Streams []struct {
		SSRC    string  `json:"ssrc"`
		MOS     float64 `json:"mos"`
		LossPct float64 `json:"loss_pct"`
	} `json:"streams"`
	Total int `json:"total"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
	return err
}
for _, s := range result.Streams {
	fmt.Printf("SSRC %s: MOS=%.1f, loss=%.1f%%\n", s.SSRC, s.MOS, s.LossPct)
}

JavaScript (Node.js):

const resp = await fetch(`${base}/v1/streams?mos_below=3.0`, {
  headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(`GET /v1/streams: ${resp.status} ${resp.statusText}`);
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": "12013223@203.0.113.195",
      "mos": 4.2,
      "mos_grounded": true,
      "mos_grounding": "published"
    }
  ],
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

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. It needs an equipment impairment factor (Ie) for the codec.

  • G.113 publishes one for some codecs and not for others.
  • For a codec with none, the score falls back to a placeholder that means “unknown”.
  • It does not mean “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:

KeyTypeMeaning
mos_groundedbooltrue 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_groundingstringWhich one: published (ITU-T G.113), operator_declared (this deployment’s [media.codec_ie]), or unpublished (the placeholder)
mos_notestringThe 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. It cannot score a wideband codec, because the narrowband and wideband models use different scales. An AMR-WB stream whose mode sipnab could read gets a second figure instead:

KeyTypeMeaning
mos_widebandnumberMOS_CQEW on the ITU-T G.107.1 wideband scale
mos_wideband_contextstringmonotic (handset or monaural headset) or diotic (stereo headset or speakerphone)
mos_wideband_unavailablestringWhy 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 for these reasons:

  • ITU-T G.113 tabulates the two separately.
  • 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. 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.
    • That 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.
    • That makes this one stream impossible to score.
    • The tables are not silent in this case.

AMR-WB under loss on a handset is not computable from published data. sipnab says so. It does not substitute 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.
  • 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:

KeyTypeMeaning
amr_mode_kbpsnumberThe one mode every readable frame used, in kbit/s. Absent when the sender switched mode during the stream
amr_modes_observednumberHow 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. 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.
    • 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 may send nothing but silence descriptors.
  • It reports no modes observed.
  • That 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_version 2. A version 1 response has no mos_grounded, mos_grounding or mos_note, and its mos_below filter also selected placeholder scores. A client that asserts on the exact key set must accept the three keys.

The MCP rtp_stats tool carries the same three keys. 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:

ValueWho said itWhat the address is
signaleda negotiating party, in its own SDPthat party’s endpoint
media-relayan rtpengine relay, about a port it allocatedthe leg’s midpoint

A relay’s answer is authoritative about the port. rtpengine cannot be wrong about which socket it opened. At the same time, it is not an endpoint.

  • An operator might trace one-way audio to 192.0.2.40:38664.
  • They need to know whether that address is the far end or the box in the middle.
  • 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.
  • It never means “a party did”. That is a claim.
  • Keeping the two apart is why the field exists.

It answers a different question from dialog_origin. That field 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:

req = Request(
    f"{BASE}/v1/streams/{quote(SSRC, safe='')}",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
with urlopen(req, timeout=10) as resp:
    stream = json.load(resp)
print(f"Codec: {stream['codec']}, Packets: {stream['packets']}")

Go:

req, err := http.NewRequest(http.MethodGet,
	baseURL+"/v1/streams/"+url.PathEscape(ssrc), nil)
if err != nil {
	return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("GET /v1/streams/%s: %s", ssrc, resp.Status)
}

var stream struct {
	Codec   string `json:"codec"` // empty when no codec was identified
	Packets int    `json:"packets"`
}
if err := json.NewDecoder(resp.Body).Decode(&stream); err != nil {
	return err
}
fmt.Printf("Codec: %s, Packets: %d\n", stream.Codec, stream.Packets)

JavaScript (Node.js):

const resp = await fetch(`${base}/v1/streams/${encodeURIComponent(ssrc)}`, {
  headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(`GET /v1/streams/${ssrc}: ${resp.status} ${resp.statusText}`);
const stream = await resp.json();
console.log(`Codec: ${stream.codec}, Packets: ${stream.packets}`);

Response: full RTP stream JSON. It includes:

  • codec, packet counts, jitter, loss, associated dialog,
  • the same MOS figures GET /v1/streams carries:
    • mos, r_factor, mos_grounded, mos_grounding,
    • mos_note when the MOS needs care,
    • for AMR-WB, mos_wideband, mos_wideband_context or mos_wideband_unavailable.

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.

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

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 is the count /metrics reports as sipnab_capture_packets_total.

The body is the capture analysis itself, the object --json-analyze prints. It opens with schema_version.

Query parameter:

NameLegal valuesIf omitted
formatjson, or yang-json for the same analysis RFC 7951-encoded against the YANG module sipnab-diagnosis. Anything else is a 400.json

?format=yang-json answers Content-Type: application/yang-data+json. That is the media type RFC 8040 registers for YANG data. The body validates against the module sipnab --print-yang-module prints:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
  'http://127.0.0.1:8080/v1/report?format=yang-json' > analysis.json
yanglint -t data sipnab-diagnosis@2026-10-09.yang analysis.json

It is the same analysis in a different encoding. The YANG section of Output formats lists every difference.

This is not a RESTCONF server. The route, the guard and the bearer token are the ones every other route uses.

The MCP get_capture_report tool answers the same question, with the same format spellings.

  • So do sipnab --analyze, --json-analyze and --yang-analyze on the command line.
  • --report does not. It is the tabular per-dialog summary and ranks nothing.

GET /v1/runtime

What sipnab is doing, and what it is costing the host it runs on.

sipnab exports 51 Prometheus metrics. The listener that serves them is off by default.

  • This endpoint answers the same questions without that listener.
  • It also reports:
    • sipnab’s own resource use,
    • 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_sources": { "used": 1, "capacity": 65536, "pct": 0.0015 },
  "capture_sources_refused_total": 0,
  "capture_packets_total": 852,
  "capture_queue_depth_packets": 0,
  "capture_backpressure_blocks_total": 0,
  "uptime_seconds": 41,
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

On a --hep-send run the envelope also carries hep_export. It shows what the exporter delivered, replayed, dropped and failed:

"hep_export": {
  "transport": "tcp",
  "packets_sent": 18204,
  "failures": { "connect": 3, "tls_handshake": 0, "write": 0 },
  "reconnects": 1,
  "queued": 18210,
  "replayed": 212,
  "dropped": { "queue_full": 0, "stopped": 0, "unsent_at_exit": 0 },
  "queue_depth": 6,
  "delivery": "written to the connection: a write that fails is counted, the packet stays queued, and the sender dials again with backoff"
}

Every failure kind and every drop reason appears, zeros included.

  • A failure is one failed delivery attempt. The packet stays queued and the sender tries it again, so losses are the dropped counts.
  • connect is a collector that refused, or that the sender could not reach within --hep-send-timeout.
  • tls_handshake is a collector whose certificate the sender does not accept.
  • write is a packet an established connection refused.
  • queued always equals packets_sent plus every dropped count plus queue_depth.

See When the --hep-send collector goes away, and When the collector goes away for the counts a collector restart left.

Over UDP, packets_sent means handed to the kernel. A collector that is down produces no failure at all. delivery says this in words. The key is absent on a run that exports nothing.

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 says whether sipnab’s memory use is large enough to affect the host. It is true when sipnab holds at least the threshold share of host memory. note states the share and the threshold.

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. The fix is ring size, coalescing or RSS.
    • ps_drop climbing alone is sipnab’s read loop falling behind. 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. They do not appear as 0.
  • Zero there would read as “the queue is clear”. That 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. “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]
  ]
}

calls_per_second_by_method separates INVITE from OPTIONS, so keepalive traffic does not hide the call rate.

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.
  • window_seconds tells you that happened.

sipnab rejects sample_seconds=0 rather than answering it.

  • An empty window returns zero deltas.
  • Zero deltas is exactly what a healthy quiet capture looks like.

The MCP tool runtime_stats returns the same envelope, with the same refusal and the same clamp.

GET /v1/hep/senders

This route shows:

  • who is feeding this run’s HEP listener,
  • who went silent,
  • who it is turning away.

It exists only in a build with the hep feature. It needs a full-scope token. Sender addresses describe your estate, so a metrics token gets 401.

curl:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/hep/senders | jq .

Response:

{
  "schema_version": 1,
  "listening": true,
  "trust": "shared_secret_plain",
  "silence_threshold_seconds": 30,
  "packets_received": 1843,
  "packets_admitted": 1829,
  "packets_refused": 14,
  "refused_by_reason": {
    "allowlist": 0, "auth_missing": 0, "auth_mismatch": 14, "event_sender_not_listed": 0,
    "hmac_bad_format": 0,
    "hmac_bad_mac": 0, "hmac_replay": 0, "hmac_timestamp_out_of_window": 0,
    "hmac_unsupported_version": 0, "malformed": 0, "peer_tracking_full": 0,
    "rate_limit_global": 0, "rate_limit_per_peer": 0
  },
  "senders_tracked": 2,
  "senders_limit": 4096,
  "senders_silent": 1,
  "untracked_packets": 0,
  "senders": [
    {
      "source": "hep:7@192.0.2.7", "capture_id": 7, "peer": "192.0.2.7",
      "identity": "claimed_by_sender", "trust": "shared_secret_plain",
      "packets": 1604, "first_seen": "2026-09-21T12:00:00.412Z",
      "last_seen": "2026-09-21T12:14:59.880Z", "idle_seconds": 0, "silent": false
    },
    {
      "source": "hep:9@192.0.2.9", "capture_id": 9, "peer": "192.0.2.9",
      "identity": "claimed_by_sender", "trust": "shared_secret_plain",
      "packets": 225, "first_seen": "2026-09-21T12:00:01.020Z",
      "last_seen": "2026-09-21T12:09:12.334Z", "idle_seconds": 348, "silent": true
    }
  ],
  "refused_sources_tracked": 1,
  "refused_sources_limit": 256,
  "refused_sources_evicted": 0,
  "refused_sources": [
    {
      "peer": "203.0.113.66", "packets": 14, "by_reason": { "auth_mismatch": 14 },
      "first_seen": "2026-09-21T12:03:40.101Z", "last_seen": "2026-09-21T12:14:58.007Z"
    }
  ]
}

A sender is the capture id it claims and the address it sent from.

  • Two proxies that both leave --hep-id at its default of 1 are still two rows.
  • One host running two sipnab agents with different ids is two rows too.
  • source is the same hep:<capture-id>@<peer> string every frame pointer from that sender carries. So a row and a pointer name one sender the same way.

identity is always claimed_by_sender.

  • One shared secret serves the whole listener.
  • sipnab asks no sender for a client certificate.
  • So any sender holding the secret can claim any capture id.
  • The roster reports what senders say about themselves, and says so.

silent, never “down”. A sender is silent once it has sent nothing sipnab admitted for silence_threshold_seconds (--hep-silence-warn).

  • A PBX with no calls at night may legitimately send nothing.
  • So the word describes what sipnab saw, not what happened to the sender.
  • Only admitted packets count. A sender whose packets now fail authentication goes silent. It shows up under refused_sources instead.

The refused-source table keys on address alone. A refused packet proved nothing, including the capture id it claimed.

  • The table holds the 256 addresses refused most recently and evicts the oldest.
  • So a spoofed flood rotates it rather than freezing it.
  • refused_by_reason counts every refusal, and nothing evicts it.

Both lists have bounds, and the totals beside them do not.

  • The sender table holds senders_limit senders ([limits] max_tracked_peers).
  • A sender arriving after it fills still reaches the capture and counts in untracked_packets. But it gets no row.
  • The table refuses new entries rather than recycling old ones, because it also numbers each sender’s frames.
  • ?limit=N caps the rows each list returns without changing any total.

No listener, no roster. A run without -L answers listening: false, zero counts and a note. It never answers with an empty roster that would read as “nobody is sending”.

The MCP tool hep_senders returns the same fields and values for the same roster, with its keys in alphabetical order and source_exhausted and source_stopped_early added. --hep-senders prints it at the end of a headless run (See who feeds the listener). Prometheus carries only the aggregate counts, with no per-sender label. See Metrics.

GET /v1/th-mappings

The OpenSIPS topology-hiding mappings sipnab holds, one row per mapping, from the E_SIPNAB_TH_MAP events the HEP listener accepted. Use it to find why one call through topology_hiding("C") did not join its caller leg.

It exists only in a build with the hep feature, where the events can arrive.

curl:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/th-mappings?call_id=DLGCH_fl0RBn4kMSMPFR0PPjk8NmETCgM-" | jq .

Response:

{
  "schema_version": 1,
  "call_id": "DLGCH_fl0RBn4kMSMPFR0PPjk8NmETCgM-",
  "state": null,
  "mappings": [
    {
      "caller_call_id": "1-th-map@example.com",
      "callee_call_id": "DLGCH_fl0RBn4kMSMPFR0PPjk8NmETCgM-",
      "state": "joined",
      "caller_captured": true,
      "callee_captured": true
    }
  ],
  "total_matched": 1,
  "counts": {
    "accepted": 3, "duplicate": 0, "conflict": 0, "malformed": 0,
    "refused_full": 0, "expired": 0, "held": 3, "pending": 0
  },
  "th_callid": {
    "decoded": 0, "no_secret": 0, "undecodable": 0, "unmatched": 0,
    "mapped": 3, "conflict": 0, "undecoded_with_peer": 0
  },
  "capture_identity": {
    "node": "capture-01",
    "instance": "1f4a17c8e2b91d40-1",
    "dialog_generation": 6,
    "stream_generation": 0
  }
}
ParameterMeaning
call_idOnly the mapping that names this Call-ID, on either side
stateOnly mappings in this state: conflict, pending or joined. Any other word answers 400
limitThe most rows to return, at most --api-max-rows. total_matched counts every match

state is one of three values:

  • joined: sipnab captured both legs, and the mapping joins them.
  • pending: a leg is not captured yet. caller_captured and callee_captured say which one is missing.
  • conflict: another event named one of its Call-IDs with a different partner, so this mapping joins nothing.

Rows come in that order of interest: conflicts first, then pending, then joined, each by caller-side Call-ID.

Why a call did not join. Ask with the call’s Call-ID, either leg:

  • No row: no E_SIPNAB_TH_MAP event named it.
  • pending: the event arrived, and the SIP of the leg marked false was not captured.
  • conflict: two events disagree about it.

counts is the object /v1/stats returns as th_mappings, and th_callid is the object it returns as th_callid. The MCP tool th_mappings returns the same fields and values, and --th-mappings prints them at the end of a headless run. Find why a call through topology hiding did not join walks through using it.

Find why a call through topology hiding did not join

OpenSIPS topology_hiding("C") gives the callee’s leg a Call-ID that shares nothing with the caller’s. When OpenSIPS sends sipnab an E_SIPNAB_TH_MAP event per call, sipnab joins the two legs. When one call shows up as two unrelated dialogs, this finds out which part did not arrive.

Prerequisites.

  • OpenSIPS: the event_datagram module, the subscribe_event line and the raise_event lines in Have OpenSIPS send the mapping. Nothing else changes on OpenSIPS.
  • sipnab: a build with the hep feature, which the release packages carry, listening for HEP with --hep-listen, with OpenSIPS’s address in --hep-allow. sipnab refuses every event from a sender --hep-allow does not list.
  • REST: --api and a key. For a run with no server, --th-mappings prints the same listing when the run ends.

Steps. On a running instance, read the counts, then ask about the call by either of its Call-IDs:

# Run all of these, in order.
curl -s -H "Authorization: Bearer s3cret" http://127.0.0.1:8092/v1/stats | jq '{th_mappings, th_callid}'
curl -s -H "Authorization: Bearer s3cret" "http://127.0.0.1:8092/v1/th-mappings?call_id=DLGCH_fl0RBn4kMSMPFR0PPjk8NmETCgM-" | jq '.mappings'

This example’s listener ran sipnab -N -L 127.0.0.1:9063 --hep-allow 127.0.0.1/32 --api 127.0.0.1:8092 --api-key s3cret. It received the three calls of the OpenSIPS 4.0.2 capture in tests/fixtures/th-mapping/opensips-4.0-events.pcap, SIP over HEP and one event each.

Verify. Every event joined a call when accepted equals held, pending is 0 and the hidden legs are all mapped:

{
  "th_mappings": {
    "accepted": 3,
    "conflict": 0,
    "duplicate": 0,
    "expired": 0,
    "held": 3,
    "malformed": 0,
    "pending": 0,
    "refused_full": 0
  },
  "th_callid": {
    "conflict": 0,
    "decoded": 0,
    "mapped": 3,
    "no_secret": 0,
    "undecodable": 0,
    "undecoded_with_peer": 0,
    "unmatched": 0
  }
}

The call asked about is one row, joined, with both legs captured:

[
  {
    "caller_call_id": "1-th-map@example.com",
    "callee_call_id": "DLGCH_fl0RBn4kMSMPFR0PPjk8NmETCgM-",
    "state": "joined",
    "caller_captured": true,
    "callee_captured": true
  }
]

A headless run prints the whole table at its end with --th-mappings. This run (sipnab -N -L 127.0.0.1:9063 --hep-allow 127.0.0.1/32 --duration 10 --th-mappings --no-cli-print) received the same three calls, then an event pairing call 1’s caller with call 2’s callee, and an event for a call whose SIP never arrived:

Topology-hiding mappings: 4 mappings held, 1 pending; events: 4 accepted, 0 duplicate, 1 conflicting, 0 malformed, 0 refused (table full); 0 expired
Hidden legs held: 0 decoded, 0 no_secret, 0 undecodable, 0 unmatched, 1 mapped, 2 conflict
Caller Call-ID        Callee Call-ID                      State     Caller captured  Callee captured
----------------------------------------------------------------------------------------------------
1-th-map@example.com  DLGCH_fl0RBn4kMSMPFR0PPjk8NmETCgM-  conflict  yes              yes
2-th-map@example.com  DLGCH_fV0RBn4kMSMPFR0PPjk8NmETCgM-  conflict  yes              yes
4-th-map@example.com  DLGCH_never-captured                pending   no               no
3-th-map@example.com  DLGCH_fF0RBn4kMSMPFR0PPjk8NmETCgM-  joined    yes              yes

Troubleshoot.

  • No row for the call’s Call-ID. No event named it. Check that OpenSIPS raises E_SIPNAB_TH_MAP after topology_hiding("C"). Then read refused_by_reason on /v1/hep/senders: event_sender_not_listed means the run has no --hep-allow at all, and allowlist means OpenSIPS’s address is not in it.
  • pending, with a leg marked false (no in the table). The event arrived and the SIP of that leg did not. With --hep-listen and -d together and no filter of your own, the interface takes media only. See Have OpenSIPS send the mapping.
  • conflict. Two events named one of the Call-IDs with different partners, so neither joins. Rows 1 and 2 above are one such pair. Check for two OpenSIPS instances sharing a Call-ID space, or a script that raises the event with the wrong variables.
  • malformed grows in the counts. A datagram reached the listener from a listed sender and was not a valid event. Compare the script’s raise_event lines with the guide’s.
  • undecodable or unmatched legs, with undecoded_with_peer above 0. sipnab tried to join these legs by decoding, not by events, and it captured the caller leg. Check [sip] th_callid_secret_file and th_callid_prefix.

The MCP tool th_mappings takes the same call_id and state.

GET /v1/capabilities

What this build can do and what the operator turned on. This is the machine contract a program reads before it asks. With it, 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,
    "api_allow_vcon_fetch": false
  },
  "libpcap": {
    "banner": "libpcap version 1.10.5 (with TPACKET_V3)",
    "version": "1.10.5",
    "named_backends": []
  }
}

features is the one canonical list.

  • It is the same set --version prints and the MCP server_capabilities tool returns.
  • It names only features compiled into the running binary.
  • A capability absent here is one this build cannot do. That is a different fact from one this run did not turn on.

runtime names what the operator turned on.

  • Some routes answer not_permitted because --api-allow-relay-query was not passed. That is not a missing feature.
  • 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.
  • api_allow_vcon_fetch is off unless GET /v1/vcon/fetch/:uuid can reach a store: the run has --api-allow-vcon-fetch and the config file sets [vcon_fetch] url.

libpcap names the library this process captures through, as the library reports itself at runtime.

  • A gnu build or a package reports the host’s libpcap. The sample above is Debian 13’s.
  • A static musl build reports the 1.10.6 it carries.

named_backends lists the alternate capture backends (netmap, dpdk, dag, snf) that the banner names. It is [] when the banner names none. That is not proof the library has none:

  • libpcap names netmap in its banner only from 1.10.6,
  • and DPDK only in a DPDK-only build.

See which capture backends an artifact can reach.

The MCP tool server_capabilities returns the same feature set and the same libpcap block. The second line of sipnab --version prints the same library banner.

GET /v1/inventory

What this sipnab process is, for managing many instances as one fleet:

  • its build: version, git commit and tag, target and compiled features,
  • SHA-256 of the binary it is running,
  • its configuration, as digests only,
  • its user id and privilege drop, its pid, start time and uptime,
  • its capture interfaces’ error and drop counters, its capture and HEP export counters, and the addresses its HEP listener receives from.

It needs a token with scope inventory, or full. sipnab fleet inventory reads it from every instance in a hosts file.

curl:

curl -s -H "Authorization: Bearer $SIPNAB_INVENTORY_TOKEN" http://127.0.0.1:8080/v1/inventory | jq .

Response (from a run on 2026-11-03 of a development build, with the config path replaced by /etc/sipnab/sipnab.toml):

{
  "schema_version": 1,
  "node_name": "edge-a",
  "build": {
    "version": "0.5.232",
    "commit": "29204db7",
    "tag": "",
    "dirty": true,
    "target": "aarch64-unknown-linux-gnu",
    "features": ["api", "archive", "audio", "g729", "hep", "mcp", "mcp-http", "metrics",
                 "native", "opus", "plugins", "tls", "tui", "vcon"],
    "version_line": "0.5.232 (29204db7-dirty) features: native,tui,audio,tls,hep,api,mcp,mcp-http,metrics,plugins,vcon,opus,archive,g729"
  },
  "binary": {
    "sha256": "3242a3324b1c3ac762ba15d4f2eec3789a995533540b8aee56ec087afdc51ea8",
    "error": null
  },
  "signature": {
    "state": "unverified",
    "note": "sipnab does not verify its own binary. Compare binary.sha256 with SHA256SUMS.txt of the release, after `gh attestation verify SHA256SUMS.txt --repo NormB/sipnab`."
  },
  "config": {
    "recorded": true,
    "source": "/etc/sipnab/sipnab.toml",
    "shadowed": [],
    "file_sha256": "703493b6b6bdef18b62090d6452a2bd9fc72de264d4b97822cb0b7763697fca6",
    "parsed_sha256": "1849a6ee687ec0b1394f2d305198c638bca159e7aa032911156074b8d11f4044",
    "argv_sha256": "d5c72de2bcf085f7d8ca9884bed30027d7504215d507b04f6be16ecc063194e0",
    "effective_sha256": "31e67effe25101b07a2c7942f05b83d568816bf81a1769d65ab8396c8e89705a"
  },
  "privilege": { "uid": 1000, "root": false, "dropped_to": null },
  "process": { "pid": 3983974, "started": "2026-11-03T13:26:05Z", "uptime_seconds": 17 },
  "interfaces": [],
  "counters": {
    "capture_packets_total": 852,
    "capture_sources_refused_total": 0,
    "capture_backpressure_blocks_total": 0,
    "hep_export_failures_total": null,
    "hep_export_dropped_total": null,
    "hep_export_queue_depth": null
  },
  "hep_listener": { "listening": false, "packets_refused": 0, "senders": [] }
}

No configuration value is in the answer.

  • file_sha256 is SHA-256 of the config file’s bytes as this run read them.
  • parsed_sha256 is SHA-256 of the configuration as sipnab parsed it, after ${NAME} expansion, with every object’s keys sorted. Two files that differ only in comments, whitespace, key order or an unknown key give one value.
  • argv_sha256 is SHA-256 of the command line, without the program path.
  • effective_sha256 is SHA-256 over parsed_sha256 and argv_sha256. A flag overrides the file, so this is the digest to compare across instances. sipnab fleet inventory shows it as CONFIG.
  • A secret in the file or on the command line changes a digest, and its value never appears. A secret held in a file the configuration names, such as --api-signing-key-file, reaches the digests by its path only.

signature.state is always unverified. A process cannot prove which binary it is. Compare binary.sha256 with the release’s SHA256SUMS.txt, after verifying that file’s attestation with gh attestation verify. sipnab reads binary.sha256 from /proc/self/exe on Linux, so after an upgrade replaces the file it still names the binary that is running.

Every field is the process’s own claim. An attacker who controls a host can report anything.

hep_listener.senders lists at most MAX_HEP_PEERS (256) senders.

config.recorded is false, and every digest null, for a server that did not record its configuration at start. The MCP tool fleet_inventory returns the same fields and values.

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:

req = Request(
    f"{BASE}/v1/stats",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
with urlopen(req, timeout=10) as resp:
    stats = json.load(resp)
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, err := http.NewRequest(http.MethodGet, baseURL+"/v1/stats", nil)
if err != nil {
	return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("GET /v1/stats: %s", resp.Status)
}

var stats struct {
	Dialogs struct {
		Total  int `json:"total"`
		Active int `json:"active"`
		Failed int `json:"failed"`
	} `json:"dialogs"`
}
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
	return err
}
d := stats.Dialogs
fmt.Printf("Dialogs: %d total, %d active, %d failed\n", d.Total, d.Active, d.Failed)

JavaScript (Node.js):

const resp = await fetch(`${base}/v1/stats`, {
  headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) throw new Error(`GET /v1/stats: ${resp.status} ${resp.statusText}`);
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
  },
  "th_mappings": {
    "accepted": 0, "duplicate": 0, "conflict": 0, "malformed": 0,
    "refused_full": 0, "expired": 0, "held": 0, "pending": 0
  },
  "th_callid": {
    "conflict": 0, "decoded": 0, "mapped": 0, "no_secret": 0,
    "undecodable": 0, "undecoded_with_peer": 0, "unmatched": 0
  },
  "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.

KeyWhat it counts
media_creating_commandsrtpengine subscribe, publish and start recording commands seen and deliberately not attributed
tlsTLS decryption state — absent unless a decryptor ran

Those commands create media for a call without being one of its two legs. sipnab does not attribute that media to the call, so a two-party call keeps two streams and its one-way-audio and asymmetry checks stay valid. A non-zero media_creating_commands means such media existed and is not in the call’s streams.

media_creating_commands is always present, and 0 is a real answer.

caveats.tls — is decryption working?

Absent when nobody supplied keys. That is not a decryption failure. 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.
  • Without this number, there is nothing on the page to tell them apart.

late_recovered and late_evicted stay separate. “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. sipnab publishes them as decryption happens. So a client can read them mid-run. It does not need to wait 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. 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.
  • Both see a swap the moment it happens.

sipnab reads the instance and both generations at the same moment.

null when nobody told this server what capture it holds.

These answers carry capture_identity: GET /v1/dialogs, /v1/dialogs/{call_id}, /v1/dialogs/{call_id}/report, /v1/dialogs/{call_id}/correlated, /v1/dialogs/{call_id}/tree, /v1/dialogs/{call_id}/lint, /v1/dialogs/compare, /v1/dialogs/tail, /v1/dialogs/rates, /v1/streams, /v1/streams/{id}, /v1/report, /v1/stats, /v1/aggregate, /v1/timeline, /v1/talkers, /v1/endpoints, /v1/runtime, /v1/security/findings and /v1/th-mappings. Each one carries the identity the MCP tool answering the same question carries, read under the same locks as the answer. These answers do not carry it:

AnswerWhy
/v1/report?format=yang-jsonRFC 7951 allows no member at the top of the document that its YANG module does not declare. Request format=json for the identity.
/v1/dialogs/{call_id}/vconThe body is the vCon container itself. MCP export_vcon puts the identity on the envelope around its containers.
/v1/hep/sendersThe MCP hep_senders tool does not carry it either: the answer is the HEP listener’s sender roster, not the capture’s contents.
/v1/capabilities, /v1/captures/compare, /v1/vcon/spool, /v1/vcon/fetch/{uuid}, /v1/tfps/*, /v1/relay/*The answer is not about the loaded capture: it describes the build, two files on the file root, the spool, the vCon store, the TFPS peer, or the media relay.
/v1/persistence, /v1/calls/liveNo MCP tool answers the same question.
/health, /metrics, the audio routesThey answer text or audio, not a JSON object.
KeyMeaning
sourcelive, file, or unknown
capture_nameinterface name when live, file path when replaying
uptime_secseconds since capture began
source_exhaustedtrue once sipnab reaches the end of a file source
writing_topath sipnab saves packets to, if any
unsavedtrue only for a live capture with no output file — packets held in memory and nowhere else

unknown is a real answer, not a default. sipnab reports unknown rather than guess live or file.

Read unsaved before you stop a capture.

  • A file replay is already on disk, so it is never unsaved.
  • A live capture with an output file is safe to stop.

Count the OpenSIPS topology-hiding mappings

th_mappings counts the OpenSIPS E_SIPNAB_TH_MAP events the HEP listener accepted from a --hep-allow sender, and the mappings they hold. The MCP tool capture_status returns the same object.

KeyMeaning
acceptedevents that added a mapping
duplicateevents that repeated a mapping already held
conflictevents refused because a Call-ID was already mapped to another partner; the held mapping stops joining
malformedevent datagrams that were not a valid event
refused_fullevents refused because the table held [sip] th_mapping_max mappings
expiredmappings dropped after [sip] th_mapping_ttl_secs with neither leg in the store
heldmappings held now
pendingmappings held now whose two legs are not both captured

See Have OpenSIPS send the mapping. To list the mappings themselves, use GET /v1/th-mappings.

Count the topology-hiding legs by status

th_callid counts the OpenSIPS topology-hiding legs the store holds now, by the status each leg’s th_callid reports on /v1/dialogs/{call_id}/correlated. The MCP tool capture_status returns the same object.

KeyMeaning
decodedhidden legs whose Call-ID decoded to a captured caller leg with the same From tag
no_secrethidden legs seen with no [sip] th_callid_secret_file configured
undecodablehidden legs the configured secret or prefix does not decode
unmatchedhidden legs that decoded to no captured caller leg
mappedhidden legs an E_SIPNAB_TH_MAP event joined
conflicthidden legs whose mapping a conflicting event stopped
undecoded_with_peerundecodable or unmatched legs whose From tag is on a captured caller leg: sipnab captured the caller side, so check the secret and the prefix

Find SIP that --portrange excluded

sipnab reads SIP only on the ports --portrange names (default 5060-5061). When it reads a file, it counts the SIP it skipped on other ports and names those ports. capture_quality does not include this count: sipnab lost no packet, and it recognized the bytes as SIP.

KeyMeaning
unanalysed_sip_messagesplain SIP with both ports outside --portrange
unanalysed_busiest_portsthe top five ports carrying it, busiest first
unanalysed_websocket_messagesSIP-over-WebSocket (RFC 7118) outside the WebSocket port set
unanalysed_websocket_portsthe top five ports carrying that

To check a server, read the four keys from /v1/stats. This server replays tests/pcap-samples/sip-problem-call.pcap with --portrange 5070-5080, a range that excludes the capture’s SIP port:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" http://127.0.0.1:8080/v1/stats \
  | jq '{dialogs: .dialogs.total, unanalysed_sip_messages, unanalysed_busiest_ports}'
{
  "dialogs": 0,
  "unanalysed_sip_messages": 23,
  "unanalysed_busiest_ports": [
    {
      "messages": 23,
      "port": 5060
    }
  ]
}

dialogs is 0 because every SIP message was on port 5060. To analyze them, restart sipnab with a range that covers the listed ports, for example --portrange 5060-5061, or --portrange 1-65535 for every port.

For SIP over WebSocket, widening --portrange does not help. Set --ws-portrange to the ports unanalysed_websocket_ports lists. This is common behind a reverse proxy that terminates WSS, and with Kamailio, OpenSIPS and Janus, whose WebSocket ports are outside sipnab’s default 80/443/8080/8443.

Both counts are 0 on a live capture. There, the port range becomes the BPF filter, and the kernel drops other ports before sipnab sees them.

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,
  • 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.

undecodable_frames is a fourth channel. It is 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 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. The proportion is sipnab_capture_undecoded_fraction.

It is not part of degraded. ARP frames count as undecodable, and nearly every Ethernet capture holds some.

snapped_frames is a fifth channel. It is neither loss nor a decode failure.

  • The frames arrived, and most of them decoded.
  • What is missing is payload.
  • That is exactly what a signaling-only capture sets out to discard.

It matters because the --snaplen warnings fire once per run. They 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.
  • 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. What each key describes went wrong on the wire:

KeyWhat it counts, and what to do about it
unanswered_nat_requestsSTUN 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_allocationsTURN 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_streamsMedia 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_conflictsCandidate 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. It 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.
  • It does not mean the capture provably saw every packet.
  • Loss upstream of the capture point is invisible to all three counters. Examples:
    • an oversubscribed SPAN port,
    • a tap mirroring one direction,
    • a filter that excluded the traffic.

GET /v1/aggregate

How many dialogs, grouped by one dimension. This is the “how many, by what” question, answered over the store rather than the model.

Query parameters:

  • by (required) — the dimension: one of state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec. A key outside that set is a 400.
  • filter (optional) — a DSL expression or named alias narrowing which dialogs the count includes, what /v1/dialogs?filter= accepts. A value that is neither is a 400.
  • top_n (optional) — keep the largest N buckets. The rest fold into other_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,
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

One dimension at a time. Narrow with filter rather than asking for a second.

  • The buckets are largest first.
  • Value breaks ties, so the same store always gives the same answer.
  • 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.
    • 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).

  • It fences the sender-controlled values for a model.
  • This route returns them raw for a program.

GET /v1/timeline

How many calls opened over time, in fixed-width buckets. This is the volume series a dashboard polls.

Query parameters:

  • bucket_seconds (optional) — the interval width, default 60. Zero is a 400. The dialogs may span at most MAX_TIMELINE_BUCKETS (100,000) intervals of the width. A span of more is a 422 whose detail names a width that fits.

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,
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

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.
  • 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. This is 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 a 404.
  • b (required) — Call-ID of the second call. A Call-ID with no dialog is a 404.

curl:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/dialogs/compare?a=call-a@example.com&b=call-b@example.com" | jq .

Response:

{
  "schema_version": 1,
  "a": {
    "call_id": "call-a@example.com",
    "state": "InCall",
    "final_status_code": 200,
    "msg_count": 7,
    "methods": ["ACK", "BYE", "INVITE"],
    "hints": []
  },
  "b": {
    "call_id": "call-b@example.com",
    "state": "Failed",
    "final_status_code": 486,
    "msg_count": 3,
    "methods": ["INVITE"],
    "hints": ["the callee was busy"]
  },
  "differences": ["state", "final_status_code", "msg_count", "methods"],
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

differences names the fields that moved.

  • It is a subset of state, final_status_code, msg_count and methods, in that order.
  • It is 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. This is cursor-based change tracking.

  • It is the pattern a monitoring system reaches for.
  • The /v1/dialogs offset pagination cannot express it.
  • It is the same question the MCP tail_dialogs tool answers.

Query parameters:

  • since (optional) — the previous response’s next_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 a 400.
  • 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": "a@example.com", "state": "InCall", "updated_at": "2026-09-15T12:00:03Z" }
  ],
  "returned": 1,
  "next_cursor": "2026-09-15T12:00:03Z|a@example.com",
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

The rows are the same summaries /v1/dialogs returns, oldest update first.

  • Pass next_cursor back as since on the next poll. 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, grouped by one dimension:

  • ASR, NER, ACD,
  • post-dial-delay percentiles,
  • MOS p10,
  • a retransmit rate.

This is the scorecard a monitoring system polls. /v1/aggregate bare counts cannot express it. These are the same figures the MCP group_dialogs tool computes.

Query parameters:

  • by (required) — the ONE dimension to group by: the fields /v1/aggregate counts by (state, response_code, method, from.user, to.user, ua, src.ip, dst.ip, rtp.codec) plus to_domain, hour and next_hop. A key outside that set is a 400.
  • metrics (optional) — a comma-separated subset of count, asr, ner, acd, pdd_p50, pdd_p95, mos_p10, retransmit_rate. Defaults to all. An unknown name is a 400.
  • filter (optional) — a DSL expression or named alias narrowing which dialogs the route groups, what /v1/dialogs?filter= accepts. A value that is neither is a 400.
  • top_n (optional) — keep the largest N groups by dialog count. The rest fold into other_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,
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

Every figure carries the population behind it.

  • A metric its population cannot support comes back null, with the reason in not_grounded. It does not come back 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.
  • 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).

  • This is the volume-and-abuse view a dashboard polls.
  • No other route exposes it.
  • It is the same ranking the MCP top_talkers tool answers.

Query parameters:

  • by (required) — ip, ua, or prefix. A key outside that set is a 400.
  • filter (optional) — a DSL expression or named alias narrowing which dialogs count, what /v1/dialogs?filter= accepts. A value that is neither is a 400.
  • limit (optional) — the most rows to return, clamped to the server’s row cap.
  • prefix_digits (optional) — leading digits that make one prefix bucket (default 4). Zero is a 400. Ignored for every other by.

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,
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

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. It 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). This is the single-participant profile you reach for when a complaint names one phone or one extension. It reports:

  • 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,
  • a bounded page of its most recent dialogs.

These are the same facets the MCP describe_endpoint tool reports.

Query parameters:

  • ip (one of ip/user) — the endpoint’s IP address. An address that does not parse is a 400.
  • user (one of ip/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. 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": "a1b2@203.0.113.9", "method": "INVITE", "state": "Completed" }
  ],
  "truncated": true,
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

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.
    • Its streams are the ones linked to its dialogs.

Banner values and codec tokens come back raw. These are the values a program keys on. The MCP surface 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 recorded, newest first. The detectors are scanner, fraud, digest and reg_flood.

  • This is the poll a SOC dashboard makes.
  • It is the same ring the MCP security_findings tool reads.

Query parameters:

  • kinds (optional) — a comma-separated subset of scanner, fraud, digest, reg_flood.
    • Omitted returns every kind.
    • Any other name is a 400 naming 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 a 400.
  • limit (optional) — the most findings to return, clamped to the server’s row cap. total_matched still 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,
  "scanner_response": "not_answering",
  "observation_gaps": [],
  "capture_identity": {
    "node": "capture-01",
    "instance": "117b0818df1033d4fd2a28-2",
    "dialog_generation": 13,
    "stream_generation": 2
  }
}

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

scanner_response says whether sipnab answers the scanners it detects. scanner in armed_kinds says only that sipnab detects scanners.

  • off: scanner detection is not armed.
  • detect_only: the run uses --kill-scanner-detect-only. sipnab detects scanners and never answers them.
  • not_answering: sipnab detects scanners and does not answer them, because the run reads a capture file, runs the interactive TUI, or could not start its scanner-kill worker.
  • answering: the scanner-kill worker is running and sends a SIP reply to each detected scanner.

An armed detector can also say it could not see. observation_gaps lists what it cannot establish from this capture. It is per detector and narrowed by kinds. Each entry is { rule_name, reason, seen, unestablished, detail }.

  • reg_flood files reason: "no_answers" when the capture holds REGISTERs and no final response to any of them.
  • It files reason: "unanswered" when some credentialed REGISTERs drew no final response inside --reg-flood-transaction-timeout.
  • An empty findings beside an entry here means “could not tell”, not “clean”.
  • The entry names no source and never becomes a finding.
  • The array is always present. It is empty when there is nothing to say.

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.

  • It comes back raw, the value a program keys on.
  • The MCP tool fences it.

GET /v1/captures/compare

Diff two capture files by aggregate. Per dimension, it shows:

  • how many dialogs fell in each bucket in each capture,
  • how far that moved.

The route ranks the rows so “today is worse than yesterday, and here is where” is the first row.

  • This is the query a monitoring system polls.
  • It is the same diff the MCP compare_captures tool answers.

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 as a is a 400.
  • A name with a separator, a .., an absolute prefix or a control character, or a symbolic link that resolves outside the root, is a 400.
  • 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 takes state and response_code.
  • top_n (optional) — rows per dimension, clamped to the server’s row cap. Everything past it sums into an (other) bucket.

Password-protected archives. a or b may name a ZIP, encrypted or not.

  • sipnab tries the passwords the operator configured at start-up first (see Archives).
  • A client may add one in the Sipnab-Archive-Password request header.
  • sipnab uses it for that request only and never remembers it.

The rules for the header:

  • Only from this host, unless the operator allows more.
    • sipnab accepts the header from a loopback client: a local tool or a TLS proxy on the same host.
    • A remote client gets 403. That holds when sipnab serves HTTPS itself with --api-tls-cert too.
    • --api-accept-archive-passwords lets remote clients send it. Pass it only when TLS encrypts the connection, through --api-tls-cert or a TLS proxy in front, so the password does not cross the network in the clear.
  • Never in the URL. A query parameter named like a password gets 400, on every route.
    • The answer advises you to treat that password as exposed.
    • URLs land in proxy and access logs.
  • sipnab limits wrong passwords.
    • After 5 wrong passwords for one archive within 15 minutes, that client gets 429 with Retry-After for that archive.
    • sipnab counts a client by a fingerprint of its bearer token. It uses its address when the client sends none.
    • sipnab logs each wrong password at warn with that fingerprint, the archive’s name and the count.
    • The log never holds the password.
  • No surrounding spaces. HTTP trims leading and trailing whitespace from a header value (RFC 9110).
    • So a password that starts or ends with a space cannot travel in this header.
    • Configure it on the server instead.
  • No cache keeps a copy. Some responses carry Cache-Control: no-store. These are every response to a request carrying the header, and every one that read a password-protected archive.
  • A locked archive answers 422, naming encrypted_no_password or encrypted_wrong_password per member. No response repeats the password or its length.

printf is a shell builtin, so the password never appears in a process list. curl -H @- reads the header from stdin.

printf 'Sipnab-Archive-Password: %s\n' "$(pass show pcaps/lab)" | curl -s -H @- -H "Authorization: Bearer $SIPNAB_API_KEY" "http://127.0.0.1:8080/v1/captures/compare?a=evidence.zip&b=today.pcap" | jq .

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. “This appeared today” is the finding.
  • dialogs_dropped above zero means that side hit the dialog ceiling. Its counts are a floor.

Bucket values come back raw.

  • A ua or from.user bucket is a banner a stranger typed.
  • This route hands a program the value it keys on.
  • The MCP tool 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. It is 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.

  • This is the producer-and- conserver boundary.
  • There, a store that would refuse a container can warn whoever built it first, before it reaches the store.
  • It is 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": 2,
  "verdict": "invalid",
  "schema_id": "https://ietf.org/vcon/schemas/unsigned-vcon.json",
  "schema_path": "tests/schemas/vcon.schema.json",
  "errors": [
    { "instance_path": "", "keyword": "required", "detail": "missing required properties: uuid, created_at" }
  ]
}

verdict is valid or invalid.

  • valid is a clean pass.
  • invalid carries every finding in errors.

The schema is the working group’s draft-ietf-vcon-vcon-core-04 schema, vendored unchanged.

The response is schema_version 2. A version 1 response also carried deviations, explanations and a valid-except-documented-deviation verdict. Version 2 has none of them.

The body must be the JSON object itself, not a string holding it. A non-object is a 400.

For a signed, encrypted or gzip format container, sipnab validates the unsigned vCon inside.

  • The body’s Content-Type is application/json, application/vcon, application/vcon+json or, for the gzip format, application/vcon+gzip. Another is a 400.
  • sipnab opens it with the server’s --vcon-key, --vcon-signer-ca and --vcon-signer-sha256: see A signed, encrypted or compressed container.
  • A container that does not open is a 422 with the reason: an encrypted one with no --vcon-key, a signature that does not verify, a signer the checks refuse.

A container whose critical lists an extension sipnab does not support is a 422 naming each one.

The MCP tool validate_vcon runs the same validation.


POST /v1/vcon/analyze

Rebuild the SIP messages a stored vCon container holds and analyze them, apart from the capture this server holds. The answer carries:

  • per dialog:
    • the --json-dialogs object,
    • the signaling diagnosis,
    • the message ladder,
  • the capture analysis.

It reads no store and writes none. The body is the container, in any form POST /v1/vcon/validate takes: sipnab opens a signed, encrypted or gzip format container first, and one that does not open answers verdict: unreadable with the reason. A gzip format container:

curl -s -X POST -H "Authorization: Bearer $SIPNAB_API_KEY" -H "Content-Type: application/vcon+gzip" \
  --data-binary @call.vcon.json.gz 'http://127.0.0.1:8080/v1/vcon/analyze' | jq -c '{verdict, call_id: .dialogs[0].call_id}'
{"verdict":"analyzed","call_id":"1-1966@10.0.2.20"}
  • limit (default 50, ceiling --api-max-rows) bounds the dialogs returned.
  • total_dialogs counts them all.

curl: the container is the one --export-vcon 1-1966@10.0.2.20 --node-name tap-a writes from tests/pcap-samples/sip-rtp-g711.pcap. The filter keeps the summary fields:

curl -s -X POST -H "Authorization: Bearer $SIPNAB_API_KEY" -H "Content-Type: application/json" \
  --data-binary @call.vcon.json "http://127.0.0.1:8080/v1/vcon/analyze?limit=10" \
  | jq '{verdict, uuid, messages_rebuilt, total_dialogs, truncated, schema_findings, not_recomputed, first_finding: .analysis.findings[0].kind, ladder: [.dialogs[0].ladder[].label]}'

Response:

{
  "verdict": "analyzed",
  "uuid": "0158a120-0392-857f-8193-d90b5d03d6c4",
  "messages_rebuilt": 6,
  "total_dialogs": 1,
  "truncated": false,
  "schema_findings": [],
  "not_recomputed": [
    "media: a vCon carries no RTP, so no stream, MOS, jitter or loss was computed from it",
    "the producer's analysis holds 1 media_quality row(s), its own numbers, not recomputed",
    "request-URI: not stored for 3 request(s), which carry sip:request-uri-not-stored.invalid",
    "header order: the trace keys headers by name, so their order across names is the container's, not the wire's, and headers the producer did not store (credential headers, when sipnab produced it) are absent"
  ],
  "first_finding": "rebuilt_from_vcon",
  "ladder": [
    "INVITE (SDP)",
    "100 Trying",
    "200 OK (SDP)",
    "ACK",
    "BYE",
    "200 OK"
  ]
}

verdict is analyzed or unreadable.

  • unreadable means the container stores no SIP message sipnab can rebuild, or is not a vCon at all. reason says which.
  • schema_findings lists every place the container disagrees with the schema, in the shape POST /v1/vcon/validate uses.
    • It appears beside either verdict.
    • A finding never stops the analysis.

A container that went through vcon.store, with extensions as an object, still gets an analysis. The section A container the schema refuses is still read writes store-shape.vcon.json:

curl -s -X POST -H "Authorization: Bearer $SIPNAB_API_KEY" -H "Content-Type: application/json" \
  --data-binary @store-shape.vcon.json "http://127.0.0.1:8080/v1/vcon/analyze" \
  | jq '{verdict, total_dialogs, schema_findings}'
{
  "verdict": "analyzed",
  "total_dialogs": 1,
  "schema_findings": [
    {
      "instance_path": "/extensions",
      "keyword": "type",
      "detail": "expected type \"array\", found object"
    }
  ]
}

not_recomputed lists what a container cannot reproduce.

  • A vCon stores no RTP. So sipnab computes no media statistic.
  • stored_media_quality carries the producer’s own numbers verbatim.
  • The analysis opens with a rebuilt_from_vcon finding.

The body must be the JSON object itself, not a string holding it. A non-object is a 400. A container whose critical lists an extension sipnab does not support answers verdict: unreadable, with a reason naming each one.

The MCP tool analyze_vcon runs the same analysis and answers with the same body. Its section describes every field.


GET /v1/vcon/spool

The state of the vCon export spool this run writes into. It shows:

  • containers pending, delivered and failed,
  • for each failed container, the <name>.error.json record the forwarder wrote beside it. That record holds the store’s status and the start of its answer.

It reads the directory --export-vcon-dir names. It also reads the forwarder’s default delivered/ and failed/ directories beside it. A run started without --export-vcon-dir answers 503 naming the flag.

The answer is a snapshot of files on disk, taken when as_of says.

  • The forwarder (sipnab --vcon-forward) is a separate process. It may move a file a moment later.
  • This route sends, fetches, moves and deletes nothing.
  • The MCP tool vcon_spool_status and sipnab --vcon-spool-status give the same answer.
  • The MCP tool also fences the store’s answer as untrusted text and cuts it to --mcp-max-body-bytes.
  • This route returns it as the forwarder kept it.

Paging:

  • ?limit= bounds the list of failed records (default 50, ceiling --api-max-rows).
  • ?cursor= takes the previous answer’s next_cursor.
  • The three counts always describe the whole spool.

A run with several export rules writes one spool per rule.

  • ?rule= names the rule whose spool to read. The flags’ rule is --export-vcon-when.
  • With a single spool, a request can leave it out.
  • These answer 400 listing the run’s rule names:
    • several spools and no rule,
    • a rule the run does not have.
  • The answer’s rule field says which rule’s spool it describes.

curl:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
  "http://127.0.0.1:8080/v1/vcon/spool?limit=50" | jq .

Response, from a run started with --export-vcon-dir vcons over a spool holding one container of each kind:

{
  "schema_version": 1,
  "as_of": "2026-10-12T06:49:34.783Z",
  "note": "A snapshot of files on disk at as_of. The vCon forwarder (sipnab --vcon-forward) is a separate process that moves files out of the spool; whatever produced this answer sends, fetches, moves and deletes nothing. The delivered and failed directories are the forwarder's defaults beside the spool; a forwarder started with --vcon-forward-done or --vcon-forward-failed moves files elsewhere, and those are not counted.",
  "spool": { "path": "vcons", "exists": true },
  "delivered_dir": { "path": "vcons/delivered", "exists": true },
  "failed_dir": { "path": "vcons/failed", "exists": true },
  "pending": 1,
  "delivered": 1,
  "failed": 1,
  "failures": [
    {
      "file": "refused.vcon.json",
      "record": {
        "at": "2026-10-11T11:58:02Z",
        "body": "{\"error\":\"vCon already exists\"}",
        "body_truncated": false,
        "file": "refused.vcon.json",
        "method": "POST",
        "reason": null,
        "status": 409,
        "url": "https://vcon.example.com/v1/vcons"
      }
    }
  ],
  "returned": 1,
  "truncated": false,
  "next_cursor": null
}

A spool directory that does not exist yet counts as empty, with exists: false: the capture creates it when it writes its first container. record is null when a failed container has no readable record beside it. record_problem then says why.

Only a build with the vcon feature registers this route.


GET /v1/vcon/fetch/:uuid

One vCon container from the operator’s vCon store, by uuid, returned in the answer. sipnab writes no file.

  • It connects to the store the [vcon_fetch] section of the config file configures, so it is off unless the run has --api-allow-vcon-fetch.
  • The store, its kind, the credential and the decryption key come from [vcon_fetch] only. The client gives the uuid and nothing else, and the answer never holds a key or the credential.
  • The request, the credential, the CA, the size limit, the decryption and the signer checks are the same as sipnab --vcon-fetch (Fetch a stored vCon).
  • The MCP tool fetch_vcon gives the same answer. Its section describes every field.
StatusWhen
200The container, with stored_form, decrypted, signature_verified, signer_sha256 and schema_findings
400The uuid is not 1 to 64 characters, each a letter, a digit or -. sipnab sends nothing
404The store answered 404
502Anything else the fetcher refuses: another status, a timeout, an answer over [vcon_fetch] max_size, another uuid inside, a key that does not decrypt, a signer that fails a check. detail says which, with the credential removed
503No --api-allow-vcon-fetch, or no [vcon_fetch] url; detail names the flag or the keys. Also the rate limit

curl:

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
  http://127.0.0.1:18080/v1/vcon/fetch/0158a120-0392-8751-9a04-9decf1485b22 \
  | jq -c 'del(.container)'

Response, from a run started with --api-allow-vcon-fetch and a [vcon_fetch] conserver store holding that container, container left out:

{"decrypted":false,"schema_findings":[],"schema_version":1,"signature_verified":false,"signer_sha256":null,"stored_form":"unsigned","uuid":"0158a120-0392-8751-9a04-9decf1485b22"}

Without the flag:

{"detail":"fetching a vCon from the store is off on this server: start sipnab with --api-allow-vcon-fetch, and configure the store in [vcon_fetch]","status":503,"title":"Service Unavailable","type":"https://sipnab.com/problems/unavailable"}

Only a build with the vcon feature registers this route.


GET /v1/relay/stats

Ask a media relay about itself walks through the six /v1/relay/ routes on a live relay.

The relay’s own global counters, tiered relay_reported.

  • It transmits once to the address --rtpengine-control names.
  • It sits 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/polled

The newest reading of the relay’s global counters that this run’s own --relay-stats-interval poll obtained, tiered relay_reported.

  • It sends nothing to the relay. It needs no --api-allow-relay-query.
  • Starting a poll is CLI only: a headless run started with --rtpengine-control and --relay-stats-interval polls, and this route reads what that poll obtained.
  • The reading has the form GET /v1/relay/stats returns, with origin: "polled", interval_secs, obtained_at and age_secs, the seconds since the relay answered.

outcome is one of three values:

  • ok with the reading,
  • no_reading_yet when the poll runs and has obtained nothing,
  • not_polling when this run does not poll.

last_poll names the newest attempt (unreachable or suspect, with at and detail) when it failed after the reading it sits beside. A reader that sees age_secs grow while last_poll is present is reading a poll that has stopped getting answers.

curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
  http://127.0.0.1:8080/v1/relay/stats/polled | jq '{outcome, age_secs, interval_secs}'

The MCP tool relay_stats_polled returns the same fields and values. Read the newest poll without asking again walks through it.

GET /v1/relay/stats/names

Which statistics the relay knows.

  • sipnab obtains them by asking the relay, not 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.
  • sipnab reports that as outcome: refused with the relay’s reason. It does not render it as counters.
curl -s -H "Authorization: Bearer $SIPNAB_API_KEY" \
  "http://127.0.0.1:8080/v1/relay/stats/call/1-7@203.0.113.9" | jq .

GET /v1/relay/compare/:call_id

The relay’s totals.RTP.packets for one call beside sipnab’s own measured count.

  • It names both tiers.
  • It carries a word verdict and a note.
  • The two are never summed.
  • A call this capture measured no RTP for reads outcome: not_configured naming the capture.
  • 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/1-7@203.0.113.9" | 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.
  • Otherwise it reads not_permitted/not_configured, 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.
  • That is the case during incident response.

These are 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,
  • the codec.

It uses the same gate and 200-classification rule as the list route. It TRANSMITS one control request. It is 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/1-7@203.0.113.9" | jq .

The call_ids, tags and ports are the relay’s own words. They come back raw, the values a program keys on.


Add operator notes to a capture

The REST API has no route that reads or writes operator notes. Add notes in the TUI, or write a pcapng copy of the capture that carries them with --notes <FILE> --write-annotated <OUTPUT>. sipnab does not read packet comments back from a capture, so no route shows them.

Status codes

CodeWhen
200Success
400Malformed request (e.g. invalid SSRC on /v1/streams/{id})
401Missing/invalid/expired/revoked bearer token
403A refused request. The causes: a Host header the server does not accept (see Which Host names the API answers); /v1/dialogs/{call_id}/vcon for a dialog that carries the run’s --content-deny-header; a /v1/tfps/ action or /v1/actions/revert when the operator has not enabled actions for REST, or when another token placed the ban and this one does not hold scope admin; an archive password sent from another host to /v1/captures/compare
404Unknown 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
408The handler ran past the 30-second per-request cap. Every route sits behind it, so no slow client holds a connection slot open
409POST /v1/tfps/unban or /v1/actions/revert for a ban sipnab did not place
413A request body larger than 1 MiB (see Bind address & connection limits)
422The request is well formed but sipnab does not act on it. The causes: a ban address or lifetime the rules do not allow; a vCon container that does not open, or whose critical list names an extension sipnab does not support (POST /v1/vcon/validate); an audio route for a call with nothing sipnab can decode; a locked or unreadable archive on /v1/captures/compare. detail names the reason
429An action rate limit, or too many wrong archive passwords from one client on /v1/captures/compare. The answer carries Retry-After
500The answer would not serialize. Documented on /v1/dialogs/{call_id}, its /report and /vcon variants, /v1/streams/{id} and /v1/report
502The toll-fraud prevention peer a /v1/tfps/ route asked exited non-zero, hung, or answered off the contract; detail carries its standard error
503Rejected by the rate limiter or the connection cap (these never answer 429), or a route this server was not started to serve: /v1/captures/compare without --api-file-root, /v1/vcon/fetch/:uuid without --api-allow-vcon-fetch and [vcon_fetch], an action while sipnab cannot use the actions journal. detail names the flag to add

Recipes (curl + jq)

Every recipe reads the server address from $API and the key from $KEY. Set both in the shell you run the recipes in. Without $KEY, every recipe except the health check returns 401.

  • Set the API address:

    API="http://127.0.0.1:8080"
    
  • Set your key:

    KEY="my-secret-token"
    

Each recipe below is a complete command. Run the one that answers your question.

Check that the server is up. /health is the one endpoint that takes no credential:

curl -fsS "$API/health"

List the failed dialogs:

curl -fsS "$API/v1/dialogs?state=Failed&limit=20" -H "Authorization: Bearer $KEY" | jq

List the dialogs from one user (from= is a regular expression):

curl -fsS "$API/v1/dialogs?from=alice&limit=20" -H "Authorization: Bearer $KEY" | jq

Get one dialog by Call-ID:

curl -fsS "$API/v1/dialogs/abc123@host" -H "Authorization: Bearer $KEY" | jq

Get the same dialog as a JSON call report:

curl -fsS "$API/v1/dialogs/abc123@host/report" -H "Authorization: Bearer $KEY" | jq

List only the streams already linked to a dialog:

curl -fsS "$API/v1/streams?orphaned=false" -H "Authorization: Bearer $KEY" | jq

List only the streams below a MOS threshold:

curl -fsS "$API/v1/streams?mos_below=3.5" -H "Authorization: Bearer $KEY" | jq

Read the aggregate counters and post-dial delay percentiles:

curl -fsS "$API/v1/stats" -H "Authorization: Bearer $KEY" | jq

Export every dialog to CSV, for a spreadsheet or a comparison with the switch’s own call records:

curl -fsS "$API/v1/dialogs?limit=1000" -H "Authorization: Bearer $KEY" \
  | jq -r '.dialogs[] | [.call_id, .method, .state, .from_user, .to_user, .duration_sec] | @csv'

Against tests/pcap-samples/sip-problem-call.pcap it prints:

"completed-9f8e7d@192.0.2.10","INVITE","Completed","alice","bob",20.01
"busy-3a2b1c@192.0.2.30","INVITE","Failed","carol","dave",1.81
"decline-7c6d5e@198.51.100.30","INVITE","Failed","erin","frank",1.81
"notfound-1b2c3d@203.0.113.30","INVITE","Failed","grace","heidi",1.81
"unavail-4e5f60@192.0.2.50","INVITE","Failed","ivan","judy",1.81

Print one line per stream with a poor MOS, for an alerting hook. It prints nothing while every stream is healthy:

curl -fsS "$API/v1/streams?mos_below=3.0" -H "Authorization: Bearer $KEY" \
  | jq -r '.streams[] | "LOW MOS: SSRC=\(.ssrc) MOS=\(.mos) call=\(.associated_dialog)"'

Count the dialogs by final response code, one count per call:

curl -fsS "$API/v1/aggregate?by=response_code" -H "Authorization: Bearer $KEY" \
  | jq -r '.buckets[] | "\(.count)  \(.value)"'
1  200
1  404
1  486
1  503
1  603

The REST API does not return individual SIP messages. For per-message data, use the CLI NDJSON mode: sipnab -N --json emits one record per message. See Output Formats.

Client examples

The website’s API Client Examples page has full end-to-end clients: https://sipnab.com/docs/api-clients/.

  • They cover bearer auth, pagination, /metrics scraping and error handling.
  • They are in curl, Python (sync + async), Node/TypeScript, Rust, and Go.

Security model

  • Every network listener binds to loopback by default.
  • When you configure a key or a signing key, every route except /health requires a bearer credential.
    • /metrics on the --api server takes the same credential.
    • The standalone --metrics server uses HTTP Basic instead.
    • Action routes need an actions token, and live-audio routes an audio token.
  • Rate limiting applies to every route except /health: 100 requests per second per source IP by default. --api-rate-limit-per-peer 0 removes it.
  • --api-max-conn caps the requests handled at once.
  • Key and token comparison runs in constant time.
  • Serve HTTPS with --api-tls-cert and --api-tls-key, or put a reverse proxy in front (see API TLS).

Note: The API runs as a thread in the sipnab process, not as a separate OS process. It does not expose TLS key material. It does expose call content: SIP messages inside vCon exports, and decoded audio when the run retains it (--retain-audio). Two routes change state: POST /v1/persistence, and the TFPS action routes when --allow-action enables them.

Choose the API bind address and protect the key accordingly.

GET /metrics

Prometheus-compatible metrics endpoint, documented on its own page: Prometheus Metrics.

  • The endpoints above return what sipnab saw: dialogs, streams, messages.
  • /metrics returns counters about sipnab itself, in the Prometheus text format, for a scraper to poll.
  • On the --api server it takes the same bearer credential as every other route. A metrics-scoped token reaches it and nothing else.