> ## 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.

# Billing API

> Check wallet balance, top up credits, view transactions, and download invoices

# Billing API

Orbit is **pay-as-you-go**. There are no plan tiers or recurring subscriptions
— every organization has a single prepaid USD wallet (`organizations.credits`,
stored in minor units / cents) that funds messaging, voice, and AI spend.
Top up the wallet through a one-time Stripe Checkout session; per-send charges
are deducted in real time and recorded in an append-only ledger
(`credit_transactions`).

**Base path:** `/api/v1/billing`

***

## Status

### Get Billing Status

`GET /api/v1/billing/status`

Returns whether Stripe is configured for this organization and whether a
customer record exists yet. The dashboard uses this to gate the "Open Stripe
Portal" CTA — top-up itself does not require a pre-existing customer (Checkout
auto-provisions one on first payment).

<RequestExample>
  ```bash theme={null}
  curl https://api.orbit.devotel.io/api/v1/billing/status \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "stripe_configured": true,
      "has_customer": true,
      "can_manage": true,
      "can_checkout": true
    },
    "meta": {
      "request_id": "req_status_001",
      "timestamp": "2026-05-16T12:00:00Z"
    }
  }
  ```
</ResponseExample>

***

## Wallet balance

### Get Balance

`GET /api/v1/billing/balance` (alias: `GET /api/v1/billing/credits`)

Retrieve the current wallet balance in USD cents plus the outbound-pause
flag (set when the balance hits zero or a payment-method issue parks the
account).

<RequestExample>
  ```bash theme={null}
  curl https://api.orbit.devotel.io/api/v1/billing/balance \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "balance_usd": 150.00,
      "balance_cents": 15000,
      "credits": 15000,
      "outbound_paused": false,
      "outbound_block_reason": null
    },
    "meta": {
      "request_id": "req_balance_001",
      "timestamp": "2026-05-16T12:00:00Z"
    }
  }
  ```
</ResponseExample>

<Note>
  `balance_cents` and `credits` are the same minor-unit (cents) value —
  `credits` is preserved for backwards compatibility. `balance_usd` is the
  same number divided by 100. When `outbound_paused: true` is set, all
  outbound sends (SMS, voice, AI compose) return `SENDING_PAUSED`
  (HTTP 402) until the billing alert that tripped the pause is reset in
  the billing alerts dashboard.
</Note>

***

## Top up the wallet

### Create Top-Up Session

`POST /api/v1/billing/balance/top-up` (alias: `POST /api/v1/billing/credits/purchase`)

Creates a Stripe Checkout session (mode: `payment` — one-time charge, NOT a
subscription) for adding USD credits to the wallet. Stripe webhook
`checkout.session.completed` credits the wallet asynchronously after payment
clears.

The `Idempotency-Key` header is **required**. Re-submitting the same key
within 60 seconds (Redis hot-path) or within the durable replay window
(Postgres `topup_idempotency_records`) returns the original Checkout URL
instead of creating a duplicate session.

<ParamField header="Idempotency-Key" type="string" required>
  Stable client-generated value, 8–255 characters. Re-using the key for a
  different `(amount, currency)` returns `409 IDEMPOTENCY_KEY_REUSED`.
</ParamField>

<ParamField body="amount" type="integer" required>
  Amount in minor units (cents). Min `1`, max `1000000` (\$10,000).
</ParamField>

<ParamField body="currency" type="string" default="usd">
  `usd` or `eur`. The wallet stores values in the org's default currency;
  Stripe webhook handlers apply FX conversion when the charge currency
  differs.
</ParamField>

<ParamField body="successUrl" type="string" required>
  Redirect URL on successful payment. Must be on a trusted origin
  (`orbit.devotel.io`).
</ParamField>

<ParamField body="cancelUrl" type="string" required>
  Redirect URL if the user cancels. Must be on a trusted origin.
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/billing/balance/top-up \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: topup_2026-05-16_user_42_abc123" \
    -d '{
      "amount": 10000,
      "currency": "usd",
      "successUrl": "https://orbit.devotel.io/billing?topup=ok",
      "cancelUrl": "https://orbit.devotel.io/billing?topup=cancelled"
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_live_abc123..."
    },
    "meta": {
      "request_id": "req_topup_001",
      "timestamp": "2026-05-16T12:00:00Z"
    }
  }
  ```
</ResponseExample>

### Configure Auto-Top-Up

`PUT /api/v1/billing/auto-topup`

Set up automatic top-ups when the wallet balance falls below a threshold.
Companion routes: `GET /api/v1/billing/auto-topup` reads the current
config, `DELETE /api/v1/billing/auto-topup` disables and clears it. The
scheduled charge fires from the webhook-worker on a 15-minute tick once
the threshold is crossed and uses the org's default off-session payment
method.

<ParamField body="enabled" type="boolean" required>
  Enable or disable auto-top-up.
</ParamField>

<ParamField body="threshold_minor" type="integer" required>
  Balance threshold (in minor units, e.g. cents) that triggers a top-up.
  Required when `enabled` is `true`; ignored when `enabled` is `false`. Must be
  a positive integer of at least 100 minor units (\$1.00, or the equivalent in
  your wallet currency).
</ParamField>

<ParamField body="recharge_amount_minor" type="integer" required>
  Amount (in minor units, e.g. cents) to charge when triggered. Required when
  `enabled` is `true`; ignored when `enabled` is `false`. Must be a positive
  integer between 100 minor units ($1.00) and 1,000,000 minor units ($10,000),
  and it must be greater than `threshold_minor` so each top-up lifts the balance
  back above the trigger. Stripe also rejects amounts below the per-currency
  minimum charge.
</ParamField>

<ParamField body="max_monthly_minor" type="integer" default="null">
  Optional monthly spend cap (in minor units). Once the calendar month's
  auto-charges would exceed this value, the scheduler stops topping up until the
  cap resets at the start of the next UTC month. Must be at least
  `recharge_amount_minor`, otherwise the first charge would already breach it.
  Omitting this field — or sending `null` — leaves any previously saved cap in
  place; to remove a cap you must send `clear_cap` (see below).
</ParamField>

<ParamField body="max_topups_per_day" type="integer" default="5">
  Optional cap on how many auto-top-ups may fire per UTC day, counting both
  scheduled and interactive top-ups. Must be between `1` and `100`. Omit the
  field or send `null` to use the default of `5` per day.
</ParamField>

<ParamField body="clear_cap" type="boolean" default="false">
  Set to `true` to remove a previously saved `max_monthly_minor` cap. Because
  omitting `max_monthly_minor` (or sending it as `null`) preserves the stored
  cap rather than clearing it, removing a cap is an explicit action. Only the
  literal boolean `true` clears the cap; any other value is ignored.
</ParamField>

<Note>
  When `enabled` is `true`, both `threshold_minor` and `recharge_amount_minor`
  are required. Sending `{ "enabled": true }` on its own returns a `422`.
</Note>

<Note>
  **Clearing the monthly cap.** Leaving `max_monthly_minor` out of the request
  body — or sending it as `null` without `clear_cap` — keeps any previously
  saved cap in place. This is deliberate: a partial form submit that blanks the
  cap field can't silently drop a limit another admin configured. To actually
  remove the cap, send `"clear_cap": true`; the cap is then set to `null`.
</Note>

<RequestExample>
  ```bash theme={null}
  curl -X PUT https://api.orbit.devotel.io/api/v1/billing/auto-topup \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "enabled": true,
      "threshold_minor": 5000,
      "recharge_amount_minor": 10000,
      "max_monthly_minor": 50000
    }'
  ```
</RequestExample>

<RequestExample>
  ```bash theme={null}
  # Remove a previously set monthly cap
  curl -X PUT https://api.orbit.devotel.io/api/v1/billing/auto-topup \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "enabled": true,
      "threshold_minor": 5000,
      "recharge_amount_minor": 10000,
      "clear_cap": true
    }'
  ```
</RequestExample>

### Open Stripe Customer Portal

`POST /api/v1/billing/portal`

Mints a short-lived Stripe Customer Portal URL where the end user can manage
saved payment methods, download tax receipts, and view past charges. This
is NOT a subscription portal — Orbit has no recurring subscription state to
manage.

<ParamField body="returnUrl" type="string" required>
  URL to redirect to after the portal session ends. Must be on a trusted
  origin (`orbit.devotel.io`).
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/billing/portal \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{ "returnUrl": "https://orbit.devotel.io/billing" }'
  ```
</RequestExample>

***

## Crypto top-up (NOWPayments)

Fund the wallet with cryptocurrency through a NOWPayments hosted invoice, as
an alternative to Stripe Checkout. The wallet is credited asynchronously once
the on-chain payment confirms. These endpoints require the `owner`, `admin`,
or `billing` role.

<Note>
  Crypto top-up must be enabled by your operator. If it is not yet available
  for your account, these endpoints return `503 SERVICE_UNAVAILABLE` — use
  Stripe Checkout in the meantime.
</Note>

### Create Crypto Top-Up Session

`POST /api/v1/billing/balance/top-up-crypto`

Mints a NOWPayments hosted invoice and returns its URL. Like the Stripe
top-up, the `Idempotency-Key` header is **required** — re-submitting the same
key returns the original invoice instead of minting a new one.

<ParamField header="Idempotency-Key" type="string" required>
  Stable client-generated value, 8–255 characters. Re-using the key for a
  different `(amount, currency)` returns `409 IDEMPOTENCY_KEY_REUSED`.
</ParamField>

<ParamField body="amount" type="integer" required>
  Amount in minor units (cents). Min `100` ($1.00), max `1000000` ($10,000).
</ParamField>

<ParamField body="currency" type="string" default="usd">
  Only `usd` is supported today — the wallet ledger is USD-denominated.
</ParamField>

<ParamField body="successUrl" type="string" required>
  Redirect URL after the payment confirms. Must be on a trusted origin
  (`orbit.devotel.io`).
</ParamField>

<ParamField body="cancelUrl" type="string" required>
  Redirect URL if the payer closes the invoice. Must be on a trusted origin.
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/billing/balance/top-up-crypto \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: crypto_2026-07-03_user_42_abc123" \
    -d '{
      "amount": 10000,
      "currency": "usd",
      "successUrl": "https://orbit.devotel.io/billing?topup=ok",
      "cancelUrl": "https://orbit.devotel.io/billing?topup=cancelled"
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "checkoutUrl": "https://nowpayments.io/payment/?iid=4522625843",
      "intentId": "cryptoIntent_2vXq7p9yzAbCdEf",
      "paymentMethod": "crypto_nowpayments",
      "feeCents": 0,
      "feePaidByUser": true
    },
    "meta": {
      "request_id": "req_crypto_001",
      "timestamp": "2026-07-03T12:00:00Z"
    }
  }
  ```
</ResponseExample>

<Note>
  `feePaidByUser` reflects the network-fee policy. For top-ups of $50 or less
      Orbit absorbs the network fee: `feePaidByUser` is `false` and `feeCents` is the
      absorbed amount. Above $50 the fee is added on top of `amount` at checkout, so
  `feePaidByUser` is `true` and `feeCents` is `0` — as in the \$100 example above,
  where the network fee appears on the NOWPayments payment page rather than in
  this response.
</Note>

### Get Crypto Top-Up Status

`GET /api/v1/billing/crypto-topup/{intentId}/status`

Poll the status of a crypto top-up intent while the payment settles. Returns
`404` when the intent does not exist or belongs to another organization.

<ParamField path="intentId" type="string" required>
  The `intentId` returned by the create call.
</ParamField>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "intentId": "cryptoIntent_2vXq7p9yzAbCdEf",
      "paymentStatus": "waiting",
      "priceAmountCents": 10000,
      "priceCurrency": "usd",
      "payCurrency": null,
      "invoiceUrl": "https://nowpayments.io/payment/?iid=4522625843",
      "createdAt": "2026-07-03T12:00:00Z",
      "updatedAt": "2026-07-03T12:00:00Z",
      "metadata": { "source": "crypto_nowpayments" }
    },
    "meta": {
      "request_id": "req_crypto_status_001",
      "timestamp": "2026-07-03T12:01:00Z"
    }
  }
  ```
</ResponseExample>

### Cancel Crypto Top-Up

`POST /api/v1/billing/crypto-topup/{intentId}/cancel`

Cancel a crypto top-up that has not yet settled. Succeeds only while the
payment is `waiting` or `confirming`; a payment that is already on-chain or
credited returns `409 PAYMENT_IN_FLIGHT` with the live status in `details`. If
funds arrive after a cancel, the wallet is still credited automatically — a
cancel never loses a payment.

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "intentId": "cryptoIntent_2vXq7p9yzAbCdEf",
      "paymentStatus": "cancelled",
      "cancelled": true
    },
    "meta": {
      "request_id": "req_crypto_cancel_001",
      "timestamp": "2026-07-03T12:05:00Z"
    }
  }
  ```
</ResponseExample>

***

## Pay by link

Collect a one-off customer payment over any reachable channel. Orbit mints a
Stripe-hosted Checkout link and renders a request-to-pay message for the
highest-priority channel the contact can receive it on (SMS, RCS, WhatsApp,
email, or a voice IVR read-out). Nothing is sent for you — the rendered
message and hosted URL are returned for your own send pipeline. These
endpoints require the `owner`, `admin`, or `billing` role.

### Create a Pay-by-Link

`POST /api/v1/billing/pay-by-link`

Selects the delivery channel, mints the hosted Checkout Session, and renders
the message body. Returns `422 NO_REACHABLE_CHANNEL` when none of the
preferred channels is reachable for the contact.

<ParamField body="amountMinor" type="integer" required>
  Amount to collect in minor units. Min `1`, max `99999999`.
</ParamField>

<ParamField body="currency" type="string" required>
  3-letter ISO 4217 currency code (e.g. `usd`).
</ParamField>

<ParamField body="description" type="string" required>
  What the payment is for, 1–500 characters. Shown on the Checkout page and in
  the rendered message.
</ParamField>

<ParamField body="preferredChannels" type="string[]" required>
  Delivery channels in descending priority order. Each of
  `sms`, `rcs`, `whatsapp`, `email`, `voice`.
</ParamField>

<ParamField body="reachableChannels" type="string[]" required>
  Channels the contact is currently reachable on. The first `preferredChannels`
  entry that also appears here is chosen.
</ParamField>

<ParamField body="successUrl" type="string" required>
  Redirect URL after payment. Must be on a trusted origin (`orbit.devotel.io`).
</ParamField>

<ParamField body="cancelUrl" type="string" required>
  Redirect URL if the payer cancels. Must be on a trusted origin.
</ParamField>

<ParamField body="conversationId" type="string">
  Optional conversation to associate the payment with.
</ParamField>

<ParamField body="contactId" type="string">
  Optional contact to associate the payment with.
</ParamField>

<ParamField body="merchantName" type="string">
  Optional merchant name to render in the message.
</ParamField>

<ParamField body="locale" type="string">
  Optional BCP-47 locale for the rendered message (e.g. `en-US`).
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/billing/pay-by-link \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "amountMinor": 4999,
      "currency": "usd",
      "description": "Invoice #1042",
      "preferredChannels": ["sms", "email"],
      "reachableChannels": ["email"],
      "successUrl": "https://orbit.devotel.io/pay/ok",
      "cancelUrl": "https://orbit.devotel.io/pay/cancelled"
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "id": "payByLink_2vXq7p9yzAbCdEf",
      "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_abc123...",
      "channel": "email",
      "message": "Pay $49.99 for Invoice #1042: https://checkout.stripe.com/c/pay/cs_live_abc123..."
    },
    "meta": {
      "request_id": "req_pbl_001",
      "timestamp": "2026-07-03T12:00:00Z"
    }
  }
  ```
</ResponseExample>

### Preview a Pay-by-Link Message

`POST /api/v1/billing/pay-by-link/preview`

Render the request-to-pay message for a URL you already have, without minting
a new Checkout Session. Takes the same `preferredChannels` / `reachableChannels`
selection plus `url`, `amountMinor`, `currency`, `description`, and the
optional `merchantName` / `locale`. Returns the selected channel and rendered
message, or `422 NO_REACHABLE_CHANNEL`.

***

## Transactions ledger

### List Transactions

`GET /api/v1/billing/transactions?limit=30&type=debit&cursor_ts=...&cursor_id=...`

Cursor-paginated read of `public.credit_transactions`. Negative
`amount_minor` values are spends (sends, voice minutes, AI tokens, etc.);
positive values are top-ups and refunds. Cursor is composite
(`created_at`, `id`) so ties on the same nanosecond don't skip or
duplicate.

<ParamField query="limit" type="integer" default="30">
  Page size, max 500.
</ParamField>

<ParamField query="type" type="string">
  Optional exact-match filter on a ledger row's `type`. Common credit
  types are `credit_purchase` (wallet top-up), `admin_grant`, `bonus`,
  `refund`, and `credit` (other wallet credits, e.g. trial or sub-account
  transfers); common debit types are `debit` (per-message, per-minute, or
  number-lookup usage), `credit_refunded` and `refund_clamp` (top-up
  refund offsets), and `admin_deduct`. The filter matches the stored value
  exactly, so an unrecognised value returns no rows.
</ParamField>

<ParamField query="cursor_ts" type="string">
  ISO-8601 timestamp from the previous page's `next_cursor.cursor_ts`.
</ParamField>

<ParamField query="cursor_id" type="string">
  Row id from the previous page's `next_cursor.cursor_id`.
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl "https://api.orbit.devotel.io/api/v1/billing/transactions?limit=30" \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "transactions": [
        {
          "id": "ct_2vXq7p9yzAbCdEf",
          "type": "credit_purchase",
          "amount_minor": 10000,
          "reference": "stripe_session:cs_live_abc123",
          "metadata": { "currency": "usd", "fx_rate": 1.0 },
          "created_at": "2026-05-16T11:22:33.444Z",
          "expires_at": null,
          "refunded_at": null
        },
        {
          "id": "ct_2vXq7p9yzAbCdEe",
          "type": "debit",
          "amount_minor": -42,
          "reference": "charge:sms_msg_01J9KP3R5W8YEQ4DXG6N7H2VKZ",
          "metadata": { "channel": "sms", "country": "1" },
          "created_at": "2026-05-16T11:21:18.901Z",
          "expires_at": null,
          "refunded_at": null
        }
      ],
      "next_cursor": {
        "cursor_ts": "2026-05-16T11:21:18.901Z",
        "cursor_id": "ct_2vXq7p9yzAbCdEe"
      },
      "has_more": true
    },
    "meta": {
      "request_id": "req_tx_001",
      "timestamp": "2026-05-16T12:00:00Z"
    }
  }
  ```
</ResponseExample>

***

## Usage analytics

### Get Usage By Channel

`GET /api/v1/billing/usage-by-channel?days=30&group_by=channel`

Per-channel spend + volume breakdown sourced from `credit_transactions`.
Powers the dashboard usage card. Supports `group_by` axes
`channel | country | campaign | sender | cost_center` for slicing the same
spend window. `cost_center` rolls spend up by the chargeback tag set on a
send's `metadata.cost_center`, so internal cost centers can attribute spend
to a project, department, or customer (untagged sends bucket under
`untagged`).

<ParamField query="days" type="integer" default="30">
  Lookback window, 1–90.
</ParamField>

<ParamField query="group_by" type="string" default="channel">
  `channel`, `country`, `campaign`, `sender`, or `cost_center`.
</ParamField>

<ParamField query="channel" type="string">
  Optional filter narrowing to a single channel (e.g. `sms`, `voice`).
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl "https://api.orbit.devotel.io/api/v1/billing/usage-by-channel?days=30" \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": [
      {
        "key": "sms",
        "channel": "sms",
        "total_messages": 3420,
        "delivered": 3380,
        "failed": 12,
        "total_cost_minor": 27360,
        "total_cost": 273.6000
      },
      {
        "key": "voice",
        "channel": "voice",
        "total_messages": 84,
        "delivered": 78,
        "failed": 6,
        "total_cost_minor": 11280,
        "total_cost": 112.8000
      }
    ],
    "meta": {
      "request_id": "req_usage_001",
      "timestamp": "2026-05-16T12:00:00Z"
    }
  }
  ```
</ResponseExample>

<Note>
  **Per-channel billing rules**

  * **SMS / MMS** — charged on `submitted` (carrier accepted handoff). DLR
    `delivered` updates the row's `status` but doesn't double-bill.
  * **Voice** — billed in 60s increments after `answered`; `busy` / `no-answer` is free.
  * **WhatsApp BYO** — Orbit does not charge messaging fees; the tenant's
    own WABA cost is settled directly with Meta. Orbit only charges AI
    compose tokens when an agent generates the message body.
  * **RCS / Email / Push** — charged on `submitted` (carrier or Resend accepted).
  * **Viber Tier 1 (SMPP)** — charged on `submitted` (carrier accepted
    handoff), same as SMS/MMS. A later DLR `delivered` (Viber state=5)
    updates the row's `status` but doesn't double-bill. Pre-submit
    rejections (the provider never accepts the handoff) are free.

  See [Pricing](/api-reference/pricing) for the per-channel rate card.
</Note>

### Get Spend Series

`GET /api/v1/billing/spend-series?days=30`

Per-day spend (positive minor units) over the lookback window plus a
7-day moving-average forward projection. Anomaly days (spend > mean + 2σ)
are flagged for callout in the dashboard chart.

<ParamField query="days" type="integer" default="30">
  Lookback window in days, 1–90.
</ParamField>

### Get Burn Rate

`GET /api/v1/billing/burn-rate?days=30`

Returns the trailing average daily spend and the projected number of
days remaining before the wallet hits zero (`balance_cents / avg_daily_spend`).
Powers the dashboard's BalanceWidget "X days remaining" inline copy.

### What-if pricing preview

`POST /api/v1/billing/whatif-pricing/preview`

Re-price a window of your own recorded usage against a candidate rate card
and get back the exact per-lane and total cost delta versus what you pay
today. The traffic is held fixed — only the rates change — so the result is
a faithful re-pricing of real usage, not a forecast of hypothetical volume.
Use it to back-test a negotiated sheet, a new plan, or a competitor's
published rates before you commit.

Read-only: it moves no money, writes no record, and can be run as often as
you like. Restricted to the `owner`, `admin`, and `billing` roles. The
dashboard's interactive [What-if simulator](https://orbit.devotel.io/changelog/2026-07-02-whatif-pricing-simulator)
(Billing → Pricing → What-if simulator) covers the quick per-channel case;
this endpoint is the programmatic path when you need per-country, per-direction,
and per-sub-type granularity.

<ParamField body="window_days" type="integer" default="30">
  Look-back window in days, `1`–`90`. Usage recorded in this window is
  aggregated into billing lanes (channel × country × direction × sub-type).
</ParamField>

<ParamField body="candidate_card" type="object[]" required>
  The candidate rate card — at least one row, up to 5000. A row is a partial
  override: any lane the card does not price keeps its current cost
  (`candidate_matched: false`, zero delta), exactly like an org pricing
  override layers over base rates. Country-specific rows take precedence over
  `*` wildcards, and finer sub-types (e.g. WhatsApp `marketing` vs `utility`)
  are matched the same way as on your live bill.

  <Expandable title="candidate_card[] row">
    <ParamField body="candidate_card[].channel" type="string" required>
      Billing channel the row prices, e.g. `sms`, `mms`, `whatsapp`, `rcs`,
      `viber`, `voice`. Lower-cased on input.
    </ParamField>

    <ParamField body="candidate_card[].country_code" type="string" default="*">
      ISO 3166-1 alpha-2 code (e.g. `US`) or the `*` wildcard for all
      destinations on the channel.
    </ParamField>

    <ParamField body="candidate_card[].direction" type="string" default="mt">
      `mt` (outbound) or `mo` (inbound).
    </ParamField>

    <ParamField body="candidate_card[].sub_type" type="string">
      Optional message sub-type to match (e.g. `marketing`, `utility`). Omit
      for a sub-type-agnostic row.
    </ParamField>

    <ParamField body="candidate_card[].rate_per_unit" type="number" required>
      Wholesale base price, USD per unit (per message, or per voice minute).
    </ParamField>

    <ParamField body="candidate_card[].markup_bps" type="integer">
      Proportional markup in basis points (`5000` = 50%). Default `0`.
    </ParamField>

    <ParamField body="candidate_card[].markup_fixed_cents" type="number">
      Flat markup added per unit, in cents. Default `0`.
    </ParamField>

    <ParamField body="candidate_card[].absolute_price_cents" type="number">
      Absolute per-unit price in cents. When set, it wins over
      `rate_per_unit` + markups.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="label" type="string">
  Optional human label echoed back in the response (e.g. `Plivo Growth 2026`),
  max 120 characters.
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X POST "https://api.orbit.devotel.io/api/v1/billing/whatif-pricing/preview" \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "window_days": 30,
      "label": "Negotiated SMS sheet",
      "candidate_card": [
        { "channel": "sms", "country_code": "US", "direction": "mt", "rate_per_unit": 0.0065 }
      ]
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "window_days": 30,
      "label": "Negotiated SMS sheet",
      "lanes": [
        {
          "channel": "sms",
          "country_code": "US",
          "direction": "mt",
          "sub_type": null,
          "quantity": 3420,
          "current_unit_price_cents": 0.8,
          "current_total_cents": 2736,
          "candidate_unit_price_cents": 0.65,
          "candidate_total_cents": 2223,
          "candidate_matched": true,
          "delta_cents": -513,
          "delta_percent": -18.75
        },
        {
          "channel": "voice",
          "country_code": "US",
          "direction": "mt",
          "sub_type": null,
          "quantity": 84,
          "current_unit_price_cents": 60,
          "current_total_cents": 5040,
          "candidate_unit_price_cents": 60,
          "candidate_total_cents": 5040,
          "candidate_matched": false,
          "delta_cents": 0,
          "delta_percent": 0
        }
      ],
      "totals": {
        "current_total_cents": 7776,
        "candidate_total_cents": 7263,
        "delta_cents": -513,
        "delta_percent": -6.6,
        "lane_count": 2,
        "matched_lane_count": 1,
        "total_quantity": 3504
      }
    },
    "meta": {
      "request_id": "req_whatif_001",
      "timestamp": "2026-07-02T12:00:00Z"
    }
  }
  ```
</ResponseExample>

<Note>
  All money fields are in USD cents; `*_unit_price_cents` carries sub-cent
  precision while `*_total_cents` is a whole-cent, rounded-up (never
  under-bill) total. A negative `delta_cents` / `delta_percent` means the
  candidate card is cheaper for your mix. `candidate_matched: false` flags a
  lane the candidate card does not cover — spot these before you commit.
  `delta_percent` is `null` when the current cost is `0` but the candidate
  cost is not.
</Note>

***

## Budget cap and spend monitoring

Two read-only endpoints for keeping spend under control: a monthly budget cap
you can check before an action, and a velocity-anomaly signal that flags a
sudden surge before the hard daily caps bite. Both require the `owner`,
`admin`, or `billing` role.

### Get Budget Cap

`GET /api/v1/billing/budget-cap`

Returns the organization's monthly budget cap (when one is configured), the
current calendar-month period boundaries in UTC, and month-to-date spend. Use
it to warn a user before an action would push spend past the cap — for
example, showing a "would exceed monthly cap" banner before launching a
campaign.

Response fields:

* `cap_cents` — the monthly cap in minor units (cents), or `null` when no cap
  is configured.
* `period_start` / `period_end` — ISO-8601 UTC boundaries of the current
  calendar month. `period_end` is exclusive (the first instant of next month).
* `current_spend_cents` — spend so far this month, in minor units.

Returns `404` when the organization record is missing or has been deleted.

<RequestExample>
  ```bash theme={null}
  curl https://api.orbit.devotel.io/api/v1/billing/budget-cap \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "cap_cents": 500000,
      "period_start": "2026-07-01T00:00:00.000Z",
      "period_end": "2026-08-01T00:00:00.000Z",
      "current_spend_cents": 128450
    },
    "meta": {
      "request_id": "req_budget_cap_001",
      "timestamp": "2026-07-03T12:00:00Z"
    }
  }
  ```
</ResponseExample>

### Detect Spend Anomalies

`GET /api/v1/billing/spend-anomaly`

Flags channels whose current spend velocity has surged past the organization's
trailing baseline. Each channel's spend so far today is extrapolated to a
full-day rate and compared against the average of the trailing baseline
window; a channel projecting past the baseline by the surge multiple (and
above an absolute floor) is flagged. This catches a compromised account
*before* the hard daily caps are exhausted. Flagged channels are also recorded
for operator review, deduplicated to one alert per channel per UTC day.

Response fields:

* `anomalies` — array of flagged channels. Each has `channel`,
  `baseline_daily_minor`, `today_spend_minor`, `projected_daily_minor`,
  `surge_ratio` (projected ÷ baseline, or `null` for a channel with no prior
  spend), and `severity` (`high` or `critical`).
* `detected` — `true` when `anomalies` is non-empty.
* `baseline_days` — trailing complete days used for the baseline (14).
* `surge_multiplier` — the multiple over baseline required to flag (10).
* `min_projected_daily_minor` — the absolute projected-spend floor a channel
  must clear to flag, in minor units (25000 = \$250/day).
* `day_fraction_elapsed` — fraction of the current UTC day elapsed, used for
  the extrapolation.

All monetary fields are in minor units (cents).

<RequestExample>
  ```bash theme={null}
  curl https://api.orbit.devotel.io/api/v1/billing/spend-anomaly \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "anomalies": [
        {
          "channel": "sms",
          "baseline_daily_minor": 4200,
          "today_spend_minor": 96000,
          "projected_daily_minor": 288000,
          "surge_ratio": 68.6,
          "severity": "critical"
        }
      ],
      "detected": true,
      "baseline_days": 14,
      "surge_multiplier": 10,
      "min_projected_daily_minor": 25000,
      "day_fraction_elapsed": 0.333
    },
    "meta": {
      "request_id": "req_spend_anomaly_001",
      "timestamp": "2026-07-03T08:00:00Z"
    }
  }
  ```
</ResponseExample>

***

## Committed-use drawdown

### Get Commitment Drawdown

`GET /api/v1/billing/commitment`

Read-only drawdown meter for organizations on a negotiated monthly spend
commitment. Reports how much of the commit has been drawn down this period, a
linear run-rate projection, and the projected end-of-period true-up
(minimum-commit shortfall if under pace, overage if exceeded). Requires the
`owner`, `admin`, or `billing` role.

Organizations without a commitment configured receive `{ "commitment": null }`
alongside the current period boundaries and spend, so a dashboard panel can
self-hide. Returns `404` when the organization record is missing.

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "commitment": {
        "monthly_commit_cents": 1000000,
        "discount_percent": 15,
        "currency": "USD",
        "term_start": "2026-01-01",
        "term_end": "2026-12-31"
      },
      "period_start": "2026-07-01T00:00:00.000Z",
      "period_end": "2026-08-01T00:00:00.000Z",
      "drawdown": {
        "committed_cents": 1000000,
        "consumed_cents": 420000,
        "remaining_cents": 580000,
        "drawdown_percent": 42,
        "overage_cents": 0,
        "shortfall_cents": 580000,
        "projected_spend_cents": 1302000,
        "projected_overage_cents": 302000,
        "projected_shortfall_cents": 0,
        "days_elapsed": 10,
        "days_in_period": 31,
        "days_remaining": 21,
        "status": "on_track"
      }
    },
    "meta": {
      "request_id": "req_commitment_001",
      "timestamp": "2026-07-03T12:00:00Z"
    }
  }
  ```
</ResponseExample>

***

## Pricing previews

Read-only "see what it would cost" estimators. Each runs a pure pricing
calculator over the inputs you supply and returns the computed breakdown — no
money moves, no rows are written, and no charges fire. All require the `owner`,
`admin`, or `billing` role.

### Preview Volume-Tier Discount

`GET /api/v1/billing/volume-tier-preview?channel=sms&projected_volume=250000`

Given a channel, a hypothetical monthly `projected_volume`, and an optional
destination `country_code`, returns the discount ladder your organization
bills against for that channel (your negotiated tiers, else the platform
defaults), the per-unit price the projected volume earns, and the projected
spend and savings versus the undiscounted rack rate.

<ParamField query="channel" type="string" required>
  Channel to price (e.g. `sms`, `whatsapp`, `voice`).
</ParamField>

<ParamField query="projected_volume" type="integer" required>
  Hypothetical monthly volume, a positive integer.
</ParamField>

<ParamField query="country_code" type="string">
  Optional ISO 3166-1 alpha-2 destination (e.g. `US`) for a country-specific
  ladder.
</ParamField>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "channel": "sms",
      "country_code": "US",
      "projected_volume": 250000,
      "has_tiers": true,
      "source": "org",
      "country_specific": true,
      "rack_price_cents_per_unit": 80,
      "effective_price_cents_per_unit": 64,
      "discount_percent": 20,
      "projected_spend_cents": 16000000,
      "rack_spend_cents": 20000000,
      "savings_cents": 4000000,
      "tiers": [
        {
          "tier_id": "tier_1",
          "min_volume": 0,
          "max_volume": 100000,
          "price_cents_per_unit": 80,
          "discount_percent": 0,
          "current": false
        },
        {
          "tier_id": "tier_2",
          "min_volume": 100000,
          "max_volume": null,
          "price_cents_per_unit": 64,
          "discount_percent": 20,
          "current": true
        }
      ]
    },
    "meta": {
      "request_id": "req_vtp_001",
      "timestamp": "2026-07-03T12:00:00Z"
    }
  }
  ```
</ResponseExample>

### Simulate a Rate Card (What-If Pricing)

`POST /api/v1/billing/whatif-pricing/preview`

Replays your organization's own recorded usage over the last N days through a
candidate rate card and returns the exact per-lane and total cost delta versus
today's live rates. Use it to back-test a proposed rate card, volume tier, or
committed-use plan against real traffic before switching.

<ParamField body="window_days" type="integer" default="30">
  Look-back window, 1–90 days.
</ParamField>

<ParamField body="candidate_card" type="object[]" required>
  The candidate rate card, at least one row. Each row: `channel` (required),
  `country_code` (ISO alpha-2 or `*`, default `*`), `direction` (`mt` or `mo`,
  default `mt`), optional `sub_type`, `rate_per_unit`, and optional
  `markup_bps`, `markup_fixed_cents`, `absolute_price_cents`.
</ParamField>

<ParamField body="label" type="string">
  Optional label echoed back in the response (e.g. `Growth plan 2026`).
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/billing/whatif-pricing/preview \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "window_days": 30,
      "label": "Growth plan 2026",
      "candidate_card": [
        {
          "channel": "sms",
          "country_code": "US",
          "direction": "mt",
          "rate_per_unit": 0.006
        }
      ]
    }'
  ```
</RequestExample>

<Note>
  The response contains `window_days`, the echoed `label`, a per-lane `lanes`
  array (current vs candidate cost), and a `totals` object with the aggregate
  cost delta. Lanes the candidate card does not cover are flagged so you can
  spot gaps before committing.
</Note>

### Preview AIaaS Usage Pricing

`POST /api/v1/billing/aiaas-usage-pricing/preview`

Estimates the AIaaS pure-usage charge breakdown across the message,
voice-minute, and resolved-outcome axes for a hypothetical metered period.
Send a `usage` object (`messageCount`, `voiceMinutes`, `resolvedOutcomes[]`)
and an optional `config`; omit `config` to use the committed pure-usage
defaults. Returns the computed charge breakdown in micro-cents.

### Preview Marketplace Rev-Share

`POST /api/v1/billing/marketplace-rev-share/preview`

Estimates the marketplace rev-share split and per-builder payout rollup for a
set of metered agent/tool `invocations` under a `pricingByListing` map, with an
optional `minPayoutMicroCents` threshold. Returns the rev-share `breakdown`
and the aggregated `payouts`.

### Preview Resolution Usage Meter

`POST /api/v1/billing/resolution-usage-meter/preview`

Projects a set of confirmed AI `resolutions` into idempotent usage events, a
per-period meter rollup, and pay-per-resolution invoice line descriptors. Send
`resolutions[]` and an optional outcome-pricing `config` (omit for the opt-out
defaults, which produce no invoice lines). Returns `events`, `rollup`, and
`lineItems`.

### Preview Margin Rollup

`POST /api/v1/billing/margin-rollup/preview`

Rolls a set of reseller profitability `lines` up into net margin per
sub-account, per channel, and per region, so you can see which parts of your
book actually make money — not just what you billed. Each line pairs the
revenue you billed a sub-account with the cost of goods sold (the carrier or
wholesale cost) for the same usage:

<ParamField body="lines[].subaccount_id" type="string" required>
  The sub-account the usage is attributed to.
</ParamField>

<ParamField body="lines[].channel" type="string" required>
  The channel the usage ran on (for example `sms`, `voice`, `whatsapp`, `email`).
</ParamField>

<ParamField body="lines[].region" type="string">
  Destination region or country bucket. Omitted lines roll up under `unknown`.
</ParamField>

<ParamField body="lines[].currency" type="string" required>
  ISO-4217 currency. Every line must share one currency; mixed currencies
  return `422 VALIDATION_ERROR`.
</ParamField>

<ParamField body="lines[].revenue_minor" type="integer" required>
  Revenue billed for the line, in minor units (cents). Maps directly onto the
  `amount_minor` a meter rollup returns per sub-account.
</ParamField>

<ParamField body="lines[].cost_minor" type="integer" required>
  Cost of goods sold for the line, in minor units. Maps onto the meter rollup's
  `base_amount_minor` (the carrier pass-through), or supply your own carrier
  cost figures.
</ParamField>

<ParamField body="lines[].quantity" type="number">
  Billable units the line represents (informational; summed per group).
</ParamField>

<ParamField body="currency" type="string">
  Optionally force the rollup currency; omitted uses the first line's currency.
</ParamField>

The two money fields line up with the per-sub-account `ledger_entries` from
[`POST /api/v1/usage/meters/rollup`](/api-reference/usage-metering), so you can
feed a rated period straight in. The report moves no money and stores nothing.

Returns `totals` plus `by_subaccount`, `by_channel`, and `by_region` arrays
(each sorted by net margin, most profitable first). Every group carries
`revenue_minor`, `cost_minor`, `margin_minor` (negative when a line sold below
cost), `margin_pct_bps` (margin as basis points of its own revenue), and
`revenue_share_bps` (its share of total revenue).

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "currency": "USD",
      "totals": {
        "revenue_minor": 1000,
        "cost_minor": 600,
        "margin_minor": 400,
        "margin_pct_bps": 4000,
        "quantity": 500,
        "line_count": 1
      },
      "by_subaccount": [
        {
          "key": "sub_a",
          "revenue_minor": 1000,
          "cost_minor": 600,
          "margin_minor": 400,
          "margin_pct_bps": 4000,
          "revenue_share_bps": 10000,
          "quantity": 500,
          "line_count": 1
        }
      ],
      "by_channel": [
        {
          "key": "sms",
          "revenue_minor": 1000,
          "cost_minor": 600,
          "margin_minor": 400,
          "margin_pct_bps": 4000,
          "revenue_share_bps": 10000,
          "quantity": 500,
          "line_count": 1
        }
      ],
      "by_region": [
        {
          "key": "US",
          "revenue_minor": 1000,
          "cost_minor": 600,
          "margin_minor": 400,
          "margin_pct_bps": 4000,
          "revenue_share_bps": 10000,
          "quantity": 500,
          "line_count": 1
        }
      ]
    },
    "meta": {
      "request_id": "req_margin_001",
      "timestamp": "2026-07-07T12:00:00Z"
    }
  }
  ```
</ResponseExample>

***

## Outcome-based pricing

Configure per-resolved-outcome AI-agent billing (a fixed charge each time an
agent resolves a conversation, instead of per-token). Charges fire downstream
when a conversation passes its rubric and the organization has opted in — this
config surface moves no money itself. Requires the `owner`, `admin`, or
`billing` role. Rates are in micro-cents (1 cent = 1,000,000 micro-cents; the
\$0.005 benchmark is `500000`).

### Get Outcome Pricing

`GET /api/v1/billing/outcome-pricing`

Returns the current config, or the opt-out defaults (`enabled: false`, the
benchmark rate, no per-outcome overrides) when it has never been set.

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "enabled": false,
      "defaultRateMicroCents": 500000,
      "perOutcomeRateMicroCents": {}
    },
    "meta": {
      "request_id": "req_outcome_get_001",
      "timestamp": "2026-07-03T12:00:00Z"
    }
  }
  ```
</ResponseExample>

### Update Outcome Pricing

`PUT /api/v1/billing/outcome-pricing`

Enable or disable outcome billing and set the rates. Returns the persisted
config. Returns `422 VALIDATION_ERROR` when a rate is out of range.

<ParamField body="enabled" type="boolean" default="false">
  Whether outcome charges are applied for this organization.
</ParamField>

<ParamField body="defaultRateMicroCents" type="integer" default="500000">
  Charge per resolved outcome in micro-cents, `0`–`1000000000` (\$10.00).
</ParamField>

<ParamField body="perOutcomeRateMicroCents" type="object" default="{}">
  Optional per-outcome-key rate overrides (each `0`–`1000000000` micro-cents).
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X PUT https://api.orbit.devotel.io/api/v1/billing/outcome-pricing \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "enabled": true,
      "defaultRateMicroCents": 500000,
      "perOutcomeRateMicroCents": { "appointment_booked": 2000000 }
    }'
  ```
</RequestExample>

***

## Seats

Report and manage agent seat tiers. `full` seats are billable; `light` seats
(read-only, KB-authoring, reporting, manager dashboards) are free. Reading the
breakdown is open to `owner`, `admin`, and `billing`; changing a seat tier is
restricted to `owner` and `admin`. All amounts are in minor units (cents).

### Get Seat Breakdown

`GET /api/v1/billing/seats`

Returns full vs light seat counts, the per-full-seat monthly rate, the
estimated monthly seat cost, and the estimated savings versus an all-full-seat
plan.

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "full_seats": 8,
      "light_seats": 5,
      "total_seats": 13,
      "per_full_seat_monthly_minor": 4900,
      "estimated_monthly_minor": 39200,
      "estimated_savings_vs_all_full_minor": 24500
    },
    "meta": {
      "request_id": "req_seats_001",
      "timestamp": "2026-07-03T12:00:00Z"
    }
  }
  ```
</ResponseExample>

### Update a Seat Type

`PATCH /api/v1/billing/seats/{userId}`

Flip a user between `full` and `light`, then return the recomputed breakdown.
Returns `404` when the user is not in your organization. Requires the `owner`
or `admin` role.

<ParamField path="userId" type="string" required>
  ID of a user in your organization.
</ParamField>

<ParamField body="seat_type" type="string" required>
  `full` or `light`.
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X PATCH https://api.orbit.devotel.io/api/v1/billing/seats/user_2vXq7p9yzAbCdEf \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{ "seat_type": "light" }'
  ```
</RequestExample>

***

## Trial credits

Grant and check the one-time promotional trial credit. Requires the `owner` or
`admin` role.

### Get Trial Credit Status

`GET /api/v1/billing/trial-credits/status`

Reports whether the trial credit has already been granted for your
organization, so the "Claim trial credit" control can hide once it has fired.
Always scoped to your own organization.

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": { "granted": false },
    "meta": {
      "request_id": "req_trial_status_001",
      "timestamp": "2026-07-03T12:00:00Z"
    }
  }
  ```
</ResponseExample>

### Grant Trial Credits

`POST /api/v1/billing/trial-credits`

Grants the trial credit to your organization. The grant is one-time — a repeat
call is a no-op. `amount` is bounded by a per-tenant cap; a request above the
cap returns `422 TRIAL_CREDIT_CAP_EXCEEDED`.

<ParamField body="amount" type="integer">
  Optional credit amount in minor units (cents). Omit to grant the default
  promotional amount.
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/billing/trial-credits \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{ "amount": 500 }'
  ```
</RequestExample>

***

## Invoices

### List Invoices

`GET /api/v1/billing/invoices`

Retrieve the org's invoices, newest first. Results are cursor-paginated.

<ParamField query="status" type="string">
  Filter by lifecycle status. One of `draft`, `issued`, `paid`, `void`, `synced`.
</ParamField>

<ParamField query="cursor" type="string">
  Opaque pagination cursor. Pass the `next_cursor` value from the previous page
  to fetch the next one.
</ParamField>

<ParamField query="limit" type="integer" default="25">
  Maximum number of invoices to return. Minimum `1`, maximum `100`.
</ParamField>

<RequestExample>
  ```bash theme={null}
  curl "https://api.orbit.devotel.io/api/v1/billing/invoices?limit=25" \
    -H "X-API-Key: dv_live_sk_your_key_here"
  ```
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "invoices": [
        {
          "id": "kix7Gsf7nYNitX4",
          "invoice_number": "INV-2026-003",
          "status": "paid",
          "currency": "USD",
          "amount_due": "0.00",
          "total": "100.00",
          "subtotal": "100.00",
          "invoice_date": "2026-05-16T11:22:33Z",
          "due_date": "2026-05-31T23:59:59Z",
          "period_start": "2026-05-01T00:00:00Z",
          "period_end": "2026-05-31T23:59:59Z",
          "hosted_invoice_url": "https://my.withorb.com/view/kix7Gsf7nYNitX4",
          "invoice_pdf": "https://my.withorb.com/invoices/kix7Gsf7nYNitX4.pdf",
          "issued_at": "2026-05-16T11:22:33Z",
          "paid_at": "2026-05-16T12:00:00Z"
        }
      ],
      "has_more": true,
      "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0xNiJ9"
    },
    "meta": {
      "request_id": "req_inv_001",
      "timestamp": "2026-05-16T12:00:00Z"
    }
  }
  ```
</ResponseExample>

<Note>
  Monetary fields (`amount_due`, `total`, `subtotal`) are decimal strings in the
  major currency unit (e.g. `"100.00"` is \$100.00) — no division required.
  `next_cursor` is `null` and `has_more` is `false` on the final page.
</Note>

***

## Refunds (owner only)

`POST /api/v1/billing/refunds` issues a Stripe refund against a specific
top-up `credit_transaction` lot (30-day window). `GET /api/v1/billing/refunds`
lists the org's refund history. Both endpoints are restricted to the org
owner role — unexpected refunds against a prepaid wallet are high-impact
and hard to reverse.

### Create a refund

`POST /api/v1/billing/refunds`

<ParamField body="credit_transaction_id" type="string" required>
  ID of the top-up `credit_transaction` lot to refund against, 1–128
  characters. Must belong to your org and be a `credit_purchase` lot; the
  refund is drawn from that lot's remaining unused balance.
</ParamField>

<ParamField body="amount_cents" type="integer" required>
  Partial refund amount in wallet minor units (USD cents). Integer, min `1`,
  max `1000000000`. Cannot exceed the lot's remaining refundable balance —
  the request returns `422 VALIDATION_ERROR` if it does.
</ParamField>

<ParamField body="reason" type="string">
  Optional free-text note (max 500 characters) attached to the Stripe refund
  metadata and the refund audit record.
</ParamField>

***

## Examples

### Node.js

```javascript theme={null}
import { Orbit } from '@devotel/orbit-sdk'

const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY! })

// Check balance
const balance = await orbit.billing.getBalance()
console.log(`Wallet: $${balance.data.balance_usd}`)

// Mint a top-up Checkout session
const session = await orbit.billing.topUp({
  amount: 10000,           // $100.00 in cents
  currency: 'usd',
  successUrl: 'https://yourapp.com/billing?topup=ok',
  cancelUrl: 'https://yourapp.com/billing?topup=cancelled',
  idempotencyKey: `topup_${Date.now()}_${userId}`,
})
// Redirect the user to session.data.checkoutUrl
```

<Note>
  A first-party Python SDK is on the roadmap but not yet shipped. Call the REST
  endpoints above with `requests` / `httpx` / any HTTP client.
</Note>
