> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Commerce Hub: Carrier Billing & Pay Links

> Operate the Commerce hub end to end — pick the right rail (DCB, card checkout, or a hosted pay link), onboard a DCB merchant, run operator-billed charges, reconcile the operator settlement, and drive a pay link from mint to expiry in the console.

# 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](#1-pick-the-rail)
2. [Onboard a DCB merchant](#2-onboard-a-dcb-merchant)
3. [Run operator-billed charges](#3-run-operator-billed-charges)
4. [Reconcile the operator settlement](#4-reconcile-the-operator-settlement)
5. [Drive the hosted pay-link lifecycle](#5-hosted-pay-link-lifecycle)

All requests go to a single base URL:

```
https://api.orbit.devotel.io/api/v1/commerce
```

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/commerce/dcb/merchant \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "merchantId": "pixel_games",
    "displayName": "Pixel Games",
    "currency": "SAR",
    "category": "games",
    "maxChargeAmount": 50,
    "coverage": [
      { "country": "SA", "operator": "stc", "status": "live" },
      { "country": "AE", "operator": "etisalat", "status": "pilot" }
    ]
  }'
```

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`:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/commerce/dcb/charge \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "merchant": <merchant snapshot>,
    "charge": {
      "id": "dcb_9842a",
      "phoneNumber": "+966512345678",
      "country": "SA",
      "operator": "stc",
      "amount": 12.5,
      "currency": "SAR",
      "description": "Season pass",
      "merchantReference": "order_9842"
    }
  }'
```

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/commerce/dcb/operator-request \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "charge": <initiated charge snapshot>, "phoneNumber": "+966512345678" }'
```

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/commerce/dcb/charge/capture \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "charge": <initiated charge 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 (`initiated` → `captured`/`failed` → `partially_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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/commerce/dcb/reconcile \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "charges": [ <charge snapshots in the batch> ],
    "records": [
      { "chargeId": "dcb_9842a", "operator": "stc", "amount": 12.5, "currency": "SAR" }
    ]
  }'
```

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.

## 5. Hosted pay-link lifecycle

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):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/commerce/payment-request \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "req_9842",
    "amount": 25.0,
    "currency": "USD",
    "hostedBaseUrl": "https://pay.yourshop.example",
    "description": "Chai Blend 500g ×2",
    "reference": "order_9842",
    "expiresAt": 1893456000
  }'
```

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):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/commerce/payment-request/render \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "request": <payment request snapshot>, "channel": "sms" }'
```

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/commerce/payment-request/reconcile \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "request": <payment request snapshot>,
    "capture": {
      "amount": 25.0,
      "currency": "USD",
      "providerReference": "psp_txn_8842a"
    }
  }'
```

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 `pending` → `viewed` → `paid` (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](/guides/conversational-commerce-rails) walks its API. Watch the next-charge dates and the renewal ledger there while operating DCB and pay links.

## See also

* [Conversational Commerce Checkout](/guides/conversational-commerce-checkout) — the end-to-end purchase thread these rails close, §6 names the rails this guide operates
* [Conversational Commerce Rails](/guides/conversational-commerce-rails) — the five-rail overview with one worked snippet each
* [Commerce API reference](/api-reference/commerce) — every endpoint and field
* [Checkout spine concept](/concepts/commerce-checkout-spine) — why one cart resolves across surfaces
