Skip to main content

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:
  • channel and kind — the billable channel (sms, mms, whatsapp, email, voice for calls) and whether the row is a per-message record or a per-call CDR.
  • direction — inbound or outbound.
  • status — the interaction status (delivered, completed, …).
  • endpoint — the one “other party” address: for an outbound row it is the recipient to; for an inbound row it is the sender from. The feed folds the to/from pair 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.
  • price and currency — the per-row price, denominated in the row’s currency (null until priced).
  • ts — the recorded billing-event time (when the message was recorded or the call logged).
The feed below is the row-level parent of the per-category aggregate — the chains here cover read → page → export → reconcile. Copy a request as written and compare the response envelope.
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=…
Every filter narrows the same row set on both arms (messages and calls). 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=…
The export repeats the feed with the same filter set, bounded to 20,000 rows (pass a smaller 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 optional filename query parameter overrides the stem (the date stamp is appended either way).
  • Truncation signal — an X-Export-Truncated: true header 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:read scope (the JSON feed is any authenticated session), and it is rate-limited to 5 requests per minute.
Both the JSON list and this export are GET reads: they never create a resource, so an idempotency key is unnecessary on them. (POST endpoints that create resources accept an 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
The CSV columns are id, kind, channel, direction, endpoint, status, units, price, currency, ts:
The second row is a two-segment message: 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’s units 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

Sum price 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.