Skip to main content

Commerce Hub: Carrier Billing & Pay Links

The checkout guide covers one purchase end to end; the rails guide covers all five rails briefly. This guide goes deep on the two rails a merchant operates daily from the console: Direct Carrier Billing (DCB) — charges that land on the customer’s mobile operator bill — and hosted pay links, the channel-agnostic fallback checkout page. Each section works the same flow through the API and the Commerce hub console pages it lands on. You will:
  1. Pick the rail — DCB vs card checkout vs pay link
  2. Onboard a DCB merchant
  3. Run operator-billed charges
  4. Reconcile the operator settlement
  5. Drive the hosted pay-link lifecycle
All requests go to a single base URL:
Every request carries your key in the X-API-Key header (or a session JWT). Use a sandbox key (dv_test_sk_…) while you build; swap in a live key (dv_live_sk_…) for production. Every rail round-trips a serializable snapshot — the API computes the next valid state; you persist the returned snapshot verbatim. In the dashboard, open Messages → Commerce (/messages/commerce). The hub links the rail consoles this guide walks: DCB (/messages/commerce/dcb) and Pay links (/messages/commerce/pay-links), plus Subscriptions (/messages/commerce/subscriptions) for the recurring-billing sibling rail.

1. Pick the rail

The right rail depends on what the buyer can pay with in the thread you have:
  • DCB (operator billing) — the customer pays on their mobile carrier bill. No card, no wallet, no checkout page — the buyer confirms a code their carrier texts them and the charge appears on the phone bill. Best fit for digital goods (games, streaming, news, content passes) sold to prepaid mobile customers in EMEA/MENA, where card penetration is low and a carrier-bill line converts better than a redirect. Constrained by the merchant’s per-transaction cap and its country+operator coverage map — DCB never runs on a pair the tenant has not onboarded (live).
  • Card checkout (native in-thread where it exists) — WhatsApp Pay where Meta has rolled payments out, Apple Pay inside Apple Messages for Business. The buyer pays without leaving the conversation; a card rails underneath. Use it whenever the channel offers a native wallet and the buyer is card-holding.
  • Hosted pay link (fallback) — when no native wallet exists (SMS, email, Telegram, Viber, a voice-IVR readout with a QR code), mint a tokenized hosted checkout URL and render it into the thread. It also opens cleanly inside WhatsApp’s in-app browser. The pay link is universal — use it as the fallback when DCB coverage or a native wallet is missing, and as the recovery leg when a DCB charge is declined (failed).
A workable decision order: try the native in-thread checkout first; if none exists and the buyer’s country+operator pair is a live DCB pair under the tenant’s merchant config, bill the operator; otherwise mint a pay link. When a DCB charge fails mid-flight, route the same buyer to a pay link instead of retrying the operator — an operator decline is terminal.

2. Onboard a DCB merchant

Onboarding declares the merchant’s settlement currency, digital-goods category, per-transaction charge cap, and tenant-level coverage map of country+operator pairs:
The response is the merchant snapshot — persist it; every later call round-trips it. Coverage is tenant-owned: a pair is chargeable only when its status is live. pilot pairs are visible but blocked while the operator agreement finishes; unsupported is an explicit off switch. There is no global country gate — a tenant onboarded for SA/stc bills there and nowhere else. maxChargeAmount is the per-charge ceiling in major currency units; a charge above it never reaches the operator. In the console (/messages/commerce/dcb), the merchant card mirrors this call: set the merchant name, settlement currency, category, and per-transaction cap, then build the coverage list — the picker lists common EMEA/MENA pairs (e.g. SA/stc, AE/etisalat) as hints. Saving the card calls POST /commerce/dcb/merchant and keeps the returned snapshot in view, so the next section’s charge form reuses it.

3. Run operator-billed charges

Initiate a charge against the onboarded merchant. The API validates, in order: a positive amount, the currency matching the merchant’s, the amount under the cap, and the target country+operator pair being live:
The buyer’s MSISDN is stored only in masked form on the returned charge snapshot — the raw number never persists. To actually bill the operator, shape the carrier-billing request body and submit it to the carrier-billing route, which owns the cost cap and metering:
The operator texts the buyer a confirmation code; when it confirms, capture the charge; on a decline, fail it. Both moves are one call with the round-tripped snapshot:
A smaller capturedAmount (a partial capture the operator confirmed) is honoured; it can never exceed the initiated amount. After capture, refund part or all of the captured value (/dcb/charge/refund — full headroom lands the charge in terminal refunded, a partial one in partially_refunded), and record an operator-initiated reversal with /dcb/charge/chargeback. refunded, charged_back, and failed are terminal: no transition leaves them, and they settle for zero. In the console (/messages/commerce/dcb), the charge form and its lifecycle buttons drive the same calls: initiate a charge with a masked buyer number, build the operator request, then Capture / Fail / Refund / Chargeback the selected charge. Each charge pill shows its lifecycle state (initiatedcaptured/failedpartially_refunded/refunded/charged_back) and the refunded-vs-captured amounts, so a half-refunded charge reads at a glance.

4. Reconcile the operator settlement

Operators settle in batches — a settlement file lines each charge id up with the amount the operator actually pays out. Close the batch by reconciling your charges against those lines:
The response carries the full settlement report plus the charges stamped with their settled timestamp. Every captured (or partially-refunded) charge is expected to settle for its net — captured minus refunded — and the report flags:
  • matched — settled amount and currency equal the expected net; the charge moves to settled.
  • amount_mismatch / currency_mismatch — the operator settled a different figure or a different currency than expected.
  • missing — a captured charge with positive net has no settlement line (the operator did not pay it out).
  • unexpected — a settlement line with no matching charge in the batch.
The report totals gross captured, total refunded, chargeback count, the expected net, the settled net, and the signed delta; reconciled: true only when every captured charge matched and nothing unexpected arrived. Refunded, charged-back, failed, and not-yet-captured charges net to zero and are not expected in the settlement, so they never pollute the missing set. Totals are re-derived server-side — a forged settlement total cannot pass. In the console (/messages/commerce/dcb), paste the operator’s settlement lines into the reconciliation card and run it: the result panel shows the matched/mismatched/missing/unexpected counts, the gross-to-net figures with the signed delta, and per-charge lines with the reason each did (not) reconcile. Matched charges stamp settled in the list; a non-zero delta or any missing line is the signal to dispute the batch with the operator before the next cycle. When DCB is not the fit, the hosted pay link is the channel-agnostic checkout. Mint one — amount, currency, the hosted checkout base URL, an optional description, your own reconciliation reference, and an optional TTL (expiresAt, epoch seconds):
Render the minted request for the buyer’s channel — whatsapp, rcs, sms, email, telegram, viber, or voice (the pay-by-voice IVR readout plus a QR payload):
Hand the rendered content to your per-channel send call — the Commerce API produces the link content; your messaging send delivers it. Drive the lifecycle as buyer signals arrive: MARK_VIEWED when the buyer opens the page, MARK_PAID on payment, EXPIRE once the TTL passes, CANCEL to withdraw a minted link (/payment-request/transition — an illegal transition, including any move after a terminal state, rejects with VALIDATION_ERROR). Every real status change fires the commerce.payment_request.status_changed webhook to your subscribed endpoints, so an order-management system learns the outcome without polling. When the PSP reports the capture, close the request against the captured amount and currency:
An exact amount+currency match flips the request to terminal paid; on a mismatch the response returns the unchanged snapshot with the mismatch reason so you can dispute the capture. A request whose expiresAt has passed accepts only EXPIRE — mint a fresh link; an expired one never comes back. In the console (/messages/commerce/pay-links), the mint form maps one-to-one to the create call (amount, currency, hosted base URL, description, reference, TTL in minutes). The link panel then shows the minted URL with copy/open actions, renders the request for a picked channel, and drives the lifecycle with Mark viewed / Mark paid / Expire / Cancel buttons. The status pill tracks pendingviewedpaid (or expired / cancelled), and the reconciliation card takes the PSP’s captured amount, currency, and reference — an exact match flips the link to paid in place, with the history keeping the terminal snapshot. Subscriptions. The recurring sibling rail — a subscription reuses the mandate the customer already signed, so a renewal collects no new consent — lives at /messages/commerce/subscriptions; the rails guide walks its API. Watch the next-charge dates and the renewal ledger there while operating DCB and pay links.

See also