Skip to main content

CDR usage export for billing reconciliation

Every finished message and every finished call in your workspace carries the exact unit it was billed on — SMS segments, 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.
Both endpoints require an owner, admin, or developer role with the 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, or statuses token is dropped — it matches nothing, it never errors the request. A stale or typo’d value returns fewer rows, not a 422.
  • until must be after from; otherwise the request 422s.
  • include_unpriced defaults to true because 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’s recorded_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:
The response envelope per page:
  • data.range — the window this page covers (from / until).
  • data.data — the rows, newest first.
  • data.pagination.cursor — pass back as cursor on the next request.
  • data.pagination.has_more — when false, the drain is done.
Two edge behaviors to know before you write the loop:
  • 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 id sequence and stop rather than drain the window twice.
  • The cursor is tenant-bound and route-bound. A cursor from /usage/cdr is 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:
Delivery guarantees and limits:
  • 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: true and 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 id is 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_exported with the row count, window, and truncation flag, so the org has a record of who pulled billing data and when.
Pre-signed URLs are not part of this surface: the CSV downloads directly from the endpoint (the response body is the file). If you need the export handed to a system that can’t hold an API key, pull it with a key and re-upload to your own object store rather than proxying the endpoint.

6. Worked reconciliation example

Finance’s monthly loop, end to end: Step 1 — export the month. For August’s close:
Step 2 — aggregate the CSV into invoice shape. Group rows by the dimensions your invoice uses — typically currency, type, and the channel bucket the rate card bills at — and sum price per group:
Step 3 — match against the invoice. Pull the invoice’s line items for the same period (see invoices). Each invoiced line should equal the matching bucket’s sum. A diff has exactly three sources, and they classify deterministically: 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 price exactly; 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. from and until are ISO-8601 and compared in UTC — pass them with an explicit Z or 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_at may 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).