Skip to content

Managing CDRs

This page covers the operational side of CDRs: pulling records for billing and troubleshooting, exporting them in bulk, and erasing them for retention compliance. Read How CDRs Work first for the call/leg model and the field meanings — the commands below make much more sense knowing that a “call” is a header with leg children.

Conventions as usual: $VSS, $TOKEN, $NSID ($PTID for partition-wide work) for curl; an active vsscli profile. CDR commands live under vsscli cdrs … (alias cdr).

Three knobs are shared by every query below:

  • Scope — the active namespace by default; --include-children (curl: ?include_children=true) widens to the whole descendant subtree; --partition (curl: the /v1/pt/{ptid}/… variants) fans out over every namespace in a partition. Bare --partition uses your token’s partition, --partition=<ptid> targets one explicitly.
  • Range--from/--to accept RFC3339 or unix seconds; --since takes a duration (24h, 7d). Default: the last 24 hours.
  • Pagination — an opaque cursor. Each page returns next_cursor; pass it back (--cursor) for the next page, or let --all follow the cursor to the end. Page size: --limit, server default 100, cap 1000.
Action At namespace scope At partition scope
cdr:read partition-admin, namespace-admin, operator, editor, viewer, system-auditor, billing, cdr-compliance partition-admin, operator, editor, viewer, system-auditor, billing, cdr-compliance
cdr:purge partition-admin, cdr-compliance only partition-admin, cdr-compliance only

Note the deliberate asymmetry: namespace-admin reads CDRs but cannot purge them — erasure stays with the partition owner and the dedicated cdr-compliance role, mirroring how namespace deletion is withheld. Give your billing system a service account with the billing role (read-only, no switch access at all); give your retention job cdr-compliance.

The calls view returns headers with their legs nested — the right shape for “show me what happened”:

Terminal window
curl -s "$VSS/v1/ns/$NSID/cdrs?from=2026-07-11T00:00:00Z&limit=50" \
-H "Authorization: Bearer $TOKEN"
# → {"success":true,"calls":[{"call_ulid":"01K...","direction":"outbound",
# "started_at":"…","ended_at":"…","completed":true,
# "legs":[{"leg_uuid":"…","leg_role":"inbound-device",
# "disposition":"answered","billable_ms":184220, …}, …]}],
# "next_cursor":"eyJlcyI6..."}

Calls are returned newest-first. A call whose completed is false is live right now — open records are visible the moment a call starts.

The flat legs view is the workhorse for billing files and targeted searches. It supports a substring filter: any subset of the leg’s string fields, each matched case-insensitively as a substring, ANDed together. Filterable keys are the leg columns — sip_source_user, sip_dialed_user, callerid_number, route_trunk, disposition, direction, leg_role, attestation, sip_call_id, and the rest of the string fields on the record.

Terminal window
# Who dialed 3055551234 this week? Filters are plain query parameters
curl -s "$VSS/v1/ns/$NSID/cdrs/legs?from=2026-07-05T00:00:00Z&sip_dialed_user=3055551234&direction=outbound" \
-H "Authorization: Bearer $TOKEN"
# Same query as CSV (note the Accept header)
curl -s "$VSS/v1/ns/$NSID/cdrs/legs?from=2026-07-05T00:00:00Z&sip_dialed_user=3055551234" \
-H "Authorization: Bearer $TOKEN" -H "Accept: text/csv" -o legs.csv

The filter can also be sent as a JSON body of the same keys (handy when an integration builds it programmatically); when both are present, a query parameter wins over the body’s value for that key. In CSV mode the next-page cursor arrives in the X-Next-Cursor response header instead of the body.

For pulling more than a few thousand rows, prefer the bulk export below — it’s resumable and doesn’t re-run the scan per page.

GET …/cdrs/{id} takes either identifier — a call ULID returns that call; a leg UUID returns the call(s) containing that leg (two when the leg straddles a namespace transfer). Transfer links are followed one hop by default, nesting the counterpart as linked_call:

Terminal window
curl -s "$VSS/v1/ns/$NSID/cdrs/01K02X5RJ8Q9Z3F7M6T1V4W8YA" \
-H "Authorization: Bearer $TOKEN"
# Without link traversal
curl -s "$VSS/v1/ns/$NSID/cdrs/01K02X5RJ8Q9Z3F7M6T1V4W8YA?follow_links=false" \
-H "Authorization: Bearer $TOKEN"

A record is only addressable from its own namespace — asking a namespace for a sibling’s record is a 404, and a transfer counterpart’s body is only nested when your grant also covers its namespace.

For end-of-cycle billing across every tenant at once, the same three reads exist at partition scope, fanning out over every namespace the partition owns:

Terminal window
curl -s "$VSS/v1/pt/$PTID/cdrs/legs?from=2026-07-01T00:00:00Z&to=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer $TOKEN" -H "Accept: text/csv" -o july.csv

These are scan-heavy, infrequent-use endpoints — a partition query walks every namespace in the date range. For the monthly billing pull, use the bulk export instead.

The export facility is built for the “give me last month, all of it” job: it opens a server-side download session that freezes the query (range, scope, filter, format), then streams the result in ~100 MB chunks. The server tracks the last chunk you acknowledged, so a dropped transfer resumes where it left off instead of starting over. Formats: NDJSON (default) or CSV. Sessions expire after 48 idle hours.

Terminal window
# One file for the whole month, partition-wide
vsscli cdrs export --partition --from 2026-06-01T00:00:00Z \
--to 2026-07-01T00:00:00Z --format csv --out june.csv
# One JSON file per leg, for an archive bucket (NDJSON only)
vsscli cdrs export --since 30d --out-dir ./cdr-archive/
# The export prints its download id when it opens; resume a dropped one:
vsscli cdrs export --resume 4c1f2a3e-… --out june.csv
# Inspect / cancel sessions
vsscli cdrs downloads list
vsscli cdrs downloads abort 4c1f2a3e-…

Everything about export requires only cdr:read — it is a read, not an administrative action. Partition-wide exports use the /v1/pt/{ptid}/cdr-downloads variants (vsscli: --partition).

The range purge permanently erases every CDR whose event time falls in the window — the enforcement half of a retention policy, and the erasure tool for compliance requests. It requires the cdr:purge action (partition-admin or cdr-compliance only), an explicit to cutoff (a purge with no to is refused with a 400 — the read endpoints’ “last 24 hours” default would erase the newest records), and a confirmation body; without one the API answers 412 echoing the exact date window it would erase, plus an example body to re-send. vsscli builds the confirmation for you after an interactive confirm that shows the same window:

Terminal window
curl -s -X DELETE "$VSS/v1/ns/$NSID/cdrs?to=2025-07-12T00:00:00Z&from=2020-01-01T00:00:00Z" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"confirm_id\":\"$NSID\",\"confirm_timestamp\":$(date +%s)}"
# → {"success":true,"legs_deleted":184223,"calls_deleted":91456}

confirm_id must equal the namespace (or partition) id in the URL, and the timestamp must be within an hour of server time — a stale script can’t accidentally re-fire.

There is no undo. Export before you purge if the records feed anything downstream, and remember CDRs never expire on their own — a scheduled purge under the cdr-compliance role is the retention mechanism.

Response / symptom Meaning Do
404 CDR not found No record with that id in this namespace Check you’re in the record’s own namespace; transfer counterparts live under their own nsid
400 missing range bound Purge sent without an explicit to cutoff Pass --to / ?to= — purge never defaults its upper bound
412 confirmation required Purge sent without (or with a stale) confirmation body Check the echoed date window, then re-send with confirm_id = the URL’s scope id and a fresh unix timestamp
400 invalid cursor The cursor isn’t one a previous page returned Restart the listing; cursors are opaque and query-specific
410 download session gone The export session expired (48 h idle) or was aborted Open a new export; already-downloaded chunks are still valid
Empty results for a call you just made Range/scope miss, not absence Records appear immediately — check --since, the namespace, --include-children
A call shows cancelled legs Forked dial where another branch answered Normal — see dispositions
disposition: interrupted records A call-processing node restarted mid-call Expected for calls cut by the restart; durations are as-of the failure