CDR usage export for billing reconciliation
Every finished message and every finished call in your workspace carries the exact unit it was billed on — SMSsegments, voice duration_seconds, the
persisted price + currency the wallet was charged at send or call-close
time. Two endpoints expose that record so finance can reconcile Orbit’s
billed record against an invoice row-for-row:
GET /usage/cdr— one page of records as JSON, keyset-paginated.GET /usage/cdr/export.csv— the same filter set as a downloadable CSV.
billing:read scope — the feed is bulk billing data over the whole
workspace. This guide walks the full reconciliation loop: when to page versus
export, how the query keys compose, how the keyset cursor drains a month,
what the CSV does and does not guarantee, and how to match rows against an
invoice and classify what’s left over.
See the endpoint field-by-field contract at
CDR Export API, and the standalone recipes at
Task 34 in the API recipes cookbook. This guide is
the workflow those reference pages sit inside.
1. Why a reconciliation feed
The dashboard’s cost views are meant for operators: they answer “how much did we spend this month” at category granularity and make drift visible. They are not a ledger. When finance closes the month, they need one answerable question per invoice line: which billed records sum to this charge? A dashboard reading can’t answer that — it aggregates, rounds, and buckets by design. The CDR feed answers it because every row in the feed is one persisted message or call record with the price the wallet was actually charged, not a recomputed estimate. Reconciliation against the CDR feed is the difference between “the invoice looks about right” and “every invoiced line maps to 4,812 specific records, here are 3 that don’t.” The second is a finance-grade close; the first is not.2. Two consumption modes
Pick the mode per use case — both endpoints share the same query keys, the same row shape, and the same gate, so the choice is about cadence, not capability. Paginate the JSON feed for live lookups: a support case tied to one message id, a BI tool that syncs recent records into a warehouse table, or a drain of a window that exceeded the CSV’s row cap. Cursor pagination is stable under concurrent writes — new records landing mid-drain don’t shift the window under you — so it’s the correct mode for anything that reads while traffic is live. Bulk-export CSV for month-end close: the month’s full window, landing in a file you hand to Excel, a data warehouse, or the accounting export. One request produces one file, audit-logged with its row count. The CSV is capped at 50,000 rows per pull — when a month is larger than that, the JSON drain is the path (the CSV tells you it truncated; see section 5). The two modes never disagree on which rows match: both endpoints apply the identical filter set, so a CSV you exported at close and a JSON page you pulled live return the same records for the same window.3. Query field matrix
Every query key is optional; omit all of them and you get the last 24 hours of both record kinds. Compose them for the window and slice you need.
Rules that surprises you once if you miss them:
- An unrecognised
types,channels, orstatusestoken is dropped — it matches nothing, it never errors the request. A stale or typo’d value returns fewer rows, not a 422. untilmust be afterfrom; otherwise the request 422s.include_unpriceddefaults totruebecause a reconciliation feed that silently drops un-priced rows looks like “no usage” — you want the row to reconcile, even when its price is NULL.
4. Cursor-pagination walkthrough
The cursor is a keyset cursor — it encodes the last row’srecorded_at + id of the page you just read, not an offset. That means
records landing mid-drain don’t shift or duplicate your pages: the next
request resumes strictly after the row the cursor names. An offset cursor
can’t promise that; a keyset cursor can.
Drain a full month terminal-first like this:
data.range— the window this page covers (from/until).data.data— the rows, newest first.data.pagination.cursor— pass back ascursoron the next request.data.pagination.has_more— whenfalse, the drain is done.
- A malformed or tampered cursor degrades to the first page of the
window rather than erroring — an export surface doesn’t hard-fail on a
bad token. Your loop should detect a repeated first-page
idsequence and stop rather than drain the window twice. - The cursor is tenant-bound and route-bound. A cursor from
/usage/cdris not exchangeable with the CSV endpoint’s continuation, and a cursor from one tenant is meaningless on another.
5. CSV layout and delivery
GET /usage/cdr/export.csv streams the same rows as a CSV attachment
(Content-Disposition: attachment; filename="<stem>-<YYYY-MM-DD>.csv"),
with the columns in this stable order:
- Rate limit — 5 requests per minute. Treat a 429 as backoff, not failure (see Task 2 for the retry loop).
- Row cap — 50,000 rows per pull. When the window exceeds the cap, the
response carries
X-Export-Truncated: trueand the file holds the first 50,000 rows of your window. Drain the rest through the JSON endpoint (section 4) — there’s no CSV-side continuation. - Filter fidelity — the CSV applies the exact filter set you sent: the same window, the same channels, the same statuses. It’s never “the full month re-filtered client-side.” Export per-channel slices separately when finance wants per-channel CSVs; don’t export everything and filter downstream.
- Dedupe on rerun — rerunning an export with the same window and
filters returns the same rows the window now resolves to. It is not a
frozen snapshot: records that finished rating between your two runs
appear in the second, and records that were pending in the first may now
carry delivery timestamps. For close, treat the last re-export before
your ledger freeze as authoritative. The row
idis the dedupe key when you merge overlapping windows into a warehouse table. - Audit log — every export is written to the audit log as
usage.cdr_exportedwith the row count, window, and truncation flag, so the org has a record of who pulled billing data and when.
6. Worked reconciliation example
Finance’s monthly loop, end to end: Step 1 — export the month. For August’s close:currency, type, and the
channel bucket the rate card bills at — and sum price per group:
Step 4 — classify the unknowns. Any row or sum that survives step 3’s
reconciliation lands in one of three buckets: rounding variance (expected,
write it off), unpriced records (usually a rating-side hiccup — flag for
Ops), or genuinely unmatched (escalate with the row
id list). The audit
log entry from the export gives you the exact window provenance when you do
escalate.
7. Caveats
- Rounding. The feed sums each record’s persisted
priceexactly; an invoice may round per line-item or aggregate at a different precision. Expect a sub-cent or few-cent variance per bucket — the deterministic shape from step 3 — and treat it as expected, not a discrepancy. If finance’s materiality threshold is tighter than per-line rounding, reconcile at the record level rather than the bucket level and the rounding variance collapses to nil. - Time zones.
fromanduntilare ISO-8601 and compared in UTC — pass them with an explicitZor offset. A window given in local time without offset lands where the server interprets it, which for a month-end close usually means a few hours of the wrong month on one side. Invoice periods are UTC-bounded too, so match the window to the invoice period exactly. - Subaccount inheritance. The feed is scoped to the calling tenant. If your workspace operates subaccounts, run the export once per subaccount (with that subaccount’s key) and reconcile per-tenant; the parent tenant’s feed does not include child-tenant records. Aggregate the per-tenant CSVs client-side when finance wants one rolled-up file.
- Terminal status lag. A delivered message’s price is persisted at
send, but its
delivered_atmay fill in after the fact. Export close to the period end and treat the last few minutes of the window as provisional — or re-export once before freezing the ledger (section 5).