Skip to main content

CDR export & billing reconciliation feed

Finance reconciliation on a usage-based plan comes down to two data feeds: the per-call records behind your voice charges (call detail records, CDR) and the per-message counters behind your messaging charges (billable usage records). Devotel Orbit ships both as API-level surfaces, so your finance stack — not a shared spreadsheet — reconciles invoices. This page covers the three pieces that make up the feed:
  1. GET /messages/usage/records — per-category usage counters (Twilio /v1/Usage/Records parity), optionally split per country.
  2. GET /voice/calls/export — streamed per-call detail records, CSV or JSON.
  3. The Insights › Costs panel that renders the same counters in the dashboard.
If you need a per-record feed (one row per individual message or call, keyset-paginated) rather than aggregated category counters, see the CDR usage export guide — it covers GET /usage/cdr and GET /usage/cdr/export.csv, which give you row-level granularity for finance-grade close.

1. Usage records — GET /messages/usage/records

Returns one counter row per billing category over a rolling window: SMS inbound/outbound, MMS inbound/outbound, and voice inbound/outbound. Each row carries its category, channel, direction, count, summed price, and currency.
Example record when country_splits=1:
Three counting rules to know before you reconcile against your invoice:
  • SMS counts by segment, MMS and voice by message or call. A multipart SMS bills per segment, so the usage counter matches what the invoice charges.
  • Inbound traffic is included. Inbound SMS/MMS and inbound voice are billable categories and appear alongside outbound.
  • Unresolvable recipients land in an UNKNOWN country bucket in the split view rather than being dropped — your split totals always reconcile to the category total.
Currency is preserved per bucket: an account that bills in more than one currency gets one record per (category, currency) pair, so mixed windows never sum apples with oranges.

Worked reconciliation against an invoice line

Pull a month of usage records with per-country splits, then match each record against the line items on the corresponding invoice:
Group the response by the dimensions your invoice uses — typically category and currency — and sum price per group. In Python:
Now match each grouped total against the invoice’s line items for the same period. A diff usually has one of these causes: The most common discrepancy in practice is the segment-split mismatch: a 3-segment outbound SMS billed at 0.015/segmentshows0.015/segment shows 0.045 on the usage endpoint (count=3, price matches) but the finance sheet summed one “message” at a flat message rate. Always group by count (segments) for SMS in the usage-records view — the price field already applies the segment multiplier, so when your invoice price per category matches the usage price, the count is the only reconcilable axis. When the country split doesn’t add up to the category total, check the UNKNOWN bucket first — it contains records whose destination prefix couldn’t be resolved to a country code, and those are billed, not dropped.

2. Call detail records — GET /voice/calls/export

Exports per-call detail records as a streamed file — CSV by default, JSON on request — filtered by the same dimensions as the calls list.

CSV column reference

When format=csv (the default), the response is a Content-Disposition: attachment CSV with these columns in stable order: CSV-specific quirks to expect:
  • Multiline fields. Recording URLs are long enough that some CSV parsers fail on line length — use a parser that reads RFC 4180 double-quoted fields properly rather than splitting naively on newlines.
  • Currency preservation. Each row carries its own currency — a tenant that bills in multiple currencies gets rows with mixed currency values. Sum per currency before comparing to the invoice.
  • Pre-signed URL expiry. The recording_url field carries a URL that expires after its TTL (typically 1 hour). If you need the recording for later audit, download it before the URL expires rather than storing the URL itself.
  • Empty cells. sip_response can be empty for calls that never reached a SIP leg. recording_url is empty when no recording was produced. answered_at is empty for unanswered calls. Treat empty strings, not "null", as the absent case.

Row cap and EXPORT_TOO_LARGE

The export is capped at 100,000 rows per request. When a request would exceed the cap it is rejected outright before any bytes stream — a partial file is never written:
The HTTP status is 422 Unprocessable Entity. Your next action depends on the window:
  • Monthly export for a high-volume tenant. Split into weekly or daily chunks by adjusting from/to, pull each chunk as a separate CSV, and concatenate client-side.
  • A single-week export that still hits the cap. Add a direction or status filter to halve the result set, or pull two halves (inbound + outbound) separately.
  • Concatenation gotcha. When you concatenate multiple CSVs, strip the header row from every file except the first, or your downstream aggregator double-counts the header as a data row.

Window validation and empty-window edge cases

The endpoint validates two constraints on from/to:
  • to must be strictly after from — a reversed or equal pair returns 422 with code: "INVALID_DATE_RANGE".
  • Both timestamps must be valid ISO-8601 strings — an unparseable value returns 422 with code: "INVALID_DATE_FORMAT".
An empty window (a valid range that produced zero calls) is not an error. The endpoint returns a CSV with only the header row (or an empty JSON array for format=json), and the response status is 200. Your reconciliation script should treat a header-only CSV as “no calls in this window” rather than as a failure.

Rate-limit posture

The export endpoint is rate-limited at 5 requests per minute per tenant. Exceeding it returns 429 Too Many Requests with a Retry-After header. Back off linearly — the retry window is typically 60 seconds. Bursting exports for the same tenant from concurrent workers will trigger the limit; serialize exports for a single tenant, and if multiple tenants need the same reconciliation pull, run them from separate API keys (the rate limit is per-tenant, not per-key).

3. End-to-end reconciliation: usage records → invoice

A concrete monthly close loop that reconciles the usage-records feed against an invoice: Step 1 — Pull a month of usage records.
Step 2 — Pull the call detail records for the same window.
Step 3 — Aggregate the CSV into category buckets that match the usage records.
Step 4 — Classify any mismatch. For reconciling at the per-message level (not aggregated category counters), switch to the per-record CDR export guide — it covers the row-level GET /usage/cdr feed, keyset-cursor paging, and a detailed classification table for unmatched diffs.

4. Reconciling the Costs panel against the Billed messages tile

The Insights › Costs page renders the same usage-records feed next to a “Billed messages” summary tile. They intentionally count different things, and the panel explains this inline:
  • Billable usage records counts SMS by segment and includes inbound traffic.
  • Billed messages counts outbound messages that incurred a charge.
So the usage-records total being higher than the tile total is the expected relationship — it is not a discrepancy. When your finance system reconciles, use the usage-records counters (segment-level, inbound-inclusive), because that matches the invoice’s own counting.

5. Role and access notes

The two endpoints on this page are gated by role and scope: Without the billing:read scope, both endpoints return 403 Forbidden with code: "INSUFFICIENT_SCOPE". A member with the analyst or operator role can see the Insights › Costs dashboard panel, but cannot call the export or usage-records endpoints directly — the panel renders the same data under the dashboard’s own authorization boundary. The Insights › Costs panel (Dashboard → Insights → Channel Costs) is visible to Owner, Admin, Developer, Billing, and Analyst roles. It is not role-gated beyond the dashboard access tier — any user who can reach the Insights hub can see the Costs panel, but only roles with billing:read can pull the raw data through the API.

6. Automation recipes

Monthly cron: pull usage records by window

A cron job that pulls the usage-records feed on the first of each month, offset by the invoice period:

Monthly cron: pull CDR export by month boundaries

For high-volume tenants where a single month exceeds the 100,000-row cap, split into weekly chunks:
Concatenate the weekly CSVs, stripping headers from all but the first:

Reverse-ETL into BigQuery

If your finance stack runs in a data warehouse, the reverse-ETL mechanism mirrors rated usage records and voice call detail records to BigQuery on a schedule — the same per-call and per-message contract, landed in tables you own: Configure the sync from Dashboard → Integrations → Reverse ETL. See the reverse-ETL warehouse exports guide for destination setup, and the reverse-ETL destination model for the schema each table lands with. Once the tables are in BigQuery, your month-end reconciliation becomes a SQL query:

The usage-records and CDR feeds tie into several other surfaces on the platform:
  • Insights hub map — Usage alert rules. The per-category counters you pull from GET /messages/usage/records are the same metrics the Usage alert rules engine watches. When a rule fires (e.g. “SMS outbound spend exceeded $500/day”), the threshold it crossed is the same price field you reconcile against. See the insights usage alert rules guide for configuring rules, and the spend anomaly model for how the alert engine’s 10-minute evaluator and the usage-records feed draw from the same source data.
  • Insights hub map — Channel Costs. The Insights › Costs panel (/insights/costs) renders the same usage-records feed grouped by channel. When you pull GET /messages/usage/records?country_splits=1, the per-category totals should match the panel’s per-channel breakdown one-to-one. A mismatch between the panel and the API response for the same days window means the panel is rendering a cached snapshot — wait one evaluation tick (10 minutes) and re-pull.
  • Billing alerts and spend caps. The billing alert engine’s spend_percent and spend_amount thresholds read the same usage counters. When an alert’s action_on_hit is pause_outbound, it pauses sending at the billing gate — and the usage record for the day the pause triggered will show the partial-day count that tripped the threshold. See Billing overview — spend alerts.
  • Per-record CDR export for row-level reconciliation. The GET /messages/usage/records and GET /voice/calls/export endpoints return aggregated category counters. When finance needs to answer “which 4,812 specific messages sum to this $73.21 charge?” the CDR usage export guide covers GET /usage/cdr and GET /usage/cdr/export.csv — the per-record feed with keyset-cursor paging, CSV truncation semantics, and a detailed reconciliation classification table for rounding vs. material vs. unpriced diffs.

See also