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
- What a vCon is, and what sipnab is to it
- Produce one
- The spool contract, for a bridge that consumes it
- Deliver the spool to a store
- Fetch a stored vCon
- Walk through one, end to end
- The container cannot say “this is an incomplete record”, so sipnab says it twice
- Someone handed you a sipnab vCon — what may you conclude?
- What sipnab refuses to put in a container
- What this does not do
- See also
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 are | Ask this way | Answers with |
|---|---|---|
| At a shell, reading a capture file | sipnab -N -I call.pcap --export-vcon 'CALL-ID' --vcon-out out.json | the container in out.json |
| At a shell, wanting it on stdout | sipnab -N -I call.pcap --export-vcon 'CALL-ID' | the container on stdout |
| An agent holding an MCP session, one call | the export_vcon tool, with call_id | one container, its SHA-256, and what the capture missed |
| An agent holding an MCP session, a set | the export_vcon tool, with filter | one entry per matching dialog, bounded by --mcp-max-rows |
| An agent checking a container before sending it | the validate_vcon tool | a verdict against the working group’s draft-ietf-vcon-vcon-core-04 schema, with every finding named |
| A program over HTTP | GET /v1/dialogs/{call_id}/vcon | 200 and the container, or 404 |
| Rust, in-process | sipnab::output::vcon::export_dialog | a 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 (
-dor--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 late200 OKanswers 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:
| Kind | Ingest path added to a base URL | The header a bare key goes in | A uuid it holds | Copy sent |
|---|---|---|---|---|
generic (default) | none: the URL is the endpoint | not accepted: give Header-Name: value | not measured | unchanged |
vcon-store | /v1/vcons | Authorization: Bearer <key> | 409; set --vcon-forward-replace-url to PUT it instead | extensions as an object, as --vcon-forward-compat vcon-store does |
conserver | /vcon/external-ingress?ingress_list=sipnab | x-conserver-api-token: <key> | 204, and the store replaces its copy | unchanged |
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, nothttps://api.vcon.store/v1/vcons). - The forwarder sends a credential that is a full
Header-Name: valueline 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:nonesends the container unchanged to avcon-storekind, andvcon-storeadapts it for agenericone.--vcon-forward-compat vcon-storemeans what it meant before kinds existed.--vcon-forward-replace-urlapplies to any kind. No kind sets one: nobody has measured aPUTto 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 answers | The forwarder |
|---|---|
2xx | moves the file to --vcon-forward-done, delivered/ in the spool by default, under its own name, and logs a delivered line |
401 or 403 | stops. 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-url | PUTs 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 URL | moves 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 connection | leaves 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
2xxmeans the store accepted the container, not that it kept it. The forwarder logsdelivered, neverstored. A self-hosted conserver once answered204for 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-compatthe 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
4xxother than a401, a403or a409it 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
401or403for 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 answers403witherror 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
| Kind | Read path added after the path in the URL | The header a bare key goes in | What the store wraps around the container |
|---|---|---|---|
generic (default) | none: the URL is a template holding {uuid} | not accepted: give Header-Name: value | nothing |
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
GETon 2026-10-09 that returned the container’s members and_meta. - conserver:
api/api.pyof the vCon server (vcon-dev/vcon-server). - vcon-mcp:
src/api/routes/vcons.ts,src/api/rest-router.tsandsrc/api/auth.tsofvcon-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 answers | The fetcher |
|---|---|
2xx with the container | writes 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 uuid | writes nothing for that uuid |
404 | writes nothing for that uuid, and logs that the store holds none |
401 or 403 | stops: asks for no further uuid, and exits 3 |
| another status, a timeout or no connection | writes 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 holds | type | disposition |
|---|---|---|
| audio | recording | absent — it is an incomplete field |
| no content, no observed failure | recording, with no content | absent |
| no content, an observed final failure | incomplete | the 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:
| Surface | Where it sits | Who reads it |
|---|---|---|
capture_completeness, inside analysis[0].body | inside the report, beside the diagnosis | anything that reads what sipnab concluded |
the sipnab-capture-completeness attachment | a document in attachments, attributed to the observer | anything 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.
| Field | What it means |
|---|---|
gate_closed_during_run | An 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_deny | How 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 see | What it licenses you to say | What it does not |
|---|---|---|
parties[].sip | the From or To header carried this URI | that anyone with that URI took part |
parties[].sip_display_name | the sender wrote this string in its header | that this names a person |
validation: "none" on every party | nobody checked anything about this party | that a check failed — the field never carries another value |
a role: "observer" party | this container came from a tap | that the tap was in the media path |
attachments[].party | the observer contributed this document | that a participant did |
| the message trace | these messages reached sipnab’s parser | that they are all the messages |
analysis[0].body | sipnab’s diagnosis of what it held | a diagnosis of the call |
analysis[0].body.media_quality | each 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 value | that a listener heard that quality |
| an absent field | the capture did not carry it | that the call lacked it |
a dialog[] object with no media fields | this export carries no media | that the call had none |
parties on a recording with a body | the stream on each channel came from the address and port the named party advertised in its own SDP | that the party spoke, or that the channel holds only that party’s audio |
a recording with a body and no parties | at least one channel came from an address and port no party advertised in its SDP, such as a media relay’s | that 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:
| Refused | Why |
|---|---|
| a JWS signature | a signature over an observation verifies as a signature over a recording |
| JWE encryption | it asserts a custody relationship sipnab does not have |
| consent attachments | sipnab obtained no consent, and an empty consent field reads as “none recorded” |
| lawful-basis attachments | the same, with a named regulatory reader on the other end |
a party name that sipnab vouches for | From and To are the caller’s claim about the caller, trivially spoofed |
hosted artefacts by url | sipnab 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
recordingDialog Object with asha512-content hash. When it did not, the container says so in words and carries none. There is never aurl, 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-mediasets that budget in MiB. It defaults to 5, a figure measured against a real vCon store that answered204for 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.0refuses every inline body without turning the exporter off.A
--redactexport 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, andcapture_completeness.mediasayswithheld-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
uuidis 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
DialogStorefrom a capture