Skip to main content

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 a reference 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 a reference 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 other email_… references.
  • rcs — charge:rcs_sent:… and other rcs_… references.
  • telegram — charge:telegram_sent:… and other telegram_… references.
  • viber — charge:viber_sent:… and other viber_… references.
  • fax — charge:fax_sent:… and other fax_… references.
  • push — charge:push_sent:… and other push_… 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 reference ai_compose_campaign.
  • numbers — number purchases, lookups, and rentals: number_purchase…, number_lookup…, and other number_… 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 in other instead of inventing a bucket name out of the reference text.
  • other stays 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.
Read “money under Other” with that in mind: it is not lost money. It means “a reference shape newer than the current vocabulary” — visible on every spend surface and reconcilable against the transactions feed. A metadata channel tag, where a writer stamps one, can only move a row out of 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_channel rows 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 messaging line 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).
Contrast that with 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 api bucket is declarative-only, so do not build reporting on the expectation of channels or reference shapes that are not listed here.
  • An other row 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.