Usage records billing feed — worked samples and errors
One row per billed event: a message (kind: "message") or a call detail record (kind: "call"). Every row carries the full reconciliation shape:
channelandkind— the billable channel (sms,mms,whatsapp,email,voicefor calls) and whether the row is a per-message record or a per-call CDR.direction—inboundoroutbound.status— the interaction status (delivered,completed, …).endpoint— the one “other party” address: for an outbound row it is the recipientto; for an inbound row it is the senderfrom. The feed folds theto/frompair into this one column so a finance workbook does not have to pick a side per direction.units— the billed units for the row: segment count for SMS (2 on a multipart message), 1 per row for every other channel.priceandcurrency— the per-row price, denominated in the row’s currency (nulluntil priced).ts— the recorded billing-event time (when the message was recorded or the call logged).
Access gates: the JSON feed accepts any authenticated session. The CSV export is a bulk pull gated to the owner, admin, or developer role with the
voice:read scope, and is bounded to at most 20,000 rows per request.1. Read the JSON feed with filters (cURL + Node)
GET /api/v1/usage-records?since=…&until=…&kinds=…&channels=…kinds picks message and/or call; channels takes the inbox vocabulary (sms, mms, whatsapp, email, voice — voice selects the calls arm); statuses accepts the open union of call and message statuses; endpoint matches the remote party; direction narrows to inbound or outbound; since/until is an ISO-8601 window on the billing event time.
Error envelope
Failed reads return{ "error": { "code", "message", "status" }, "meta": { "request_id", "timestamp" } }. A malformed filter combination (an unknown kinds/channels value, an inverted since/until window) is a 422 VALIDATION_ERROR with per-field entries under error.details.issues; a missing or invalid API key is 401 UNAUTHORIZED. Both branches apply to the JSON feed and the CSV export — export-specific query-parameter failures report Invalid usage-feed export parameters.
2. Page through the cursor
has_more in data.pagination answers whether another page exists; pass data.pagination.cursor back as the cursor query parameter. The token is an opaque keyset over (ts, id) — treat it as a string, never construct one client-side. A replayed or tampered cursor degrades to the first page again rather than failing, so polling restarts always make progress.
cURL
3. Export the same window as a CSV
GET /api/v1/usage-records/export.csv?since=…&until=…&filename=…limit to bound it further). It differs from the JSON feed in four ways you build on:
- Content type — the body is
text/csv; charset=utf-8, not JSON (res.json()would throw; read the body as text). - Content-Disposition —
attachment; filename="<stem>-YYYY-MM-DD.csv"; the optionalfilenamequery parameter overrides the stem (the date stamp is appended either way). - Truncation signal — an
X-Export-Truncated: trueheader tells you the window exceeded the cap, so split it (e.g. day-by-day) instead of reconciling a partial snapshot. - Higher gate — export requires an owner, admin, or developer role with the
voice:readscope (the JSON feed is any authenticated session), and it is rate-limited to 5 requests per minute.
Idempotency-Key; on this page every retained retry of either GET returns the same artefact with no double cost.)
Worked export — an outbound-SMS month. Narrow the window before you download it: since/until pick the billing month, kinds=message keeps per-call CDRs out, channels=sms picks the channel, and direction=outbound drops inbound rows a carrier-cost reconciliation would double-count. A reply header of X-Export-Truncated: true means the window exceeded the 20,000-row cap — split it (day-by-day is usually enough) rather than treat the download as complete.
cURL
id, kind, channel, direction, endpoint, status, units, price, currency, ts:
units is the billed segment count (2), and price is the per-message total across both segments — that is why a single row can carry a non-integer-segment price. Sum price over the exported window and it matches the per-usage_events rollup the Reverse-ETL arm ships to your warehouse (see the Reverse ETL guide), and the channel-by-direction split matches the Billable usage records panel on the Channel costs page for the same month.
4. Reconcile against the per-category aggregate
Sum the export’sunits column per (channel, direction, currency) and compare against GET /api/v1/messages/usage/records, the aggregate sibling on the usage page that rolls the same underlying rows up per category — the numbers match 1:1 for the same time window, so a discrepancy means the filters drifted, not the ledger. For per-record billing reconciliation with line-item timing see the CDR export feed; this endpoint is the per-record companion feed that takes its window from the billing event time and returns directly comparable sums.
5. Tie the feed total to your invoice
Sumprice over the drained window — the pagination walker in the usage page’s reconcile-to-invoice chain does exactly this over the CDR sibling — and compare the total with the matching period’s line from GET /api/v1/billing/invoices. Feed rows carry sub-minor precision while the invoice reports integer minor units, so round the summed total to the nearest minor unit before comparing; a residual wider than one minor unit means the filters drifted, not the ledger. The same UTC-boundary and sandbox-isolation rules from that chain apply here: cut windows in UTC, and a dv_test_sk_ key reconciles sandbox traffic only.