> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# CDR usage export for billing reconciliation

> Walk the billing-reconciliation flow end to end: page the per-record CDR feed with a keyset cursor, bulk-export CSV for month-end close, and match per-call and per-message line items against invoiced line items.

# 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](/api-reference/cdr-export), and the standalone recipes at
[Task 34 in the API recipes cookbook](/guides/api-recipes). 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.

| Key                | Values                                           | Effect                                                                                                                     |
| ------------------ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `from`             | ISO-8601 datetime                                | Window start, inclusive. Default: 24 hours ago.                                                                            |
| `until`            | ISO-8601 datetime                                | Window end, exclusive. Default: now.                                                                                       |
| `types`            | `message`, `call` (CSV of either)                | Restrict to one record kind. Omit for both.                                                                                |
| `channels`         | `sms`, `whatsapp`, `email`, … (CSV)              | Match message channel. Calls carry the synthetic channel `voice`, so `channels=voice` selects the calls arm.               |
| `statuses`         | `queued`, `sent`, `delivered`, `failed`, … (CSV) | One key spans both kinds — matches message statuses and call statuses (`initiated`, `answered`, `completed`, `failed`, …). |
| `direction`        | `inbound`, `outbound`                            | Restrict direction.                                                                                                        |
| `include_unpriced` | `true` (default), `false`                        | `true` keeps rows whose price was never rated; `false` restricts to billed rows only.                                      |
| `limit`            | integer 1–50,000                                 | Rows per page / per CSV. Default 10,000.                                                                                   |
| `cursor`           | opaque string                                    | Continuation token from a prior response.                                                                                  |
| `filename`         | string (CSV only)                                | Download filename stem, sanitized server-side.                                                                             |

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:

```bash theme={null}
FROM="2026-08-01T00:00:00Z"
UNTIL="2026-09-01T00:00:00Z"
CURSOR=""

while :; do
  URL="https://api.orbit.devotel.io/api/v1/usage/cdr?from=${FROM}&until=${UNTIL}&limit=10000"
  [ -n "$CURSOR" ] && URL="${URL}&cursor=${CURSOR}"

  RESP=$(curl -s "$URL" -H "X-API-Key: dv_live_sk_your_key_here")
  echo "$RESP" | jq -r '.data.data[] | [.id, .type, .status, .price, .currency] | @csv'

  HAS_MORE=$(echo "$RESP" | jq -r '.data.pagination.has_more')
  [ "$HAS_MORE" = "true" ] || break
  CURSOR=$(echo "$RESP" | jq -r '.data.pagination.cursor')
done
```

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:

```
id, type, channel, direction, from_addr, to_addr, status,
segments, duration_seconds, price, currency, recorded_at,
sent_at, delivered_at, failed_at, started_at, answered_at, ended_at,
sip_response_code, error_code, error_message, contact_id, campaign_id, href
```

Delivery guarantees and limits:

* **Rate limit** — 5 requests per minute. Treat a 429 as backoff, not
  failure (see [Task 2](/guides/api-recipes) 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:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/usage/cdr/export.csv?from=2026-08-01T00:00:00Z&until=2026-09-01T00:00:00Z&include_unpriced=true&filename=august-cdr" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -O
```

**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:

```python theme={null}
import csv
from collections import defaultdict
from decimal import Decimal

totals = defaultdict(Decimal)
with open("august-cdr-2026-09-01.csv") as f:
    for row in csv.DictReader(f):
        if row["price"] == "":
            bucket = (row["currency"] or "unpriced", row["type"], row["channel"])
            # keep unpriced rows visible — they're a separate bucket, not absent
            totals[bucket] += Decimal(0)
            continue
        bucket = (row["currency"], row["type"], row["channel"])
        totals[bucket] += Decimal(row["price"])

for (currency, kind, channel), total in sorted(totals.items()):
    print(f"{currency} {kind:8s} {channel:10s} {total}")
```

**Step 3 — match against the invoice.** Pull the invoice's line items for
the same period (see [invoices](/guides/billing-invoices-statements-and-pay-by-link)).
Each invoiced line should equal the matching bucket's sum. A diff has
exactly three sources, and they classify deterministically:

| Diff shape                                                  | Cause                                                                                                                                    | Next step                                                                                                                                      |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Invoice line exceeds feed total by a tiny, uniform fraction | Invoice rounds per-line; the feed sums exact per-record prices                                                                           | Expected — note it as the rounding variance (section 7), not a mismatch.                                                                       |
| Invoice line exceeds feed total materially                  | Records the feed didn't return — usually `include_unpriced=false` on one side, or an `until` boundary that cut off the tail of the month | Re-export with `include_unpriced=true` and a window padded past the period end.                                                                |
| Feed total exceeds invoice line                             | Unpriced rows in the window (price NULL) or failed-then-refunded records                                                                 | Filter the `unpriced` bucket out (or `include_unpriced=false`) and re-diff; anything left is worth a support ticket with the specific row ids. |

**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).
