Wallet spend-channel attribution: how each debit gets its channel
Every spend surface in Orbit answers “how much did we spend per channel?” from the same wallet ledger: the Spend by channel tiles on Billing → Usage, the burn-rate forecast, spend anomalies, the statement’s usage-by-channel breakdown, and the AI usage split. This page explains how a ledger that stores no channel column still answers that question identically on every surface: each debit carries areference string that
stamps what was charged, and one shared rule reads that stamp back into a
channel.
1. Why channel is not a ledger column
A wallet debit row carries three things you can rely on: the tenant it belongs to, an idempotency key that collapses a retried charge into one row (see wallets, credits, and charges), and areference naming
the charge, for example charge:sms_mccmnc:msg_4f8c1d. There is no
channel column on the row. Instead, every charge writer stamps the
channel into the reference it records, and the spend surfaces derive the
channel back out of that stamp at read time.
That derivation lives in exactly one place: the billing package’s
channel-bucket module, as a TypeScript function and its SQL twin, a
single CASE expression over the reference. The Usage spend-by-channel
query, the burn-rate forecast, and the spend-anomaly scope all call that
one classifier. The customer-visible consequence is a parity contract:
every spend surface buckets identically by construction, so two tiles
on the same dashboard can no longer disagree about which channel a dollar
belongs to. If a debit’s reference changes how it is stamped, every
surface moves it together, because there is only one rule to change.
2. The canonical channel vocabulary
Each debit lands in exactly one of these buckets:- sms — per-segment SMS sends, the SMPP preflight charge, and the
retry/scheduled arms.
charge:sms_mccmnc:msg_4f8c1d,charge:smpp…,sms_retry:… - mms — MMS sends.
charge:mms_sent:msg_… - whatsapp — WhatsApp sends, including WhatsApp Business Calling
(
whatsapp_calling:…). Business Calling is WhatsApp spend, not voice: one product stays on one row. - voice — call charges, holds, and dialer calls.
charge:voice…,voice_hold:…,charge:dialer…,dialer_… - email —
charge:email_sent:…and otheremail_…references. - rcs —
charge:rcs_sent:…and otherrcs_…references. - telegram —
charge:telegram_sent:…and othertelegram_…references. - viber —
charge:viber_sent:…and otherviber_…references. - fax —
charge:fax_sent:…and otherfax_…references. - push —
charge:push_sent:…and otherpush_…references. - verify — OTP sends:
verify_sms,verify_email,charge:verify…. A verification is billed as verify whatever transport carried the code; an OTP sent over SMS shows under verify, not sms, because you bought a verification, not a message. - agents — LLM and agent spend:
agent_llm:…,charge:llm…,charge:ai_…, and the exact referenceai_compose_campaign. - numbers — number purchases, lookups, and rentals:
number_purchase…,number_lookup…, and othernumber_…references. - marketplace — marketplace installs:
marketplace_install…. - api — reserved for metered API usage:
meter:api_…descriptors from the reseller metering bridge. No live debit mints these references today, so the bucket exists in the vocabulary but carries no spend figure until such a writer ships. - other — the deliberate catch-all for any reference shape newer than the vocabulary above.
3. Ordering and the catch-all rule
The classifier is a CASE evaluated top to bottom; the first matching arm wins, exactly as in SQL. Three rules fall out of that ordering:- Mid-reference channels are decomposed first. Scheduled sends carry
the channel in the middle of the reference
(
charge:scheduled_message:whatsapp:msg_…), so that shape is unpacked before the prefix arms run; a scheduled WhatsApp send buckets as whatsapp, not as something else. - The generic send shape is matched per channel, never as a wildcard.
charge:<channel>_sent:…is checked once per known channel. A reference from a channel the vocabulary does not know therefore lands inotherinstead of inventing a bucket name out of the reference text. otherstays last, and rows are never dropped. Every debit lands in exactly one bucket, so the per-channel rows always sum back to the period’s total spend and to the balance movement on the statement.
other; it never moves a row between two named channels.
4. The surfaces one classifier feeds
- Billing → Usage, Spend by channel — the per-channel tiles for the current period, the most direct rendering of the vocabulary above.
- Burn-rate forecast — the
by_channelrows of the month-end projection, same buckets (billing burn rate). - Spend anomalies — the wallet scope the anomaly detector watches is bucketed by the same rule (anomaly insights model).
- Statements — the usage-by-channel JSON and CSV fold the same
attribution into statement line types for the invoice-facing contract
(billing documents model): smaller
messaging channels share a
messagingline and agent spend reports on an AI line, but the underlying attribution is the one above. - AI usage split — the drill-down of the agents bucket by agent and model (AI usage breakdown).
GET /api/v1/usage-records, which carries an
authoritative channel column: metering writes it at send time, and
nothing is derived. When you reconcile a ledger reference to a usage
record, join on the record id embedded in the reference:
charge:sms_mccmnc:msg_4f8c1d names the record msg_4f8c1d, whose own
channel says sms.
5. Worked reconciliation example
Three ledger rows, read top to bottom by the first-match rule:
In the statement CSV, the same three debits appear on the statement’s
invoice-facing lines: an
sms usage line, an AI usage line, and a verify
line. The line types differ from the dashboard bucket names by design;
the attribution underneath is the same, and the lines still sum to the
period’s usage total.
In usage records, the first row joins by the id in its reference to
the record msg_9f2e, whose authoritative channel agrees with the
derived bucket. The AI compose debit has no per-message row there; its
drill-down is the AI usage split. The verify debit is a product-level
charge: verify_sms names the transport, not a record id, so it
reconciles against the verify line and the window total rather than a
row-level join.
6. Boundaries
- This page is advisory. It documents how to read the spend surfaces; there is no per-tenant channel mapping to configure.
- The vocabulary above is what ships today. The
apibucket is declarative-only, so do not build reporting on the expectation of channels or reference shapes that are not listed here. - An
otherrow in a period is expected whenever a reference shape is newer than this vocabulary. It is a prompt to contact support only if it persists across the next release; a one-period appearance is the catch-all doing its job.
Related
- The billing model map — which billing page owns which question.
- Wallets, credits, and charges — the ledger this attribution reads from.
- Billing burn rate — the
by_channelforecast rows. - Channel cost rollup vs the billing ledger — analytics channel vs ledger-derived channel.
- Billing documents model — statement usage-by-channel and the CSV.
- AI usage breakdown — the agents bucket drilled down.
- Usage records model — the feed with the authoritative channel column.