Docs Export a call as a vCon

Export a call as a vCon

Export one observed dialog as a vCon container: what the format is, how to produce one, and what an observer's record does and does not let a consumer conclude.

On this page

Something downstream wants the call rather than the packets. A conversation archive, a compliance store, an agent that reasons over calls: hand any of them a pcap and you have made the decoding their problem. vCon is the interchange container those systems already read, and sipnab writes one per observed dialog.

The short version. A vCon is a JSON object about one conversation — the parties, what passed between them, and what some tool concluded about it. sipnab fills that shape from signaling it watched go past a tap. Each container carries the SIP ladder, the parties the From and To headers named, a diagnosis of the call, and — in two places, deliberately — a statement of what the capture missed.

When the run retained the RTP payload, the container also carries the audio, inline. None of them carries a signature or any claim that somebody consented to anything.

A sipnab vCon records what an instrument saw, not what the parties said. What may you conclude, below, is the one part of this page to read before you trust a container.

What a vCon is, and what sipnab is to it

vCon — “Conversation Data Container” — travels between systems as four arrays: parties (who took part), dialog (what passed between them and when), analysis (what a machine concluded about that) and attachments (documents riding alongside).

The ecosystem around the format assumes a recorder — something inside the conversation, which took the media from a party and can say what that party agreed to. A recorder can write “I received this audio from the caller.”

sipnab was never inside the conversation. It reads a mirror port, so the strongest sentence it can honestly write is “I saw packets claiming to be this call go past this tap.” Those two sentences look alike in JSON and mean entirely different things.

So sipnab emits observer vCons. The container names sipnab as a passive party contributing to a record somebody else owns, which is a role the format itself defines, and it stops there. It never claims to be the conversation. The Phase 0 decision argues that position in full, including every refusal summarized further down this page.

Produce one

The export sits behind the non-default vcon Cargo feature, so a stock build does not carry it. The full feature includes it. On its own it looks like this:

cargo build --release --features vcon

The feature is non-default for the reason every capture-side feature is: a container that leaves the machine is a publication surface, and a capture tool should not grow one unless an operator asks for it.

sipnab builds a container from one dialog, and every surface that offers the export writes the same bytes. Pick the door that suits you:

You areAsk this wayAnswers with
At a shell, reading a capture filesipnab -N -I call.pcap --export-vcon 'CALL-ID' --vcon-out out.jsonthe container in out.json
At a shell, wanting it on stdoutsipnab -N -I call.pcap --export-vcon 'CALL-ID'the container on stdout
An agent holding an MCP session, one callthe export_vcon tool, with call_idone container, its SHA-256, and what the capture missed
An agent holding an MCP session, a setthe export_vcon tool, with filterone entry per matching dialog, bounded by --mcp-max-rows
An agent checking a container before sending itthe validate_vcon toola verdict against the working group’s draft-ietf-vcon-vcon-core-04 schema, with every finding named
A program over HTTPGET /v1/dialogs/{call_id}/vcon200 and the container, or 404
Rust, in-processsipnab::output::vcon::export_dialoga Vcon value

Two notes on the CLI pair. --vcon-out requires --export-vcon, because a path with nothing to write to it is a mistake sipnab would rather name than ignore. And sipnab refuses a --vcon-out that names the capture it is reading, because an export that overwrites its own evidence is not an export.

On a live capture (-d or --hep-listen), --export-vcon writes the call’s container when that call ends, on the same timing the spool below follows: once the call has ended and has been quiet for five seconds, while sipnab keeps capturing. A live capture that you stop writes nothing, so a call that had not ended and settled leaves no --vcon-out file behind. A live run that ends on its own (--duration, --autostop) writes the call then if it was not already written, and still fails if that Call-ID never appeared. With -I, sipnab writes the container at the end of the run, as before.

The TUI does not offer the export. It is a live view of a running capture, and it points at the doors above — its help screen names the REST route and the feature flag rather than growing a key binding that would do nothing in a build without the feature.

Read on for what the container means once you hold one.

The spool contract, for a bridge that consumes it

--export-vcon-dir is a queue, and it never runs alone. It pairs with --export-vcon-when, which names the dialogs to emit in the same expression language --filter speaks. Each flag requires the other, and --export-vcon-when conflicts with the single-call --export-vcon, because one writes a container per matching dialog and the other writes exactly one.

sipnab -N -d eth0 --export-vcon-when "state == 'Failed'" --export-vcon-dir /var/spool/vcon

The capture process never sends a container anywhere: the export path writes files and makes no outbound connection. Delivering them to a store is the job of a separate process that watches the directory. sipnab includes one, sipnab --vcon-forward, which Deliver the spool to a store describes, and any other program can do the same job. These are the guarantees a forwarder may rely on.

When a container appears depends on where the calls come from.

  • On a live capture (-d or --hep-listen), sipnab writes a matching call’s container while it keeps capturing. It checks every five seconds, and writes a call once the call has ended (Completed, Canceled, Failed, Redirected, Expired or Terminated) and has been quiet for five seconds. Expect the container about ten seconds after the call’s last message at the latest. If the call changes after that, for example a late 200 OK answers a canceled call, sipnab writes the container again under the same name.
  • A live capture that you stop (systemctl stop, SIGTERM, Ctrl-C, or an MCP client that goes away) writes nothing more. Calls that had not yet been written are not written on the way out, because stopping sipnab must leave no call data behind. A live run that ends on its own (--duration, --autostop) writes the matching calls the checks had not reached yet, and does not write a second copy of any container already written.
  • With -I, sipnab reads the whole capture and writes every container at the end, as before.

sipnab refuses --redact, --redact-map and --content-deny-tombstone on a live capture with --export-vcon-when, and --redact and --redact-map on a live capture with --export-vcon, because they write their files at the end of the run, and a stopped live run never gets there. Use them with -I.

A name resolves to a whole container, or to nothing. Every write stages the bytes under a dot-prefixed sibling, flushes them to the filesystem, and renames into place. A reader polling the directory therefore never sees a truncated container, and a write that fails leaves the previous one intact.

Staging is deliberately in the DESTINATION directory rather than in the system temp dir: rename is atomic only within one filesystem, and across a mount boundary it either fails outright or degrades into a copy, which puts the partial file back.

A staging file you can see is litter. sipnab writes it as .<container>.partial, so an ordinary listing and a *.json glob both skip it. A failed write removes it. One left behind means sipnab died mid-write, and it is safe to delete.

Names are stable, and sipnab reuses them. A container’s file name comes from its Call-ID, with an underscore replacing every character outside [A-Za-z0-9._-] and a leading dot, so no container name starts with one. The name ends in a hyphen, 16 hexadecimal characters of the SHA-256 of the Call-ID, and .vcon.json, for example 1-1966_10.0.2.20-1cc03f180ff8a774.vcon.json. Re-exporting the same dialog to the same directory overwrites its file rather than accumulating a second one, which is what makes the directory a queue and not a log.

Two dialogs whose Call-IDs differ only outside that character set, or only beyond the first 180 characters, still get two names: the readable part of the name can match, and the SHA-256 suffix differs.

With --redact, the name comes from the redacted Call-ID. sipnab names the file after the pseudonym the container carries in sip_call_id, so neither the original Call-ID nor the host address inside it appears in the spool, in the --vcon-digest lines, or in the forwarder’s delivered/ and failed/ directories and log lines. The name still matches the Call-ID inside the container. With the same --redact-key-file, a re-export produces the same name and overwrites the file.

A container is complete when it appears. There is no partial state, no lock file and no sentinel to wait for. Read it, forward it, delete it.

Deleting is the consumer’s job. sipnab never removes a container it wrote, so a spool nobody drains grows without bound.

--vcon-digest gives you something to reconcile against. It prints a SHA-256 of every container written, in sha256sum format, so sipnab ... --vcon-digest > SHA256SUMS and a later sha256sum -c SHA256SUMS both work with no glue. Deliberately not a signature and deliberately outside the container: a store adds fields on ingest, so a signature over sipnab’s bytes would fail against the object the store holds and tell an operator nothing.

Deliver the spool to a store

sipnab --vcon-forward is a second sipnab process, started on its own, that delivers the containers in an --export-vcon-dir spool to a vCon store over HTTP or HTTPS. It needs the vcon feature, like the export. It captures nothing: sipnab refuses --vcon-forward beside -d, -I, --hep-listen, a capture filter, a listener or an export flag, so the capture process keeps making no outbound connection.

Captures carry personal data, and a store belongs to another party. Decide what may leave the machine before you forward anything: --redact on the export replaces identities and addresses with keyed tokens, and What may you conclude lists what a container carries.

Run it

The forwarder needs two things: where the store is, and the credential that authenticates you. Give the store’s URL once, in a shell variable, and put the one header that authenticates you in a file that only you can read. The forwarder refuses the file when your group or other users can read it, by the same rule sipnab applies to its other secret files. The file holds one line, Header-Name: value:

# Run all of these, in order.
STORE_URL='http://127.0.0.1:8000/vcon/external-ingress?ingress_list=sipnab'
umask 077
printf 'x-conserver-api-token: %s\n' "$KEY" > vcon-forward.auth
sipnab --vcon-forward ./spool --vcon-forward-url "$STORE_URL" \
  --vcon-forward-auth-file vcon-forward.auth --vcon-forward-once
echo "exit $?"

$KEY is the key the store gave you. For a store that takes a bearer token, the line is Authorization: Bearer <token> instead. The value never appears in a log line, an error message or a failure record: the forwarder removes it from any store answer it keeps, even one that echoes the request back.

The credential can come from the environment instead of a file: SIPNAB_VCON_FORWARD_AUTH holds exactly what the file would hold. The --vcon-forward-auth flag takes the same value, but a flag’s value is visible in the process list to other users of the host, so prefer the variable or the file. The credential has one source: sipnab refuses the variable or the flag beside --vcon-forward-auth-file or [vcon_forward] auth_file, and refuses an empty one. A capture run ignores the variable.

# Run all of these, in order.
export SIPNAB_VCON_FORWARD_AUTH="x-conserver-api-token: $KEY"
sipnab --vcon-forward ./spool --vcon-forward-url "$STORE_URL" --vcon-forward-once

--vcon-forward-once makes one pass and exits: 0 when the store accepted every container, or the spool held none, 1 when the store refused one or one is still waiting, 2 when sipnab refuses a setting given on the command line, and 3 when the store refused the credentials or the client. A config file sipnab refuses exits 1, as on any run, and so does an [vcon_forward] auth_file that sipnab cannot read or that holds no credential. The same file named by --vcon-forward-auth-file exits 2. Without --vcon-forward-once the forwarder makes a pass every --vcon-forward-interval seconds (default 5) until SIGTERM or SIGINT. Stopping it means stopping: it sends nothing more after the signal, and a container it had not reached stays in the spool for the next run.

Choose the store kind

Stores differ in where they take a container, how they authenticate, what they answer for a uuid they hold, and which parts of the drafts they accept. --vcon-forward-kind (or [vcon_forward] kind) names the kind of store, and the kind supplies those values, so you give the store’s base URL and the bare key:

KindIngest path added to a base URLThe header a bare key goes inA uuid it holdsCopy sent
generic (default)none: the URL is the endpointnot accepted: give Header-Name: valuenot measuredunchanged
vcon-store/v1/vconsAuthorization: Bearer <key>409; set --vcon-forward-replace-url to PUT it insteadextensions as an object, as --vcon-forward-compat vcon-store does
conserver/vcon/external-ingress?ingress_list=sipnabx-conserver-api-token: <key>204, and the store replaces its copyunchanged

Each value is what the store did when measured on 2026-10-07. vcon.store: the measurements in Send sipnab’s vCons to vcon.store. conserver: a self-hosted vCon server, sent a synthetic container from sipnab’s test fixtures. It answered 204 for a new container, and GET /vcon/{uuid} then returned the container with an amended member added. It answered 204 for the same uuid sent again, and the read-back then showed the second copy. It answered 422 for a body that is not JSON and for one without uuid, and 403 with no key or a wrong one. Every kind counts any 2xx as delivered. The sipnab ingress list is the one Run sipnab with a vCon server creates.

An explicit setting overrides each value the kind supplies:

  • A URL that names a path is the endpoint as written; the kind adds its path only to a URL with none (https://api.vcon.store, not https://api.vcon.store/v1/vcons).
  • The forwarder sends a credential that is a full Header-Name: value line as written. The kind forms the header only from a bare key, so give a key that itself holds a colon as the full line.
  • --vcon-forward-compat (or [vcon_forward] compat) replaces the kind’s adaptation: none sends the container unchanged to a vcon-store kind, and vcon-store adapts it for a generic one. --vcon-forward-compat vcon-store means what it meant before kinds existed.
  • --vcon-forward-replace-url applies to any kind. No kind sets one: nobody has measured a PUT to vcon.store.
# Run all of these, in order.
STORE_URL=http://127.0.0.1:8000
umask 077
printf '%s\n' "$KEY" > conserver.key
sipnab --vcon-forward ./spool --vcon-forward-kind conserver \
  --vcon-forward-url "$STORE_URL" --vcon-forward-auth-file conserver.key \
  --vcon-forward-once

Keep the settings in sipnab.toml

Every forwarder flag but --vcon-forward, --vcon-forward-once and --vcon-forward-auth has a key in the [vcon_forward] section of the config file. A flag overrides its key, so a file holds the standing settings and a command line changes one for one run:

[vcon_forward]
kind = "conserver"
url = "http://127.0.0.1:8000"
auth_file = "/etc/sipnab/conserver.key"
done = "/var/spool/sipnab-vcon-sent"
failed = "/var/spool/sipnab-vcon-held"
interval = 5
sipnab --vcon-forward /var/spool/sipnab-vcon --config /etc/sipnab/vcon-forward.toml

sipnab checks each key by the rule its flag follows. A number out of range, an unknown kind or compat name, or a first back-off longer than the cap stops any run that loads the file. A URL or a credential the forwarder cannot use stops the forwarder at startup, naming the key.

Deliver to two stores

One forwarder delivers to one store, and it moves each container out of the spool once the store accepts it, so two forwarders cannot share one spool: the first would take every container from the second. To deliver to two stores, chain them. The second forwarder’s spool is the first one’s delivered directory, and each has its own delivered directory. Run each as a process of its own, in two terminals or as two services. The first delivers to a conserver:

sipnab --vcon-forward ./spool --vcon-forward-kind conserver \
  --vcon-forward-url http://127.0.0.1:8000 --vcon-forward-auth-file conserver.key \
  --vcon-forward-done ./spool/delivered

The second delivers what the first delivered to vcon.store:

sipnab --vcon-forward ./spool/delivered --vcon-forward-kind vcon-store \
  --vcon-forward-url https://api.vcon.store --vcon-forward-auth-file vcon-store.key \
  --vcon-forward-done ./spool/delivered/vcon-store

A container reaches the second store only after the first accepted it. A container the first refused stays in the first one’s failed directory and never reaches the second.

What it does with each container

The forwarder POSTs the file’s bytes unchanged, with Content-Type: application/json, User-Agent: sipnab/<version> and the header from the file, and then moves the file by the answer:

The store answersThe forwarder
2xxmoves the file to --vcon-forward-done, delivered/ in the spool by default, under its own name, and logs a delivered line
401 or 403stops. The answer refuses the credentials or the client, so every container would draw it: the forwarder sends nothing more, moves no file, logs one error line naming the status and the first 200 bytes of the answer, and exits 3, with or without --vcon-forward-once
409, with --vcon-forward-replace-urlPUTs the same bytes to that URL, with {uuid} replaced by the container’s uuid, and acts on that answer
any other 4xx, 409 included without a replace URLmoves the file to --vcon-forward-failed, failed/ in the spool by default, beside <name>.error.json, and logs one line naming the file and the status
5xx, a timeout, or no connectionleaves the file in the spool and tries it again after --vcon-forward-backoff-first seconds (default 2), then twice that, and so on up to --vcon-forward-backoff-cap seconds (default 300). The other containers are not held up

<name>.error.json holds the status, the method and URL, and the first --vcon-forward-max-error-body bytes (default 8192) of the store’s answer. An answer whose status line and headers exceed --vcon-forward-max-response-head bytes (default 65536) counts as no answer, and the container waits for a retry. The delivered and failed directories must be on the spool’s file system, so that every move is a rename.

The forwarder reads the spool by the contract above. It skips dot-prefixed names and names that do not end in .json, so it never reads a staging file. When sipnab rewrites a container under the same name because the call changed, the rewritten file is a new container to the forwarder, and it goes out again. That holds when the rewrite lands after delivery and when it lands while the send is in flight: the forwarder checks that the file it moves is the file it sent, and leaves a newer one in the spool for the next pass.

What it guarantees, and what it does not

  • A 2xx means the store accepted the container, not that it kept it. The forwarder logs delivered, never stored. A self-hosted conserver once answered 204 for a container it then failed to write, and nothing in the answer said so. To know the store kept a container, look in the store.
  • The bytes are sipnab’s. Without --vcon-forward-compat the forwarder never changes a container. With it, only the copy it sends changes, and the file on disk keeps the form the drafts define. Send sipnab’s vCons to vcon.store explains the one compat mode there is.
  • It does not retry a refusal. A 4xx other than a 401, a 403 or a 409 it can replace stays in the failed directory until a person reads the record and decides.
  • A refused credential stops it, and moves nothing. A wrong key draws a 401 or 403 for every container, so the forwarder stops at the first one and leaves the whole spool where it is. The log line quotes the start of the answer: a Cloudflare front that refuses the client answers 403 with error code: 1010. Fix the credential or the client, and start the forwarder again.
  • It does not delete anything. The delivered directory keeps a copy of every container, and every copy is call data. Delete them on a schedule that suits you.
  • It adds no signature and no consent. A store may sign what it receives. That signature is the store’s, over an observation, and sipnab records no consent.
  • It sends one container at a time, in name order, one connection each.

Fetch a stored vCon

sipnab --vcon-fetch is the forwarder’s counterpart: a separate sipnab process that reads containers back from a store by uuid and writes each to a file. It needs the vcon feature. Like the forwarder it captures nothing, and sipnab refuses it beside any capture source, listener or export flag, so the capture process keeps making no outbound connection.

Fetch one

Give the store’s base URL, its kind, and a file holding the key, readable only by you. The fetcher writes each container to <uuid>.vcon.json in --vcon-fetch-out:

# Run all of these, in order.
umask 077
printf '%s\n' "$KEY" > conserver.key
sipnab --vcon-fetch 018bcfe5-6800-8a6b-a667-78f1c5213800 \
  --vcon-fetch-kind conserver --vcon-fetch-url http://127.0.0.1:8000 \
  --vcon-fetch-auth-file conserver.key --vcon-fetch-out fetched
echo "exit $?"

$KEY is the key the store gave you. Give several uuids after --vcon-fetch, or - to read them from standard input, one per line:

sipnab --vcon-fetch - --vcon-fetch-kind conserver --vcon-fetch-url http://127.0.0.1:8000 --vcon-fetch-auth-file conserver.key --vcon-fetch-out fetched < uuids.txt

Each fetcher flag but --vcon-fetch, --vcon-fetch-out and --vcon-fetch-overwrite has a key in the [vcon_fetch] section of the config file, which the flag overrides. The CLI reference lists every flag.

The store kinds

KindRead path added after the path in the URLThe header a bare key goes inWhat the store wraps around the container
generic (default)none: the URL is a template holding {uuid}not accepted: give Header-Name: valuenothing
vcon-store/v1/vcons/{uuid}Authorization: Bearer <key>a top-level _meta member, which the fetcher removes
conserver/vcon/{uuid}x-conserver-api-token: <key>nothing
vcon-mcp/api/v1/vcons/{uuid}Authorization: Bearer <key>{"success": true, "vcon": {...}}; the fetcher keeps the vcon member

Sources:

  • vcon.store: its OpenAPI document, and a GET on 2026-10-09 that returned the container’s members and _meta.
  • conserver: api/api.py of the vCon server (vcon-dev/vcon-server).
  • vcon-mcp: src/api/routes/vcons.ts, src/api/rest-router.ts and src/api/auth.ts of vcon-dev/vcon-mcp.

A URL that names a path keeps it, and the kind’s read path follows it: for a conserver served under /api, give --vcon-fetch-url http://127.0.0.1:8000/api. The fetcher takes a URL holding {uuid} as written, for any kind.

What it does with each answer

The store answersThe fetcher
2xx with the containerwrites it to <uuid>.vcon.json, mode 0600, without the kind’s envelope, every other byte as the store sent it
2xx with a body larger than --vcon-fetch-max-size, not JSON, without the kind’s envelope, or holding another uuidwrites nothing for that uuid
404writes nothing for that uuid, and logs that the store holds none
401 or 403stops: asks for no further uuid, and exits 3
another status, a timeout or no connectionwrites nothing for that uuid, logs the status and the start of the answer with the credential removed, and goes on to the next uuid. It does not retry

The fetcher checks each container it writes against the vendored vCon schema. A container the schema refuses is still written, as the store holds it: it is the store’s record, and you need it to see what is wrong. The log line lists each finding, and the run exits 1. A container sent through vcon.store’s compat mode comes back in the store’s form, with extensions as an object, so the schema refuses it (measured on 2026-10-09).

A file that already exists is not replaced: the fetcher does not ask the store for that uuid, and the run exits 1. --vcon-fetch-overwrite replaces it. Neither form writes through a symbolic link.

Exit codes: 0 when the fetcher wrote every container and the schema accepts each, 1 when it wrote none for some uuid or the schema refuses one, 2 when sipnab refuses a setting given on the command line, 3 when the store refused the credential or the client. A config file sipnab refuses exits 1, as on any run.

Read what came back

Each file is one vCon, as JSON. sipnab does not open a vCon file as an input in this release: -I reads captures. Read the file with a JSON tool, for example jq '.parties, .dialog[].type' fetched/<uuid>.vcon.json.

Walk through one, end to end

The capture is tests/fixtures/sip_call.pcap, committed to this repository: one INVITE, a 100, a 180, a 200, the ACK, a BYE and its 200. Seven messages, one Call-ID, two parties.

Step 1 — check the build carries the feature

The committed integration test drives the whole export through the public API against that capture, so running it confirms both the feature and the fixture in one command:

cargo test --features vcon --test vcon_export_test
running 2 tests
test a_real_capture_exports_a_complete_signaling_only_container ... ok
test re_exporting_one_dialog_from_one_capture_keeps_its_identifier ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Step 2 — build the container

The shortest path is the committed example, which does everything below against that same capture and prints the container:

cargo run --features vcon --example export_vcon -- tests/fixtures/sip_call.pcap

Its source is examples/export_vcon.rs and it compiles as part of the build, so it cannot drift from the API the way a fragment on a page can.

In your own program: read the capture into a DialogStore the ordinary way — the library page covers that part — then hand one dialog and the run’s own counters to export_dialog:

use sipnab::analysis::CaptureFacts;
use sipnab::output::vcon::{ExportContext, export_dialog};

let dialog = store.get(&call_id).expect("the dialog is in the store");
let vcon = export_dialog(
    dialog,
    &ExportContext {
        // Derive this from the capture's CONTENT — a file name, a frame
        // digest. Never from a per-process identifier: the container's uuid
        // hashes this value, so a rotating one mints a fresh uuid every time
        // you reopen the same file.
        capture_id: "sip_call.pcap",
        // What the RUN saw outside this one dialog: frames read, SIP a port
        // gate discarded, dialogs the store rotated away.
        facts: &facts,
        // The ranked capture analysis, when the run did one. `None` is an
        // honest answer, and the container reports it as one rather than as
        // a clean bill.
        analysis: None,
        // `None` takes the default inline-media budget.
        max_inline_media_bytes: None,
        // The dialog's RTP quality, for the report's `media_quality`:
        // `sipnab::output::vcon::media_quality_for(&streams, &call_id)` builds
        // it from a `StreamStore`. Empty leaves the key out.
        media: &[],
    },
);
println!("{}", vcon.to_json().expect("the container serializes"));

Step 3 — read what came out

Real output from that fixture, elided only where the message trace repeats itself. Every value below came off a run against the committed capture.

One reading convention, stated once. Every body is a JSON-encoded string on the wire — section 2.3.2 of the core draft allows nothing else, and a store normalizes it to one anyway. The blocks below show each body decoded, as an object, because an escaped one-line string is unreadable on a page. What sipnab actually writes for the first one is "body": "{\"messages\":[…]}", and a consumer parses it before indexing into it.

{
  "vcon": "0.4.0",
  "uuid": "018bcfe5-6800-8a6b-a667-78f1c5213800",
  "created_at": "2026-08-24T17:05:12+00:00",
  "extensions": [
    "sip-signaling",
    "CC"
  ],
  "parties": [
    {
      "sip": "sip:1001@192.0.2.1",
      "validation": "none",
      "sip_contact": "<sip:1001@192.0.2.1:5060>",
      "sip_user_agent": "sipnab-test/1.0"
    },
    {
      "sip": "sip:1002@192.0.2.2",
      "validation": "none"
    },
    {
      "validation": "none",
      "role": "observer",
      "sip_user_agent": "sipnab/0.5.124 (observer; node capture-01)"
    }
  ],
  "dialog": [
    {
      "type": "recording",
      "sip_call_id": "test-call-1@192.0.2.1"
    }
  ],
  "attachments": [
    {
      "purpose": "sip-message-trace",
      "party": 2,
      "mediatype": "application/json",
      "encoding": "json",
      "body": {
        "messages": [
          {
            "call_id": "test-call-1@192.0.2.1",
            "contact": "<sip:1001@192.0.2.1:5060>",
            "cseq": {
              "method": "INVITE",
              "number": 1
            },
            "dscp": 0,
            "dst": "192.0.2.2",
            "dst_port": 5060,
            "frame": "…/tests/fixtures/sip_call.pcap#0@4ed7f4560fa79e74",
            "from": "<sip:1001@192.0.2.1>;tag=1928301774",
            "is_request": true,
            "method": "INVITE",
            "schema_version": 1,
            "sdp": "",
            "src": "192.0.2.1",
            "src_port": 5060,
            "timestamp": "2023-11-14T22:13:20+00:00",
            "to": "<sip:1002@192.0.2.2>",
            "transport": "UDP",
            "ua": "sipnab-test/1.0"
          }
          … ELIDED: the 100, the 180, the 200, the ACK, the BYE and its 200,
          six more objects of the same shape …
        ],
        "schema_version": 1,
        "sip_call_id": "test-call-1@192.0.2.1"
      }
    },
    {
      "purpose": "sipnab-capture-completeness",
      "party": 2,
      "mediatype": "application/json",
      "encoding": "json",
      "body": {
        "dialogs_refused": 0,
        "dialogs_rotated": 0,
        "frames_read": 22,
        "messages_evicted": 0,
        "node": "capture-01",
        "note": "Produced by sipnab 0.5.127 on node capture-01. sipnab OBSERVED this dialog and took no part in it: the parties below are what the From and To headers said, not identities anyone established, and nothing here is signed. This container carries SIGNALING ONLY — no media, and no reference to media held elsewhere. sipnab read 22 frame(s) for this capture. No omissions recorded: every message sipnab held for this dialog is in this container. No capture-level analysis was supplied for this export, so nothing here rules out a blind spot.",
        "sip_discarded_by_port_gate": 0,
        "sip_discarded_by_websocket_gate": 0,
        "sipnab_version": "0.5.124",
        "undecodable_frames": 0
      }
    }
  ],
  "analysis": [
    {
      "type": "report",
      "dialog": 0,
      "vendor": "sipnab",
      "product": "sipnab 0.5.160 (passive observer; not a recording system)",
      "schema": "sipnab-dialog-diagnosis/1",
      "mediatype": "application/json",
      "encoding": "json",
      "body": {
        "capture_completeness": {
          … ELIDED: byte for byte the completeness body above …
        },
        "final_status_code": 200,
        "schema_version": 1,
        "sip_call_id": "test-call-1@192.0.2.1"
      }
    }
  ]
}

Four things in that output repay a second look.

Three parties for a two-party call. The last entry is sipnab. It carries no sip URI, because sipnab sent no SIP and a synthesized one would name a participant that never existed. Its role says observer, and both attachments point their party index at it — index 2 — so every document in the container has a stated origin.

The callee has fewer fields than the caller. Party 1 carries a sip and nothing else. sipnab reads a party’s Contact and User-Agent off a message that party sent, and in this capture the first message coming back is a bare 100 Trying that carries neither. An absent field means the capture never saw one, not that the endpoint has none.

The Dialog Object describes no media, and that is correct here. This run retained no RTP payload, so there is nothing to describe and inventing a mediatype or a url would name a file that does not exist. The object’s type is recording with no content — the placeholder of the type rule below — and it has no disposition, because the call in this fixture completed and nothing about it failed.

The completeness body appears twice. Once as an attachment, once inside the report. The next section is about why.

The container cannot say “this is an incomplete record”, so sipnab says it twice

This is the point of the page.

sipnab’s central discipline is reporting what it understood rather than what the wire held. Its ranked problem list puts incompleteness findings in the list at Severity::Blind, above every call fault, so a capture that failed to decode or hit a retention cap can never render as clean.

vCon has no field for that. Not a weak one — none. The type enum admits five values and no others: recording, recording-set, text, transfer, incomplete. Four of the five promise content the object holds, and the fifth names a call that “failed to be setup” — a claim about the conversation rather than about the object.

So sipnab types the object by what it carries and by the one outcome it can observe, a final failure:

The object holdstypedisposition
audiorecordingabsent — it is an incomplete field
no content, no observed failurerecording, with no contentabsent
no content, an observed final failureincompletethe reason, always

The middle row is the placeholder of section 4.3 of the core draft, for a dialog the producer knows occurred but holds little or nothing from. Such a Dialog Object “contains only the type parameter”, and for a call that reached setup its type is recording. Section 4.3.8 waives mediatype when the content is absent. “No observed failure” covers an answered call, a call whose final response the capture never saw, and a redirect. sipnab cannot tell from the wire whether the second reached setup, and it does not report a failure it did not see.

Reaching for incomplete in that row is the mistake sipnab shipped until 0.5.128, and it is not a matter of taste — it made every container for a call that answered assert a setup failure that never happened.

Changed in this release, for draft-ietf-vcon-vcon-core-04. Until now that row had no type at all, because draft-ietf-vcon-vcon-core-03 allowed “a Dialog Object with no parameters in it”. -04 removed that sentence and requires type on every Dialog Object, so a container for a call with no media now carries "type": "recording" and no body or url. A consumer that selects type == "recording" and then reads the content must check that the content is there. Measured in the vcon-server conserver source at commit 8ffbfcf: the deepgram_link transcription link reads dialog["url"] with a bracket and raises on such an object, and the hugging_face_whisper and groq_whisper links read dialog["duration"] the same way. The type-free object raised in all four transcription links, openai_transcribe included, at dialog["type"].

The last two rows move together. Section 4.3.11 of the core draft makes disposition a MUST on an incomplete object, and no value in its closed set means “not observed”, so one decision fixes both fields. disposition names a failure only when sipnab saw the final response that caused it, and its absence never means “the call succeeded”.

The extension mechanism offers no repair either. An extension is ignorable, in which case the consumer that most needs the caveat drops it, or fatal, in which case an ordinary consumer refuses the whole container. Nothing in between means “read this before trusting the contents”.

So sipnab duplicates the caveat into two surfaces a consumer walks past, from one source value:

SurfaceWhere it sitsWho reads it
capture_completeness, inside analysis[0].bodyinside the report, beside the diagnosisanything that reads what sipnab concluded
the sipnab-capture-completeness attachmenta document in attachments, attributed to the observeranything that walks the attachments

Both hold the same value byte for byte, and a test fails if they diverge. Two caveats that disagreed would read as authoritative while contradicting themselves, which is worse than carrying none.

What the caveat looks like when the run lost something

Same capture, same dialog, a run whose parser rejected three frames and whose idle compaction discarded four messages it had already captured:

{
  "purpose": "sipnab-capture-completeness",
  "party": 2,
  "mediatype": "application/json",
  "encoding": "json",
  "body": {
    "dialogs_refused": 0,
    "dialogs_rotated": 0,
    "frames_read": 22,
    "messages_evicted": 4,
    "node": "capture-01",
    "note": "Produced by sipnab 0.5.127 on node capture-01. … sipnab read 22 frame(s) for this capture. — INCOMPLETE: 3 frame(s) reached the parser and produced nothing, so any count in this container is a floor. — INCOMPLETE: idle compaction discarded 4 message(s) sipnab had already captured, so the trace in this container may be shorter than the call was. Raise [limits] keep_messages_per_idle_dialog to hold more. No capture-level analysis was supplied for this export, so nothing here rules out a blind spot.",
    "sip_discarded_by_port_gate": 0,
    "sip_discarded_by_websocket_gate": 0,
    "sipnab_version": "0.5.124",
    "undecodable_frames": 3
  }
}

Elided at the … only: the fixed preamble is identical to the clean run’s. Read the shape rather than the wording. Every clause is a measurement of this run, and none of them says the call was short, silent or broken, because none of that follows from a capture that missed something. messages_evicted: 4 and the sentence naming it move together, so a consumer that reads fields reaches the same verdict as one that reads prose.

Note the last clause in both runs: no capture analysis reached this export, so the note says so. “Nobody checked” and “checked and found nothing” are different answers, and the container keeps them apart — an analysis that ranked nothing emits blind_spots: [], while an export that skipped the analysis omits the field entirely.

When a container is missing on purpose

Two of the fields in that block report a decision rather than a capture fault, and the note says so in words. Every other clause describes something the run failed to see. These two describe something an operator chose, and a reader who cannot tell them apart goes looking for a fault that does not exist.

FieldWhat it means
gate_closed_during_runAn operator stopped this run writing content partway through, over POST /v1/persistence. Containers are absent on one side of a hole this capture does not otherwise record.
dialogs_suppressed_by_denyHow many dialogs carried the --content-deny-header name and produced no container.

--content-deny-header suppresses the whole dialog, not merely its content, and the default is the conservative reading: a denied dialog leaves this process entirely. --content-deny-tombstone makes the narrower reading available — an identity-only container carrying a redacted object (section 4.1.8 of the core draft), with no message trace, no media and no bodies. The trade is explicit, because a tombstone reveals that the call existed. Leave it off when the header means “this call must leave no trace”.

Both are always present, including as false and 0. A missing key and “nothing happened” are the same fact here, and there is no reason to make a consumer distinguish them.

The absence is what makes them necessary. A container written after recording stopped looks exactly like one from a run where those calls never happened, so a reader comparing the set against a switch’s records concludes sipnab missed them. The same goes for a deny flag: the containers that DID get written read as the complete set for their predicate. Neither fact survives in the capture itself, so the container carries it.

Suppression narrows and never widens. A header asking sipnab to RECORD is an assertion by whoever sent the request, and this tool refuses that class of claim — there is no permit flag, and no REST call can enable persistence a command line did not authorize. See docs/rest-api.md for the runtime gate.

Someone handed you a sipnab vCon — what may you conclude?

Read the container as evidence from an instrument, not as a record from a participant.

What you seeWhat it licenses you to sayWhat it does not
parties[].sipthe From or To header carried this URIthat anyone with that URI took part
parties[].sip_display_namethe sender wrote this string in its headerthat this names a person
validation: "none" on every partynobody checked anything about this partythat a check failed — the field never carries another value
a role: "observer" partythis container came from a tapthat the tap was in the media path
attachments[].partythe observer contributed this documentthat a participant did
the message tracethese messages reached sipnab’s parserthat they are all the messages
analysis[0].bodysipnab’s diagnosis of what it helda diagnosis of the call
analysis[0].body.media_qualityeach RTP stream’s MOS and R-factor, computed from the packets sipnab read, with mos_grounded saying whether the MOS rests on a published impairment valuethat a listener heard that quality
an absent fieldthe capture did not carry itthat the call lacked it
a dialog[] object with no media fieldsthis export carries no mediathat the call had none
parties on a recording with a bodythe stream on each channel came from the address and port the named party advertised in its own SDPthat the party spoke, or that the channel holds only that party’s audio
a recording with a body and no partiesat least one channel came from an address and port no party advertised in its SDP, such as a media relay’sthat nobody was on that channel

Three rules follow from that table.

A missing thing is a missing observation. Every absence in a sipnab vCon describes the capture. It never describes the conversation. That distinction is the whole reason the completeness caveat exists, and a consumer that collapses it turns a configuration choice on a capture host into a claim about somebody’s call.

sipnab signs nothing, so cryptography attributes nothing here. Trust the container exactly as much as you trust whoever handed it to you.

Two containers about one call do not merge themselves. sipnab writes the parties of the dialog it observed and infers none. One tap on a proxied call sees two legs of one conversation or one leg of three, and a second tap that saw the other leg writes its own container. Reconciling them belongs to the consumer, because the consumer is the only one holding both.

What sipnab refuses to put in a container

Each refusal has an argument behind it in the design decision. The one-line version:

RefusedWhy
a JWS signaturea signature over an observation verifies as a signature over a recording
JWE encryptionit asserts a custody relationship sipnab does not have
consent attachmentssipnab obtained no consent, and an empty consent field reads as “none recorded”
lawful-basis attachmentsthe same, with a named regulatory reader on the other end
a party name that sipnab vouches forFrom and To are the caller’s claim about the caller, trivially spoofed
hosted artefacts by urlsipnab hosts nothing, so it cannot promise where a file lives

sipnab EMITS a party name when the wire carried one. This page previously said the Rust type has no name field, which was wrong: Party::name exists and carries the display name from From or To, alongside — never instead of — sip_display_name. It travels under the declared key so a generic vCon reader shows a named party rather than an anonymous one.

What sipnab refuses is not the field but the claim: validation is unconditionally "none", which says a header asserted this name and nobody checked it.

Treat it as personal data. A display name is the caller’s own words about who they are, and a container is a thing operators forward. If you are writing a redaction step, key it on name AND sip_display_name — an earlier version of this page would have led you to redact only the second.

The message trace carries the headers, and strips the credentials

Each message in the sip-message-trace attachment carries a headers object: the header name, and an array of its values so a repeated Via or Record-Route keeps the path the message actually took.

Four headers never travel — Authorization, Proxy-Authorization, WWW-Authenticate and Proxy-Authenticate — because a digest challenge and its response are credential material and a container is a publication surface.

That filter is worth one honest sentence: until 0.5.125 it did nothing, because the trace carried no raw headers for it to find. The test guarding it passed for that reason rather than because the filter worked. Headers and the filter that makes them safe to publish landed together, deliberately, and the same test now fails if anyone drops either half.

Everything else on the wire is in the container. Decide whether that is acceptable for your traffic before you hand one to anybody. P-Asserted-Identity, Diversion, Remote-Party-ID and any custom header your platform sets all travel, exactly as the endpoint wrote them.

One thing sipnab does supply, narrowly: tel

A conserver indexes parties by tel, mailto and name and by nothing else, so a consumer can retrieve a container carrying none of the three by its UUID and by nothing else — a party search never surfaces it. An operator who knows only the phone number cannot find the capture.

tel is the only one of the three sipnab can supply from evidence, and it supplies it only when the SIP user part is unambiguously a telephone number: + followed by digits, an RFC 3966 global number. Everything else gets nothing, including bare digit runs — 1001 is an extension, and indexing it as a telephone number would put a wrong answer in a search index rather than no answer. The observer party never carries one at all.

The tags that tell forked legs apart

The dialog object carries sip_from_tag and sip_to_tag when the capture observed them. A Call-ID alone cannot distinguish one leg of a forked INVITE from another — every fork shares it — so a consumer correlating legs across nodes needs the tags. sip_to_tag is the To tag of the response that established the dialog: a 2xx answer to the opening request, or, when the capture holds none, a 101-199 response to it. sipnab falls back to a tag from a response that establishes no dialog, such as a 401 or 407 challenge, only when the capture holds neither, and an answer under another tag replaces it. RFC 3261 section 12.1 is the rule. sip_to_tag is absent when no message carried a To tag, which is itself a signal: nothing established a dialog.

What this does not do

Stated here rather than discovered later.

  • It does not link to audio held elsewhere. When the run retained the RTP payload the container carries the audio INLINE, as a recording Dialog Object with a sha512- content hash. When it did not, the container says so in words and carries none. There is never a url, because sipnab hosts nothing and cannot promise where a file lives tomorrow.

    Audio over the inline budget draws an out-loud refusal rather than a silent truncation, and --vcon-max-inline-media sets that budget in MiB. It defaults to 5, a figure measured against a real vCon store that answered 204 for a container of roughly 12 MB, wrote it to its database, and had its own file spool refuse the payload with neither side reporting the partial write. 0 refuses every inline body without turning the exporter off.

    A --redact export carries no audio at all, because redaction deletes audio rather than replacing it with a token. Its Dialog Object is the signaling one, typed by the rule above, and capture_completeness.media says withheld-by-redaction.

    Every door — batch export, REST and MCP — reads the one value, so the same call cannot come back carrying audio through one and a refusal through another.

  • It does not tie the two halves of a B2BUA call together. Two Call-IDs produce two containers, and nothing in either one links them.

  • It does not assemble a call across hops. One container describes one observed dialog from one capture.

  • It does not deduplicate across taps. Two sipnab boxes that saw the same dialog mint different uuids, because the uuid mixes in the node and the capture identity along with the Call-ID.

  • It does not survive as a stable identifier across dialogs that opened in the same millisecond on one node. The uuid layout spends its entropy on the node, leaving 12 bits to separate same-millisecond dialogs. Deduplication on uuid is safe for re-exports of one dialog, which is what it exists for.

  • It does not store anything. sipnab writes a container and forgets it. No vCon store, no index, no retention.

See also

  • The Phase 0 decision — what sipnab may put in a vCon, what it refuses to, and the structural gap in the format that decides both
  • vCon internals — for anyone changing the exporter
  • Output formats — the NDJSON and report surfaces the message trace shares its projection with
  • Library API — filling a DialogStore from a capture