# AI setup Source: https://docs.useunitpay.com/documentation/ai-setup Paste your pricing page URL and let Asterix draft your catalog — products, plans, and credits — in your sandbox. Setting up a billing catalog by hand — products, plans, features, credit currencies, grant rules, packages — is the slow part of adopting any billing engine. **Asterix**, UnitPay's onboarding agent, does the first draft for you: give it your pricing page and it builds a working catalog in your **sandbox**. ## What it does You give Asterix a **URL** (your pricing or plans page). It reads the page, extracts the pricing into a structured catalog, and **creates the objects live in your sandbox org** — in dependency order: ``` product → credit currencies → billable metrics → features → plan tiers → plans (charges · entitlements · grant rules) → credit packages ``` Credit setup is first-class: Asterix chooses the right [denomination](/documentation/credits/denominations) (`unit` vs fiat) per wallet, attaches recurring/one-time [grant rules](/documentation/credits/grants-and-priority) to plans, and creates [credit packages](/documentation/credits/top-ups) for top-ups — the same shapes you'd model by hand. It also returns a **tracking contract**: the exact `eventName`s to send to [`track`](/documentation/how-it-works#the-two-verbs) so your usage lines up with the catalog it built. ## Run it In the [dashboard](https://app.useunitpay.com), open **Import pricing**, paste your pricing URL, and watch Asterix build the catalog live. This is the easiest way to start. Kick off a run and poll for the result: ```bash theme={null} curl https://api.useunitpay.com/v1/onboarding \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://yourcompany.com/pricing" }' ``` ```json Response theme={null} { "id": "onb_...", "status": "queued" } ``` ```bash theme={null} curl https://api.useunitpay.com/v1/onboarding/onb_... \ -H "x-api-key: $UNITPAY_SECRET_KEY" ``` The result reports what was `created`, `skipped`, and `failed`, plus the `productId` and the tracking contract. Prefer a live stream? `POST /v1/onboarding/stream` returns server-sent events as the agent works. ## It's a first draft — verify it Asterix applies changes **autonomously to your sandbox** — there's no approval step before objects are created. Nothing goes live: sandbox is a safe, throwaway copy. Always review the drafted catalog before you rely on it. Treat the output as a fast starting point, not a final answer: * **It reads a URL, not prose or screenshots.** Point it at a real pricing page. * **Obscured pages extract poorly.** Prices hidden behind JavaScript or rendered as images may be missed — check them. * **Review before going live.** Confirm prices, credit amounts, and grant cadences, then promote to live yourself. * **Prefer to build by hand?** Every object Asterix creates, you can create directly — see [Billing](/documentation/billing) for the catalog API and [Credits](/documentation/credits/overview) for the credit side. ## Next steps With a catalog in place, subscribe a customer and run credits end to end. Understand the grants, denominations, and packages Asterix set up. # Authentication Source: https://docs.useunitpay.com/documentation/authentication API keys, the x-api-key header, and the difference between sandbox and live. Every request to the UnitPay API is authenticated with an **API key** sent in the `x-api-key` header. The base URL is: ``` https://api.useunitpay.com/v1 ``` ```bash theme={null} curl https://api.useunitpay.com/v1/customers \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" ``` ## Key types | Prefix | Name | Use it for | | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------ | | `upay_sk_…` | **Secret key** | Server-side calls. The only key that may **track usage** and move money. Never ship it to the browser. | | `upay_pk_…` | **Publishable key** | Safe-to-expose reads used by client SDKs. Cannot track usage or mutate billing. | Your secret key can charge customers and deduct credits. Keep it on your server and in environment variables — never in a client bundle, a repo, or a browser request. For customer-facing UIs, mint a short-lived portal token instead (see [React SDK](/react/introduction)). ## Sandbox vs live A key is bound to **one organization and one environment** — either **sandbox** (test) or **live**. The environment is baked into the key, not the URL or a header: the same key always hits the same environment. Get one of each from the [dashboard](https://app.useunitpay.com). A throwaway copy of your catalog for building and testing. Use Stripe's test cards — nothing charges a real card, no real money moves. This is where [Asterix](/documentation/ai-setup) drafts your catalog. Real customers, real cards, real invoices. Switch by swapping the key — your code doesn't change. Because the environment lives in the key, moving from sandbox to live is a one-line change: swap `upay_sk_…` (test) for `upay_sk_…` (live). Keep them in separate environment variables so a test key never runs in production. ## Store it in the environment ```bash .env theme={null} UNITPAY_SECRET_KEY=upay_sk_... ``` The [Node SDK](/node/introduction) reads `UNITPAY_API_KEY` automatically if you don't pass a key explicitly: ```ts theme={null} import { UnitPay } from '@unitpay/node'; const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_SECRET_KEY }); ``` ## Next steps Use your key to set up a catalog and run credits end to end. Let Asterix build your sandbox catalog from your pricing page. # Billing Source: https://docs.useunitpay.com/documentation/billing Plans, subscriptions, taking a card payment, and invoices — the dollar path for self-serve products. The [Quickstart](/documentation/getting-started/setup) ran the credit path with no card. This page covers the **dollar path**: what a plan is, how a customer subscribes and pays, and where the invoice lands. It's written for self-serve (PLG) products — subscriptions a customer starts themselves and pays by card. ## The catalog You sell **plans**, which live under a **product**. A plan carries: * **Charges** — the price (a `$20/month` recurring charge, a usage charge, a one-off). * **Entitlements** — the features the plan unlocks, read by [`check`](/documentation/how-it-works#the-two-verbs). * **Grant rules** — optional [credit grants](/documentation/credits/grants-and-priority) that refill each cycle. Create them with the catalog API (`POST /v1/products`, `POST /v1/plans`) or let [Asterix](/documentation/ai-setup) build the whole catalog from your pricing page. ## Subscribing and paying You always subscribe the same way — `POST /v1/subscriptions` — and the response tells you how money moved. It's a single **outcome** object discriminated by `kind`: | `kind` | When | What you do | | ------------------- | ------------------------------------------- | ----------------------------------------------------------------- | | `created_no_charge` | Free/\$0 plan, or trial with no card | Nothing — the subscription is active. | | `charged_inline` | Paid plan **and** a card already on file | Nothing — the card was charged and the first invoice is **paid**. | | `requires_form` | Paid plan, **no card yet** | Collect a card in the browser, then it settles. | | `deferred` | Change scheduled for later (e.g. downgrade) | Applies at the next cycle. | ```bash theme={null} curl https://api.useunitpay.com/v1/subscriptions \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "customerId": "cus_123", "planId": "pln_pro" }' ``` ### Collecting the first card The first card-on-file is collected in the **browser** with Stripe Elements — a card number can't be sent to the API directly. When `POST /v1/subscriptions` returns `requires_form`, it includes a client secret; mount the React SDK's payment form with it, and the subscription settles once the card is confirmed (a webhook provisions the subscription, invoice, and any credits). ```tsx theme={null} import { useCreateSubscription } from '@unitpay/react'; const { createSubscription } = useCreateSubscription({ onChargedInline: () => alert('Subscribed and paid ✓'), onRequiresForm: (r) => setFormClient(r.client), // render with this onCreated: () => alert('Subscription active.'), }); createSubscription({ planId: 'pln_pro' }); ``` Once a card is saved, later charges — renewals, plan changes, and [credit top-ups](/documentation/credits/top-ups) — are `charged_inline` automatically, no form needed. See the [React SDK](/react/introduction) for the full checkout UI. In sandbox, use Stripe's test card `4242 4242 4242 4242` with any future expiry and any CVC. ## Invoices When a paid subscription settles, UnitPay creates an **invoice** and auto-charges the card (`collectionMethod: "charge_automatically"`). Read a customer's invoices, filtered by status: ```bash theme={null} curl "https://api.useunitpay.com/v1/invoices?customerId=cus_123&status=paid" \ -H "x-api-key: $UNITPAY_SECRET_KEY" ``` Fetch one for its line items and payments: ```bash theme={null} curl https://api.useunitpay.com/v1/invoices/inv_123 \ -H "x-api-key: $UNITPAY_SECRET_KEY" ``` Renewals bill automatically each cycle. If a renewal charge fails, the subscription goes `past_due` and the open invoice can be paid with `POST /v1/invoices/:id/pay`. ## Next steps Sell credit packages and refill balances — same card-on-file flow. Pricing table, checkout, and the payment form. The two paths and the two verbs. The credit path, end to end. # Denominations Source: https://docs.useunitpay.com/documentation/credits/denominations Unit credits vs. fiat credits — two denominations that never mix. Every credit balance is denominated in one of two ways. The denomination decides what a "credit" means, how you display it, and — critically — which balances you're allowed to add together. Branch on `credit_currencies.denomination`. Never assume. ## The two denominations **Raw counts.** "AI Credits", "Lovable Credits", "API calls" — an abstract quantity you define. `denomination: unit`. **A prepaid currency balance.** "\$10 of credit", denominated in an ISO-4217 currency (`usd`, `eur`, …). `denomination: `. ### Unit credits A unit credit is just a count. You decide what one credit is worth in your product — one message, one image, one API call, or a share of several features via a credit system. There is no exchange rate to a currency; a "credit" only means something inside your app. Render unit credits with `formatCredits(n)`: ```tsx theme={null} formatCredits(1250); // "1,250 credits" ``` > **Example**
> A Pro plan grants **200 credits/month**. A basic message costs 1 credit, a premium message costs 10. The customer spends them in any mix they like. The balance is a count — there's no dollar figure attached to it. ### Fiat credits A fiat credit is prepaid money. The balance is denominated in a real currency and stored in that currency's minor units (cents for `usd`). "\$10 of credit" is a balance of `1000` in `usd`. Usage is resolved to a currency cost and drawn down against it. Render fiat credits as money with `` / `formatMoney(cents, code)` — never as a raw count: ```tsx theme={null} formatMoney(1000, "usd"); // "$10.00" formatMoney(750, "eur"); // "€7.50" ``` > **Example**
> A customer buys \*\*$10 of API credit**. Each request burns a fraction of a cent based on tokens used. Their balance falls from `$10.00`toward`\$0.00\` — it's a wallet of prepaid dollars, not a count. ## The hard rule: never mix denominations Unit credits and fiat credits — and two different fiat currencies — are **different kinds of thing**. Adding, ranking, or averaging across denominations produces a meaningless number. Never sum, rank, or compare balances across denominations. `250 credits + $10` is not `$260`, and it is not `260` of anything. Keep each denomination — and each fiat currency — in its own bucket. Concretely: * **Don't** total a customer's balances into one figure when they hold unit credits *and* fiat credits. * **Don't** sum `usd` and `eur` balances — they're separate wallets. * **Don't** cross the formatters: never `formatMoney` a unit count, never `formatCredits` a fiat balance. * **Don't** store a fiat amount as unit credits, or a count as a fiat balance. Everywhere you touch a balance, branch first: ```ts theme={null} if (currency.denomination === "unit") { render(formatCredits(balance)); } else { render(formatMoney(balance, currency.denomination)); // ISO-4217 code, e.g. "usd" } ``` Both denominations are first-class in the UI. Credit tabs, columns, and badges render on every relevant surface — a customer with several wallets sees each one on its own terms, single-currency, side by side. See [Wallets & the ledger](/documentation/credits/wallets-and-ledger). ## Modelling a fiat-credit system To make a credit system represent money, map each credit to a minor unit of the currency (1 credit = 1 cent) and price top-ups at 1 credit each. See [Top-ups](/documentation/credits/top-ups) for how a fiat balance is refilled. ## See also How the credit path fits together. Where balances live, per currency. Read the denominations configured on the account. Refill a balance — following the customer's collection method. # Grants & priority Source: https://docs.useunitpay.com/documentation/credits/grants-and-priority What a grant is, the priority convention, and how deductions pick a grant to draw from. A **grant** is a single deposit of credits into a customer's balance, from one source, with its own priority and (optionally) its own expiry. A customer's balance is the sum of all their open grants — and when usage is deducted, UnitPay has to decide *which grant to draw from first*. That decision is the priority convention. ## What a grant is Every credit a customer holds arrived as a grant. Grants come from different sources and stack: * **Plan grant** — the recurring allowance from a plan (the Lovable "1,000 credits/month"), refilled each cycle. * **Daily drip** — a small plan-level grant that refills every day. * **Rollover** — unused balance carried over from a prior period. * **Contract grant** — the committed allocation on an SLG/enterprise contract. * **Promotional** — a one-off grant you issue by hand (referral reward, goodwill, coupon). * **Package** — credits the customer bought as a self-serve top-up. See [Top-ups](/documentation/credits/top-ups). Each grant keeps its own record — its source, amount, remaining balance, and expiry — so you can always see exactly where a balance came from. This shows up as the per-grant `breakdown` behind a customer's balance (see [Wallets & the ledger](/documentation/credits/wallets-and-ledger)). ## The priority convention When usage is deducted, grants are consumed in **priority order — lowest number first**. Priorities are configurable per `credit_grant_rules`; the defaults encode a sensible convention that differs slightly between PLG (self-serve) and enterprise: | Priority | Grant type | PLG | Enterprise | | :------: | -------------------------------- | ------------------ | --------------- | | 0 | Daily drip (plan-level) | consume first | — | | 5 | Rollover (from expired contract) | — | consume first | | 10 | Plan grant / Contract grant | main allocation | committed spend | | 30 | Promotional | before packages | before packages | | 50 | Package (self-serve top-up) | preserve as buffer | overflow | | 100 | Package (post-contract) | — | dead last | The logic behind the numbers: **burn the things that expire or reset soonest, and preserve what the customer paid cash for.** Daily drips vanish tomorrow, so they go first. Purchased packages don't expire and represent real money, so they sit at the back as a buffer. ## FIFO within a priority When two or more grants share the same priority, UnitPay breaks the tie **FIFO**: ``` expiresAt ASC → createdAt ASC ``` The grant that expires soonest is drawn down first; if two expire at the same time (or neither expires), the older grant goes first. This guarantees that a customer never loses credits to expiry while newer, longer-lived credits sit unused. The same principle governs the dollar-side balance deduction order: shorter intervals before longer ones, so expiring allocation is always spent before permanent allocation. ## How the sources stack A single customer routinely holds several grants at once, and they draw down in convention order: > **Example (PLG)**
> A customer has a **daily drip** (priority 0, 50 credits, expires tonight), a **monthly plan grant** (priority 10, 1,000 credits), and a **package** they topped up with (priority 50, 500 credits).

> Usage burns the 50 drip credits first, then the 1,000 plan credits, and only touches the purchased 500 once everything cheaper-to-lose is gone — so the credits they paid for are the last to disappear. > **Example (enterprise)**
> A contract customer has **rollover** from a lapsed contract (priority 5), their **contract grant** (priority 10), and **overflow packages** (priority 50, then 100). Rollover — which would otherwise expire — is consumed before the committed contract spend, and purchased packages absorb the overflow last. ## See also The full track → resolve → deduct → ledger path. Where grants and deductions are recorded. Unit vs. fiat credits — and the rule never to mix them. Where package credits sit in the deduction order. # Credits overview Source: https://docs.useunitpay.com/documentation/credits/overview The credit-billing path end to end — track, resolve, deduct FIFO, and ledger. UnitPay is the billing engine where **dollars and credits are both first-class**. Most billing tools bolt credits on as an afterthought; here they run on the same catalog, the same subscriptions, and the same APIs as dollar billing — they're just a different way value moves out of the core. This page is the hub for the credit path: how it works, when to reach for it, and where to go next. ## Two paths, one core You model **what you sell** once and attach customers to it. From there, usage flows down one of two paths: ```mermaid actions={false} theme={null} flowchart LR USE["**Usage** — you report what happened"]:::core USE --> DOLLAR["**Dollar billing** (invoice-time)
aggregate → invoice → collect → PSP"]:::dollar USE --> CREDIT["**Credit billing** (track-time)
resolve charge → deduct FIFO → ledger"]:::credit classDef core fill:#7c3aed10,stroke:#7c3aed classDef dollar fill:#ec489920,stroke:#ec4899 classDef credit fill:#22c55e20,stroke:#22c55e ``` The difference is **when** money settles: | | Dollar billing | Credit billing | | ------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | **Settles** | At invoice time — usage aggregates and is billed at the end of the cycle | At track time — the balance is deducted the moment usage is reported | | **Customer holds** | A running tab that becomes an [invoice](/documentation/billing#invoices) | A prepaid [balance](/documentation/credits/wallets-and-ledger) that draws down | | **Runs out of money by** | Non-payment of an invoice | Hitting a zero balance | | **Real-time gate** | Entitlement check | `check` against remaining balance | A single plan can do both — a dollar price for the subscription and a credit grant that refills every cycle. ## The credit path, step by step ```mermaid actions={false} theme={null} flowchart LR T["**Track** usage"]:::s --> R["**Resolve** the charge
(how many credits this costs)"]:::s R --> D["**Deduct** FIFO from open grants"]:::s D --> L["**Ledger** the deduction
(append-only)"]:::s classDef s fill:#22c55e15,stroke:#22c55e ``` Your app reports what happened — a message sent, a token consumed, an API call made — with `track`. This is the same call the dollar path uses; UnitPay routes it by how the plan is priced. See [the two verbs](/documentation/how-it-works#the-two-verbs). UnitPay converts the raw usage into a credit cost. In a credit system, each feature has a credit cost per unit — 6 premium messages at 3 credits each resolves to 18 credits. The resolved cost is deducted from the customer's open grants in [priority order, FIFO within a priority](/documentation/credits/grants-and-priority). Expiring grants are spent before permanent ones, so nothing is wasted. Every grant and every deduction is written to the append-only [ledger](/documentation/credits/wallets-and-ledger). The customer's balance is always the sum of their open grants — never a number you mutate directly. ## When to reach for credits Credits fit when the customer pays (or is granted) **before** they use, and value is consumed action-by-action: * **Recurring allowances** — "1,000 credits/month" that refill each cycle, burned per message or per action (Lovable-style). * **Prepaid balances** — "\$10 of credit" bought upfront and drawn down per token in dollars (Anthropic-API-style). See [Denominations](/documentation/credits/denominations) for the unit-vs-fiat distinction. * **Daily free drips** on top of a paid allowance. * **Metered work with an unknown cost** — reserve a budget, then reconcile. See [Reserving before you spend](/documentation/credits/wallets-and-ledger#reserving-before-you-spend). Reach for **dollar billing** instead when the customer is invoiced for whatever they use at the end of the cycle — see [Billing](/documentation/billing). You don't have to choose globally: pick per feature, and mix both on one plan. ## Explore the credit path Unit credits vs. fiat credits — and the rule never to mix them. What a grant is, the priority convention, and FIFO tie-breaks. Credit accounts, their lifecycle, and the append-only record. Refill a balance manually, automatically, or by credit package. The Quickstart: grant credits, spend them, read the ledger. Render balances, ledger, top-ups, and burn in your app. # Top-ups Source: https://docs.useunitpay.com/documentation/credits/top-ups Refill a credit balance — manually, automatically, or by package — following the customer's collection method. A **top-up** adds credits to a customer's [wallet](/documentation/credits/wallets-and-ledger) outside the normal plan refill — the customer is running low and wants more now. UnitPay grants the credits and settles the money; the only question is *how* the money moves. ## How a top-up settles A self-serve customer has a saved payment method, so a top-up charges it right away: 1. **Charge** the saved card via the PSP for the top-up amount. 2. **Grant** the credits once the charge succeeds. Money in, then credits out — the balance reflects the purchase immediately. If no card is on file yet, the charge collects one in the browser first (the same [card-on-file flow](/documentation/billing#collecting-the-first-card) a subscription uses), then grants. ## Auto top-ups Auto top-ups make the refill automatic: when a balance falls below a threshold after a usage event, UnitPay purchases more credits without the customer lifting a finger — preventing service interruptions for customers who don't want to manage a balance by hand. You configure a per-feature threshold and a quantity to purchase (`PUT /v1/customers/:id/auto-topup-rules/:creditCurrencyId`); the charge hits the saved card. Purchase limits cap runaway spend, and burst suppression prevents duplicate purchases from rapid-fire usage. ## Credit packages A **credit package** is a pre-defined bundle of credits a customer buys as a self-serve top-up (priority 50 in the [grant convention](/documentation/credits/grants-and-priority) — preserved behind plan credits as a buffer). Packages are the manual counterpart to auto top-ups: the customer picks a bundle and checks out, and the same `collectionMethod` branch decides charge-now vs. invoice. For a \$-denominated wallet, a package is priced so one credit equals one minor currency unit — see [Denominations](/documentation/credits/denominations). ## In your app The [React credit hooks](/react/introduction) cover the whole top-up surface — reading balances, buying a package, triggering a top-up, configuring auto top-ups, and showing history. ## See also Where package credits sit in the deduction order. How a top-up lands as a grant on the wallet. Unit vs. fiat — and which top-up route applies. The card-on-file flow a top-up reuses. # Wallets & the ledger Source: https://docs.useunitpay.com/documentation/credits/wallets-and-ledger Credit accounts as wallets, their lifecycle, and the append-only ledger behind every balance. Behind every credit balance is a **wallet** — a credit account — and an **append-only ledger** of everything that ever happened to it. The balance you show a customer isn't a number you store and mutate; it's the sum of the wallet's open grants, derived from the ledger. ## Credit accounts are wallets A customer holds one credit account per [denomination](/documentation/credits/denominations) they transact in — a `unit` wallet for "AI Credits", a `usd` wallet for prepaid dollars, and so on. Each wallet is its own bucket; balances are never combined across wallets. A wallet accumulates [grants](/documentation/credits/grants-and-priority) (deposits) and deductions (usage), and its balance at any moment is: ``` balance = Σ (remaining of every open grant in the wallet) ``` Because the balance is derived, it's always internally consistent — there's no separate counter to drift out of sync with the grants. ## Wallet lifecycle A credit account moves through three states: | Status | Meaning | Can deduct? | Can grant? | | -------- | --------------------------------------------------------------------- | :---------: | :--------: | | `ACTIVE` | Normal operation | Yes | Yes | | `FROZEN` | Temporarily suspended — usage is blocked but the balance is preserved | No | — | | `CLOSED` | Retired — the wallet is done | No | No | ```mermaid actions={false} theme={null} stateDiagram-v2 [*] --> ACTIVE ACTIVE --> FROZEN: freeze FROZEN --> ACTIVE: unfreeze ACTIVE --> CLOSED: close FROZEN --> CLOSED: close ``` `FROZEN` is a pause, not a loss — grants stay on the books and become spendable again when the wallet returns to `ACTIVE`. `CLOSED` is terminal. The `ACTIVE` / `FROZEN` / `CLOSED` lifecycle is part of v2 credit accounts. Reach for `FROZEN` when you need to halt consumption without wiping a customer's balance — for example while a billing dispute is resolved. ## The ledger The **ledger** is the append-only record of every grant and every deduction against a wallet. Nothing is edited in place and nothing is deleted — a correction is a new entry, never a rewrite. That gives you: * **A complete audit trail** — every credit's origin and every unit's consumption is traceable. * **A derivable balance** — replay the open grants and you get the current balance, at any point in time. * **Explainable deductions** — you can show exactly which grant a unit of usage was drawn from and why (via the [priority convention](/documentation/credits/grants-and-priority)). Each ledger entry ties back to its source: a grant entry to its rule (plan, drip, rollover, promotional, package), a deduction entry to the usage event that caused it. ## Reserving before you spend For operations whose final cost you don't know upfront — an AI completion, a long-running job — you can **hold** balance on a wallet before the work starts, then confirm, adjust, or release the hold when it finishes. This reserve → work → finalize flow keeps concurrent requests from double-spending the same credits. ## Reading a wallet Read a customer's live per-wallet balance from `GET /v1/credit-accounts?customerId=…`, the grants behind it from `GET /v1/credit-grants?customerId=…`, and the full history from `GET /v1/credit-ledger?customerId=…`. In the app, the [React hooks](/react/introduction) render wallets, ledger, and burn directly. ## See also What a grant is and the order deductions draw from. How a top-up lands as a grant on the wallet. Render a customer's wallets and ledger in your UI. Grant, spend, and read the ledger end to end. # Quickstart Source: https://docs.useunitpay.com/documentation/getting-started/setup Set up a catalog, subscribe a customer, and watch credits burn — end to end in minutes. This quickstart runs the **credit path** end to end: you'll set up a catalog, put a customer on a plan that grants credits, spend some, and watch the balance and ledger update. Every step is a real API call — no card needed. When you're ready to charge cards and issue invoices, see [Billing](/documentation/billing). You'll need a **secret key** (`upay_sk_…`) from the [dashboard](https://app.useunitpay.com) and the base URL `https://api.useunitpay.com/v1`. See [Authentication](/documentation/authentication). Your catalog needs a **credit currency**, a **plan** that grants credits, and a **feature** to spend them on. The fastest way is to let [Asterix](/documentation/ai-setup) build it from your pricing page: ```bash theme={null} curl https://api.useunitpay.com/v1/onboarding \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://yourcompany.com/pricing" }' ``` Or build it by hand — a credit currency (`POST /v1/credit-currencies`) and a **free plan** with a monthly credit grant rule (`POST /v1/plans`). See [Billing](/documentation/billing) for the catalog API and [Credits](/documentation/credits/grants-and-priority) for grant rules. For this walkthrough you need a **free plan** (`$0`) that grants credits each cycle and a **feature** (e.g. `ai-generation`) that costs credits — so nothing has to be charged to see credits move. The customer's id is your own user or org id — pass your own `externalId` if you like. ```bash theme={null} curl https://api.useunitpay.com/v1/customers \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "name": "Acme", "email": "dev@acme.test" }' ``` ```json Response theme={null} { "id": "cus_123", "name": "Acme", "email": "dev@acme.test" } ``` Put the customer on your free plan. Because it's `$0`, the subscription is created with no charge and the plan's credits are granted on activation. ```bash theme={null} curl https://api.useunitpay.com/v1/subscriptions \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "customerId": "cus_123", "planId": "pln_free" }' ``` ```json Response theme={null} { "kind": "created_no_charge", "subscription": { "id": "sub_123", "status": "active" } } ``` Self-serve subscriptions default to `salesMotion: "self_serve"` and `collectionMethod: "charge_automatically"`. On a **paid** plan the response is `charged_inline` (card charged, first invoice paid) or `requires_form` (collect a card in the browser first) — that's the [Billing](/documentation/billing) flow. Confirm the credits landed. `check` is a read — it never changes state. ```bash theme={null} curl https://api.useunitpay.com/v1/check \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "customerId": "cus_123", "featureSlug": "ai-generation", "requestedUsage": 1 }' ``` ```json Response theme={null} { "access": true, "remaining": 100, "creditBalance": 100 } ``` Report that the customer used the feature. UnitPay resolves the credit cost and deducts it. This is the write that moves credits. ```bash curl theme={null} curl https://api.useunitpay.com/v1/usage/track \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "events": [{ "customerId": "cus_123", "eventName": "ai-generation", "quantity": 10 }] }' ``` ```ts Node theme={null} import { UnitPay } from '@unitpay/node'; const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_SECRET_KEY }); await unitpay.track({ customerId: 'cus_123', eventName: 'ai-generation', quantity: 10 }); ``` ```json Response theme={null} { "accepted": 1, "rejected": 0, "results": [{ "status": "ok", "flow": "credit", "creditsDeducted": "10", "creditBalance": "90" }] } ``` The balance is derived from the [ledger](/documentation/credits/wallets-and-ledger) — an append-only record of every grant and deduction. Read it back: ```bash theme={null} curl "https://api.useunitpay.com/v1/credit-ledger?customerId=cus_123" \ -H "x-api-key: $UNITPAY_SECRET_KEY" ``` You'll see the plan grant (`+100`) and your usage deduction (`−10`), with the running balance at `90`. The live per-wallet balance is at `GET /v1/credit-accounts?customerId=cus_123`. Credits granted → consumed → recorded. That's the whole credit path working end to end. ## Next steps Paid plans, the card-on-file flow, and invoices. Denominations, grant priority, wallets, and top-ups. Render balances, gates, and checkout with the React hooks. The model behind both billing paths. # How UnitPay works Source: https://docs.useunitpay.com/documentation/how-it-works One model behind both billing paths — and the two verbs your app calls at runtime. UnitPay is the billing engine where **dollars and credits are both first-class**. You model **what you sell** once — a catalog of products, plans, and (optionally) credit currencies — attach customers to it, and report usage. From there, everything comes down to two ideas: the **path** value takes when it moves out, and the **two verbs** your app calls at runtime. ## The two paths Usage flows down one of two paths. The difference is *when* money settles. ```mermaid actions={false} theme={null} flowchart LR USE["**Usage** — you report what happened"]:::core USE --> DOLLAR["**Dollar billing** (invoice-time)
aggregate → invoice → auto-charge → PSP"]:::dollar USE --> CREDIT["**Credit billing** (track-time)
resolve charge → deduct FIFO → ledger"]:::credit classDef core fill:#7c3aed10,stroke:#7c3aed classDef dollar fill:#ec489920,stroke:#ec4899 classDef credit fill:#22c55e20,stroke:#22c55e ``` | | **Dollar billing** | **Credit billing** | | --------------- | ----------------------------------------------- | ---------------------------------------------------- | | When you charge | At invoice time — usage adds up, you bill later | At usage time — credits come off a balance instantly | | The result | An **invoice**, auto-charged to a card | A **balance** that goes down and refills or tops up | | Feels like | A monthly bill | A prepaid wallet or an allowance | | Classic for | API metering | AI products, packs, plan allowances | You don't pick one for your whole business. You choose per plan, and a single plan can do both — a dollar price for the subscription *and* a credit grant that refills every cycle. ## The two verbs At runtime your app only ever does two things: it **checks** whether a customer is allowed to do something, and it **tracks** that they did it. ```mermaid actions={false} theme={null} flowchart LR C["**check** — is the customer allowed?
(before the action)"]:::v --> ACT["your app does the work"]:::a ACT --> T["**track** — record what happened
(after the action)"]:::v T --> SETTLE["credits deduct · usage meters · invoice accrues"]:::s classDef v fill:#22c55e15,stroke:#22c55e classDef a fill:#00302408,stroke:#003024 classDef s fill:#ec489915,stroke:#ec4899 ``` Ask UnitPay whether the customer has access before you do the work. ```bash theme={null} curl https://api.useunitpay.com/v1/check \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "customerId": "cus_123", "featureSlug": "ai-generation", "requestedUsage": 1 }' ``` ```json Response theme={null} { "access": true, "remaining": 42, "creditBalance": 42, "feature": { "slug": "ai-generation", "type": "credit" } } ``` `access` is `false` when the customer is out of balance or the feature isn't in their plan. In your UI, the React SDK does the same check synchronously — see [React SDK](/react/introduction). Report what happened. UnitPay routes the event by how the plan is priced: it **deducts credits**, meters usage for a dollar invoice, or both. ```bash theme={null} curl https://api.useunitpay.com/v1/usage/track \ -H "x-api-key: $UNITPAY_SECRET_KEY" \ -H "content-type: application/json" \ -d '{ "events": [{ "customerId": "cus_123", "eventName": "ai-generation", "quantity": 1 }] }' ``` ```json Response theme={null} { "accepted": 1, "rejected": 0, "results": [{ "status": "ok", "flow": "credit", "creditsDeducted": "1", "creditBalance": "41" }] } ``` `check` never changes state — it's a read. `track` is the write that moves money or credits. Track from your **server** with a secret key so usage can't be spoofed from the browser. ## The objects you'll use Whoever you bill. Their id is your own user or org id — no extra id to store. What a customer subscribes to: a price, entitlements, and credit grants. What a plan grants access to — what `check` reads. A customer's wallet and the append-only record behind its balance. One deposit of credits — from a plan, a drip, a promo, or a package. The dollar bill, auto-charged to a card at settlement. ## Next steps Set up a catalog, subscribe a customer, and watch credits burn — end to end. The credit path in depth: grants, denominations, wallets, and top-ups. API keys, and the difference between sandbox and live. Plans, subscriptions, payments, and invoices on the dollar side. # Customers & subscriptions Source: https://docs.useunitpay.com/node/customers Read customers, their invoices, payment methods, credit wallets, and subscriptions. Read-side access to who you bill and what they owe. List methods return a [`PagePromise`](/node/errors) you can `await` for one page or `for await` to auto-paginate. Credits are first-class: every customer can hold one or more credit wallets alongside their dollar balances. `listCreditAccounts` returns them. ## unitpay.customers.get Fetch one customer by ID. Returns `Promise` (`id`, `email`, `name`, `status`, `type`, `salesMotion`, address fields, `metadata`, …). ```ts theme={null} const customer = await unitpay.customers.get('cus_123'); ``` ## unitpay.customers.list List customers. Takes optional query `params` (`Record`); returns `PagePromise`. ```ts theme={null} // One page const { data, hasMore, nextCursor } = await unitpay.customers.list(); // Auto-paginate every customer for await (const customer of unitpay.customers.list()) { console.log(customer.id); } ``` ## unitpay.customers.listInvoices Invoices for a customer. Returns `PagePromise` (`totalAmount`, `amountDue`, `amountPaid`, `currency`, `status`, `dueDate`, `paidAt`, …). ```ts theme={null} for await (const invoice of unitpay.customers.listInvoices('cus_123')) { console.log(invoice.invoiceNumber, invoice.status, invoice.amountDue); } ``` ## unitpay.customers.listPaymentMethods Payment methods on file. Returns `PagePromise` (`type`, `status`, `isDefault`, and `card: { brand, last4, expMonth, expYear }`). ```ts theme={null} const { data } = await unitpay.customers.listPaymentMethods('cus_123'); ``` ## unitpay.customers.listCreditAccounts The customer's credit wallets. Returns `PagePromise` — one per credit currency, with `balance`, `available`, `reserved`, and `currencyId`. ```ts theme={null} for await (const wallet of unitpay.customers.listCreditAccounts('cus_123')) { console.log(wallet.currencyId, wallet.available, 'of', wallet.balance); } ``` ## unitpay.subscriptions.list List subscriptions. Filter with query `params` — `{ customerId, status }`. This is also how you list one customer's subscriptions (there's no customer-nested route). Returns `PagePromise` (`planId`, `status`, `billingInterval`, `collectionMethod`, `currentBillingPeriodEnd`, …). ```ts theme={null} const { data } = await unitpay.subscriptions.list({ customerId: 'cus_123', status: 'active', }); ``` ## unitpay.subscriptions.cancel Cancel a subscription by ID. Optional `params` (`Record`) controls cancellation behavior (e.g. immediate vs. at period end). Returns `Promise`. ```ts theme={null} const sub = await unitpay.subscriptions.cancel('sub_123'); ``` ## unitpay.subscriptions.uncancel Reverse a pending cancellation before it takes effect. Returns `Promise`. ```ts theme={null} const sub = await unitpay.subscriptions.uncancel('sub_123'); ``` ## See also Track billable events. Mint the token for customer-facing UI. # Errors, retries & pagination Source: https://docs.useunitpay.com/node/errors Typed errors, built-in retries, pagination, webhook verification, and the SDK version. Every request retries transient failures automatically and throws a typed error on real ones. Discriminate with `instanceof` — every error extends `ApiError`. ## Typed errors All errors carry `status`, `code`, `message`, `requestId`, and `headers`. `ApiError.generate` maps HTTP status to the right subclass: | Class | When it throws | | --------------------- | ----------------------------------------------------------- | | `ApiError` | Base class for every error below. | | `BadRequestError` | `400` — malformed request. | | `AuthenticationError` | `401` — missing or invalid API key. | | `NotFoundError` | `404` — resource doesn't exist. | | `ConflictError` | `409` — idempotency or state conflict (retried first). | | `ValidationError` | `422` — request body failed validation. | | `RateLimitError` | `429` — rate limited. Carries `retryAfter: number \| null`. | | `InternalServerError` | `5xx` — server error (retried first). | | `ConnectionError` | Network failure after retries are exhausted. | | `TimeoutError` | Request exceeded the configured `timeout`. | ```ts theme={null} import { UnitPay, ApiError, RateLimitError, ValidationError } from '@unitpay/node'; const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_API_KEY }); try { await unitpay.customers.get('cus_missing'); } catch (err) { if (err instanceof ValidationError) { console.error('bad input', err.code, err.message); } else if (err instanceof RateLimitError) { console.error('retry after', err.retryAfter, 'seconds'); } else if (err instanceof ApiError) { // catches NotFoundError, ConnectionError, TimeoutError, etc. console.error('api error', err.status, err.code, err.requestId); } else { throw err; // something unexpected } } ``` ## Retries & idempotency Requests retry on `429`, `409`, and `5xx` responses plus network errors, with jittered exponential backoff. A `429` honors the `Retry-After` header. `POST` and `DELETE` requests automatically carry an `Idempotency-Key` so retries never double-apply — pass your own via `RequestOptions.idempotencyKey`, or set a `idempotencyKeyPrefix` on the client. Tune retries and timeout on the constructor or per request: ```ts theme={null} const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_API_KEY, retries: 2, // default timeout: 30_000, // ms, default }); // Per-request override await unitpay.customers.get('cus_123', { timeout: 5_000, idempotencyKey: 'my-key', }); ``` ## Pagination List methods return a `PagePromise`. `await` it for a single `Page` (`{ data, hasMore, nextCursor, prevCursor }`), or `for await` over it to stream every item across pages. `toArray()` collects them all. ```ts theme={null} // One page const page = await unitpay.customers.list(); console.log(page.data, page.hasMore, page.nextCursor); // Every item, auto-paginated for await (const customer of unitpay.customers.list()) { console.log(customer.id); } // Collect all into an array const all = await unitpay.customers.list().toArray(); ``` ## Verifying webhooks Verify inbound webhook signatures with the static `UnitPay.verifyWebhook(body, headers, secret)`. It validates the Svix signature and returns the parsed, camelCased event; it throws if the signature is invalid. ```ts theme={null} export async function POST(req: Request) { const body = await req.text(); // raw body — do not parse first const headers = Object.fromEntries(req.headers); const event = await UnitPay.verifyWebhook( body, headers, process.env.UNITPAY_WEBHOOK_SECRET!, ); // handle event… return new Response('ok'); } ``` ## Observability Subscribe to raw request/response events for logging or tracing, and read the SDK version: ```ts theme={null} import { SDK_VERSION } from '@unitpay/node'; unitpay .on('request', ({ method, path, attempt }) => console.log(method, path, attempt)) .on('response', ({ status, requestId }) => console.log(status, requestId)); console.log('sdk', SDK_VERSION); ``` ## See also Paginated reads. Install and instantiate. # Introduction Source: https://docs.useunitpay.com/node/introduction The @unitpay/node server SDK — customers, subscriptions, usage, and portal sessions. `@unitpay/node` is the **server-side** SDK for **UnitPay** — the billing engine where **dollars and credits are both first-class**. Use it from your backend to read customers, subscriptions, invoices, payment methods, and credit wallets, track usage, and mint the short-lived portal tokens that power your customer-facing UI. This SDK authenticates with a **secret API key** (`upay_sk_…`). Never ship it to the browser or a client bundle. For customer-facing billing UIs, use [`@unitpay/react`](/react/introduction), which consumes the portal tokens you mint here. ## Installation ```bash theme={null} npm install @unitpay/node ``` ## Instantiate Pass your secret key, or set `UNITPAY_API_KEY` and let the SDK read it: ```ts theme={null} import { UnitPay } from '@unitpay/node'; const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_API_KEY }); ``` `new UnitPay()` throws if no key is found. Passing a `portalToken` also throws — those belong in `@unitpay/react`. The config accepts `apiKey`, `baseUrl`, `timeout` (ms, default `30000`), `retries` (default `2`), `idempotencyKeyPrefix`, and a custom `fetch`. ## What it covers ```ts theme={null} // Usage — track billable events (deducts credits / meters usage) await unitpay.track({ customerId: 'cus_123', eventName: 'ai-generation', quantity: 1 }); // Customers — read a customer and everything attached to them await unitpay.customers.get('cus_123'); await unitpay.customers.list(); await unitpay.customers.listInvoices('cus_123'); await unitpay.customers.listPaymentMethods('cus_123'); await unitpay.customers.listCreditAccounts('cus_123'); // credit wallets + balances // Subscriptions await unitpay.subscriptions.list({ customerId: 'cus_123' }); await unitpay.subscriptions.cancel('sub_123'); await unitpay.subscriptions.uncancel('sub_123'); // Portal sessions — mint the token @unitpay/react consumes const { token } = await unitpay.portalSessions.create({ customerId: 'cus_123' }); ``` For high-throughput pipelines, `UnitPayIngestion` batches and flushes events. The customer-facing UI — subscribe, checkout, gates, and balances — lives in [`@unitpay/react`](/react/introduction). ## Retries, idempotency & typed errors Every request retries transient failures (`429`, `409`, `5xx`, and network errors) with jittered exponential backoff, honoring `Retry-After`. `POST`/`DELETE` requests carry an idempotency key so retries are safe. Failures throw a typed error (`AuthenticationError`, `RateLimitError`, `ValidationError`, …) you can discriminate with `instanceof`. Verify incoming webhooks with the static `UnitPay.verifyWebhook(body, headers, secret)`. ## Next steps Track usage and run credits end to end. Secret keys, and sandbox vs live. Build the customer-facing billing UI. The two paths and the two verbs. # Portal sessions Source: https://docs.useunitpay.com/node/portal-sessions Mint the short-lived token that @unitpay/react consumes to render customer-facing billing UI. Portal sessions are the bridge from your server to the browser. You mint a token here with your secret key, hand it to your frontend, and [`@unitpay/react`](/react/introduction) exchanges it for a customer-scoped billing UI — no secret key ever leaves your backend. The token is a short-lived JWT scoped to one customer. Pass it to [``](/react/provider); every hook and component in the React SDK reads the customer from it. ## unitpay.portalSessions.create Mints a portal session for one customer and returns the token to embed in your frontend. **Parameters** — `CreatePortalSessionParams`: The customer this session grants access to. Token lifetime in seconds. Min `60`, max `86400`. **Returns** — `Promise`: The JWT to pass to ``. ISO-8601 timestamp when the token expires. Mint the token in a server route and return only the token to the browser: ```ts app/api/portal-session/route.ts theme={null} import { UnitPay } from '@unitpay/node'; const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_API_KEY }); export async function POST(req: Request) { const { customerId } = await getSession(req); // your auth const session = await unitpay.portalSessions.create({ customerId, ttlSeconds: 3600, }); return Response.json({ token: session.token }); } ``` ## unitpay.portalSessions.revoke Invalidates portal tokens before they expire — for logout, or when a customer's access changes. Revoke a single session by its `jti`, or every active session for a customer. **Parameters** — `RevokePortalSessionParams` (one of): Revoke a single session by its JWT ID. Revoke all active sessions for this customer. **Returns** — `Promise`. ```ts theme={null} // Revoke every session for a customer (e.g. on account suspension) await unitpay.portalSessions.revoke({ customerId: 'cus_123' }); ``` ## See also Build the customer-facing billing UI the token powers. Where the portal token is consumed. # Usage & ingestion Source: https://docs.useunitpay.com/node/usage Track billable events one at a time, or batch them at high throughput. Usage events feed both billing paths: they deduct credits at track-time and meter usage for invoice-time aggregation. Track a few events per request with `unitpay.usage.track`; for firehose volumes, use the stateful `UnitPayIngestion` batcher. ## unitpay.usage.track Tracks one billable event, or an array of them in a single request. Also exposed as `unitpay.track(...)`. **Parameters** — `TrackEventInput | TrackEventInput[]`: The customer the event belongs to. The billable event, e.g. `ai-generation` or `api-call`. How much usage to record. Arbitrary event metadata for aggregation and filtering. ISO-8601 event time. Defaults to now if omitted. Dedupe key so retries don't double-count. **Returns** — `Promise` with `accepted: number`, `rejected: number`, and optional `rejections` (`{ index, reason, message }[]`). ```ts theme={null} import { UnitPay } from '@unitpay/node'; const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_API_KEY }); // Single event await unitpay.track({ customerId: 'cus_123', eventName: 'ai-generation', quantity: 1, properties: { model: 'opus' }, }); // Batch — one request, one result const result = await unitpay.usage.track([ { customerId: 'cus_123', eventName: 'api-call', quantity: 10 }, { customerId: 'cus_456', eventName: 'api-call', quantity: 3 }, ]); // result.accepted === 2 ``` ## UnitPayIngestion A separate, stateful client for high-throughput pipelines. Events are buffered and auto-flushed on an interval or when the batch fills; retries and buffer overflow are handled for you. Construct it once and keep it alive for the process lifetime. **Constructor** — `UnitPayIngestionConfig`: Your secret key (`upay_sk_…`). Flush on a timer. Set `false` to flush only manually. How often the timer flushes the buffer. Flush immediately once the buffer reaches this size. Hard cap; the oldest event is dropped on overflow (surfaced via `onFlushError`). Retries on `5xx` / network errors before events go back in the buffer. Per-request timeout in ms. Called when a flush fails or an event is dropped. **Methods** * `track(event)` — buffer one event (`TrackEventParams`); non-blocking. Fills in `quantity`, `timestamp`, and `idempotencyKey` if omitted. * `flush()` — force-flush the buffer now. Returns `Promise` (`{ accepted, rejected, rejections }`). * `check(params)` — synchronous entitlement check, bypassing the buffer. Takes `{ customerId, featureSlug, requestedUsage? }`, returns `Promise` (`{ access: boolean, deniedReason? }`). * `shutdown()` — stop the timer and flush what's left. Call on process exit. * `bufferSize` — current number of buffered events. ```ts theme={null} import { UnitPayIngestion } from '@unitpay/node'; const ingestion = new UnitPayIngestion({ apiKey: process.env.UNITPAY_API_KEY!, onFlushError: (err, events) => console.error('flush failed', err, events.length), }); // Fire-and-forget on the hot path ingestion.track({ customerId: 'cus_123', eventName: 'api-call' }); // Gate an action before doing expensive work const { access } = await ingestion.check({ customerId: 'cus_123', featureSlug: 'ai-generation', requestedUsage: 1, }); if (!access) throw new Error('over quota'); // Drain on shutdown process.on('SIGTERM', () => ingestion.shutdown()); ``` ## See also Read who you're billing. Typed errors and retry behavior. # Catalog & pricing Source: https://docs.useunitpay.com/react/catalog-and-pricing Read the merchant's live plans, products, public pricing matrix, and branding — for the org in context. Read-only hooks for the merchant catalog customers can see: the flat live-plan list, the active product catalog, the full public pricing matrix for one product, and the org's branding. Customers only ever see active plans and products — draft, archived, and migrated rows never surface. Every hook returns `isLoading: boolean` and `error: Error | null` (see [Introduction](/react/introduction)); the sections below list each hook's specific returns. ## usePlans Returns the merchant's live plans — the flat list from `GET /v1/plans?status=active`. Customers only ever see active plans (never draft, archived, or migrated). For a customer-facing pricing page with tiers, intervals, entitlements, credit packages, and addons, use `usePricing` instead. **Parameters** — none. **Returns** * `plans` — `Plan[]` — the active plans, or `[]` while loading. Each plan: Plan identifier. Display name. Stable slug. Optional description. Whether the plan is publicly listed. Arbitrary merchant metadata. ```tsx theme={null} import { usePlans } from '@unitpay/react'; export default function PlanList() { const { plans, isLoading, error } = usePlans(); if (isLoading) return
Loading…
; if (error) return
Couldn’t load plans.
; return (
    {plans.map((plan) => (
  • {plan.name} {plan.description ? ` — ${plan.description}` : null}
  • ))}
); } ``` ## useProducts Returns the org's active product catalog — read-only, from `GET /v1/products?status=active`. Customers only ever see live products; archived ones are tombstones and never surface. This also powers `usePricing`'s single-product auto-derive fallback: when there's exactly one active product, a portal with no explicit `productId` self-resolves to it. **Parameters** — none. **Returns** * `products` — `Product[]` — the active products, or `[]` while loading. Each product: Product identifier. Owning org. Display name. Stable slug. Optional description. Optional product image. Always `active` from this hook. Arbitrary merchant metadata. ISO timestamp. ISO timestamp. ```tsx theme={null} import { useProducts } from '@unitpay/react'; export default function ProductPicker() { const { products, isLoading, error } = useProducts(); if (isLoading) return
Loading…
; if (error) return
Couldn’t load products.
; return (
    {products.map((product) => (
  • {product.name}
  • ))}
); } ``` ## usePricing Returns the complete customer-facing catalog for one product from `GET /v1/public/pricing/:productId`: tiers, plans-per-tier (interval variants), entitlements, credit packages, addons, and a canonical top-level `features[]` row list for building a pricing matrix. The server filters to self-serve tiers automatically. This is the hook customer-facing pricing pages want — not `usePlans`, the flat live-plan list. **Parameters** The product to load pricing for. **Optional** — when omitted, it's derived in this order: 1. Explicit arg — `usePricing('prod_xxx')` 2. `UnitPayConfig.productId` (the escape hatch set on ``) 3. The active subscription's `productId` (the sub *is* the answer) 4. A single-product org auto-resolves (`useProducts()` length === 1) 5. Otherwise `null` → returns `{ pricing: null, isLoading: false, error: null }` (multi-product org with no sub — pass `productId` explicitly) While the derivation inputs (subscription, products) are still loading, `isLoading` stays `true`. **Returns** * `pricing` — the `PricingResponse` matrix, or `null` when no product could be resolved / while loading: Catalog display currency. `{ id, name, slug, description, imageUrl }`. Canonical matrix row order — the union of every entitlement slug across every plan in every tier, sorted by `displayOrder`. Each row carries `slug`, `name`, `eventName` (billable metric, or `null`), and `creditCurrencyId` (wallet it debits, or `null`). The pricing columns. Each tier carries `plans[]` (interval variants), `addons[]`, `creditPackages[]`, an `entitlementBySlug` lookup (branches on `mode`: `unlimited` / `quota` / `rate`), plus presentation flags (`isPopular`, `isForeverFree`, `isSelfServe`, `isSalesLed`, `ctaLabel`, `intervals[]`). ```tsx theme={null} import { usePricing } from '@unitpay/react'; export default function PricingTable() { // productId derived from the active subscription / single-product org const { pricing, isLoading, error } = usePricing(); if (isLoading) return
Loading…
; if (error) return
Couldn’t load pricing.
; if (!pricing) return
Select a product to see pricing.
; return ( {pricing.tiers.map((tier) => ( ))} {pricing.features.map((feature) => ( {pricing.tiers.map((tier) => { const ent = tier.entitlementBySlug[feature.slug]; if (!ent) return ; if (ent.mode === 'unlimited') return ; return ; })} ))}
{pricing.product.name}{tier.name}{tier.isPopular ? ' ★' : ''}
{feature.name}—Unlimited{ent.value ?? ent.periodAllocation}
); } ``` ## useBranding Returns the customer-facing merchant identity for the current org — what the portal renders in its header instead of a hardcoded asset. Read-only, from `GET /v1/branding`, and cached aggressively since branding changes rarely. It's managed in the merchant dashboard. **Parameters** — none. **Returns** * `branding` — the `Branding` object, or `null` while loading: Merchant display name. Always present. Merchant logo URL, or `null` if none uploaded. Merchant website URL, or `null` if unset — powers the portal's "Return to " back-link. Typed appearance/theme config (accents, CSS overrides), or `null` if unset. ```tsx theme={null} import { useBranding } from '@unitpay/react'; export default function PortalHeader() { const { branding, isLoading } = useBranding(); if (isLoading || !branding) return null; return (
{branding.logo ? ( {branding.name} ) : ( {branding.name} )} {branding.website ? ( Return to {branding.name} ) : null}
); } ``` ## See also Preview and start a subscription to a plan from the catalog. What each plan's entitlements grant the customer at runtime. The credit packages and wallets referenced by the pricing matrix. # Checkout Source: https://docs.useunitpay.com/react/checkout Preview what the customer owes before they commit, then poll a hosted-form session to completion. Two hooks bracket a checkout: `useCheckoutPreview` shows what the customer owes today and what changes before anything is charged, and `useCheckoutSession` polls a hosted-form session until the PSP confirms. Both are scoped to the portal customer. Every hook returns `isLoading: boolean` and `error: Error | null` (see [Introduction](/react/introduction)); the sections below list each hook's specific returns. ## useCheckoutPreview Returns a server-computed preview of a checkout action — amount due today, the upcoming schedule, credit impact, line items, and warnings — before anything is charged. It calls `POST /v1/checkout/sessions/preview` with a discriminated input. Pass `null` to disable (e.g. while inputs are still being assembled). `customerId` is filled from context — callers omit it. For plan-**change** previews, use [`useSubscription(id).previewChange()`](/react/subscriptions) instead — it returns a richer shape (proration, entitlement diffs, direction/timing). **Parameters** The action to preview, a discriminated union on `purpose`. Pass `null` to hold the query inert. Preview subscribing to (or starting) a plan. Preview attaching an addon plan to an existing subscription. Preview buying a fixed credit package. Preview a custom-amount credit top-up into one wallet. Preview paying an outstanding invoice. Preview a payment-method update (no charge). **Returns** * `preview` — the `CheckoutPreviewResult`, or `null` while loading / when disabled. It extends the shared base shape with the per-purpose `details` payload: Discriminated by `kind`: `charge_now` (`cents`, `currency`), `invoice_later` (`cents`, `netDays`, `estimatedDate`), or `no_charge`. `{ description, unitAmount, quantity, subtotal, muted? }` rows. Money breakdown in minor units. Optional `{ date, amountCents? }` for the next charge. Optional wallet delta — `{ currencyName, denomination, minorUnitScale, currentBalance, delta, afterBalance, … }`. `{ code, params? }` — e.g. `trial_requires_payment_method`, `customer_has_no_payment_method`. Echoes the purpose; `details` is the per-purpose payload (e.g. `details.subscription`, `details.creditTopup`). * `refresh()` — `() => Promise` — refetch the preview (e.g. after the customer edits the amount). ```tsx theme={null} import { useCheckoutPreview } from '@unitpay/react'; export default function TopUpPreview({ creditCurrencyId }: { creditCurrencyId: string }) { const { preview, isLoading, error, refresh } = useCheckoutPreview({ purpose: 'credit_topup', creditCurrencyId, amountCents: 2000, }); if (isLoading) return
Calculating…
; if (error || !preview) return
Couldn’t preview.
; return (
{preview.dueToday.kind === 'charge_now' && (
Due today: {preview.dueToday.cents / 100} {preview.dueToday.currency}
)} {preview.creditImpact && (
New balance: {preview.creditImpact.afterBalance}
)} {preview.warnings.map((w) =>
⚠ {w.code}
)}
); } ``` ## useCheckoutSession Polls `GET /v1/checkout/sessions/:id/status` and reports when a hosted-form payment resolves. Use it to await the result of a `requires_form` [SettleOutcome](/react/settlement) — after the Elements form submits, the webhook completer flips the session to `completed` once the PSP confirms. Polling is tiered: \~1s for the first 30s (hot phase), then \~5s, then it stops and synthesizes `expired` at `maxWaitMs`. **Parameters** The session to poll. Pass `null` to leave the hook idle (e.g. before the form step is reached). Changing the id resets the timeout. Gate polling without unmounting the hook. Set `false` to hold the query inert (e.g. before the modal advances to its confirming step). Wall-clock cap on the pending window. After this elapses the hook stops polling and surfaces `status: 'expired'` even if the server row is still `pending`. **Returns** * `status` — `'idle' | 'pending' | 'completed' | 'expired'`: * `'idle'` — no `checkoutSessionId`, or `enabled` is `false`. * `'pending'` — polling; defaults to this until the first fetch resolves. * `'completed'` — the PSP confirmed and the session row finalized. * `'expired'` — terminal server status, or the client-side `maxWaitMs` timeout fired. * `data` — the `CheckoutSessionStatus` row, or `null` before the first response: `{ id, status, completedAt, subscriptionId, invoiceId }`. ```tsx theme={null} import { useCheckoutSession } from '@unitpay/react'; import { useEffect } from 'react'; export default function ConfirmingStep({ sessionId, onDone, }: { sessionId: string; onDone: (subscriptionId: string | null) => void; }) { const { status, data } = useCheckoutSession(sessionId, { maxWaitMs: 30_000 }); useEffect(() => { if (status === 'completed') onDone(data?.subscriptionId ?? null); }, [status, data, onDone]); if (status === 'completed') return
All set ✓
; if (status === 'expired') return
Payment successful — provisioning is taking longer than usual.
; return
Confirming your payment…
; } ``` Pair this with a `requires_form` outcome: render the [``](/react/entitlements-and-gates) Elements step, then mount `useCheckoutSession(session.id)` to await provisioning. ## See also The plans and packages a checkout starts from. Every `SettleOutcome` kind and its callback, including `requires_form`. Top-up and package purchases that feed the preview. # Credits & wallets Source: https://docs.useunitpay.com/react/credits-and-wallets Read wallet balances and grants, top up by amount or package, and automate refills — for the customer in context. Credits are first-class in UnitPay. These hooks read the customer's credit wallets (one per credit currency), refill them by amount or by fixed-SKU package, automate refills at a threshold, and paginate the append-only ledger and top-up history. Every hook returns `isLoading: boolean` and `error: Error | null` (see [Introduction](/react/introduction)); the sections below list each hook's specific returns. ## Two denominations Every wallet has a `denomination` that decides how to render its `balance` and which top-up shape it accepts: * `'unit'` — raw counts like "AI Credits" or API calls. Render `5,000 credits`; auto-topup uses `mode: 'package'`. * A fiat code (`'usd'`, `'eur'`, …) — a prepaid money balance with `minorUnitScale` decimal places. Render `$50.00`; auto-topup uses `mode: 'amount'`. Branch on `denomination`; never mix the two formatters. A wallet `balance` is the credit **wallet** balance — unrelated to metered entitlement `remaining` (use [entitlements](/react/entitlements-and-gates) for that). ## useCreditAccounts Returns every credit account (wallet) the customer holds — one per credit currency — each already carrying its `balance`, `currency` metadata, active `grants`, and auto-topup config. No second round-trip is needed for the typical wallet UI. **Parameters** Optional. Defaults to the customer set on ``. Pass an explicit id only to read a different customer's wallets. **Returns** * `accounts` — `CreditAccount[]` — all wallets for the customer (one per currency), or `[]` while loading or when the customer has no wallet yet. Render as a list of wallet cards. ```tsx theme={null} import { useCreditAccounts } from '@unitpay/react'; export default function Wallets() { const { accounts, isLoading, error } = useCreditAccounts(); if (isLoading) return

Loading wallets…

; if (error) return

Failed to load wallets.

; if (accounts.length === 0) return

No credit wallets yet.

; return (
    {accounts.map((acct) => { const denom = acct.currency?.denomination ?? 'unit'; const label = denom === 'unit' ? `${acct.balance.toLocaleString()} credits` : `${(acct.balance / 10 ** (acct.currency?.minorUnitScale ?? 2)).toFixed( acct.currency?.minorUnitScale ?? 2, )} ${denom.toUpperCase()}`; return (
  • {acct.currency?.name ?? acct.creditCurrencyId} — {label} ({acct.status})
  • ); })}
); } ``` ```json theme={null} { "id": "cacc_01k2v8...", "customerId": "cus_01ktx...", "creditCurrencyId": "ccur_01k0ai...", "status": "active", "balance": 4250, "currency": { "id": "ccur_01k0ai...", "slug": "ai-credits", "name": "AI Credits", "denomination": "unit", "minorUnitScale": 0 }, "grants": [ { "id": "cgr_01k2...", "creditAccountId": "cacc_01k2v8...", "customerId": "cus_01ktx...", "creditCurrencyId": "ccur_01k0ai...", "subscriptionId": "sub_01k1...", "planId": "plan_01k0...", "creditPackageId": null, "sourceInvoiceId": null, "amount": 5000, "creditsRemaining": 4250, "priority": 10, "description": null, "expiresAt": "2026-07-01T00:00:00.000Z", "voidedAt": null, "metadata": { "source": "grant_rule", "trigger": "renewal", "periodKey": "2026-06" }, "createdAt": "2026-06-01T00:00:00.000Z", "updatedAt": "2026-06-20T12:00:00.000Z" } ], "autoTopup": { "enabled": true, "threshold": 1000, "packageId": "cpkg_01k3...", "amount": null, "monthlyChargeLimit": 50000 }, "autoTopupRuntime": { "lastFiredAt": "2026-06-18T09:12:00.000Z", "lastFailedAt": null, "consecutiveFailures": 0, "totalFiresCount": 3, "isPending": false, "monthToDateCharge": 3000 }, "effectiveFundingMethod": "charge_automatically" } ``` ## useCreditCurrencies Returns the org-wide list of active credit currencies the merchant has configured. Use it to build a "Wallets you can fund" picker when the customer hasn't engaged any wallet yet (i.e. `useCreditAccounts` returns `[]`). Admin-managed, so this list is read-only and cached aggressively. **Parameters** — takes no arguments; the list is org-scoped, resolved from the `` context. **Returns** * `currencies` — `CreditCurrency[]` — every active credit currency configured on the org, or `[]` while loading. ```tsx theme={null} import { useCreditAccounts, useCreditCurrencies } from '@unitpay/react'; export default function FundableWallets() { const { accounts } = useCreditAccounts(); const { currencies, isLoading } = useCreditCurrencies(); if (isLoading) return

Loading…

; const available = currencies.filter( (c) => !accounts.some((a) => a.creditCurrencyId === c.id), ); return (
    {available.map((c) => (
  • {c.name} — {c.denomination === 'unit' ? 'credits' : c.denomination.toUpperCase()}
  • ))}
); } ``` The first `useAutoTopUp` `set()` or first `useTopUp` `topUp()` call on an unengaged currency auto-creates the `credit_account` server-side. ## useTopUp Refills the customer's credit wallet by a free-form amount. The server dispatches on customer state — PLG with a payment method charges inline, SLG appends the top-up to the next invoice — and returns a [`SettleOutcome`](/react/settlement) you wire to your UX with `on*` callbacks. **Parameters** — `useTopUp(options?)` accepts an optional `HandleSettlementOptions` object of settlement callbacks (`onChargedInline`, `onInvoiceAdded`, `onRequiresForm`, `onNoAction`, …). Each server outcome maps to exactly one; unhandled ones are silent no-ops. The returned `topUp(input)` alias takes: The wallet currency to refill. Auto-creates the `credit_account` server-side if the customer hasn't engaged this currency yet. Amount to add, in minor units of the currency's denomination (`unit` credits or fiat). Override the auto-generated `Idempotency-Key`. Auto-generated by default. Force the inline `` even when a payment method is on file. PLG customers with **no** payment method throw `TOPUP_REQUIRES_PM` (402) — route the customer to payment-method setup first. **Returns** — a React Query mutation, plus a `topUp` alias for `mutateAsync`: * `topUp(input)` — `(input: UseTopUpInput) => Promise` — performs the top-up; the `options` callbacks fire automatically on success. * `isPending` / `data` — standard mutation state (`isPending` while in flight, `data` is the last `SettleOutcome`). ```tsx theme={null} import { useTopUp } from '@unitpay/react'; export default function TopUpButton({ creditCurrencyId }: { creditCurrencyId: string }) { const { topUp, isPending } = useTopUp({ onChargedInline: () => alert('Wallet topped up!'), onInvoiceAdded: ({ lineAmount }) => alert(`Added ${lineAmount} to your next invoice.`), onRequiresForm: ({ client }) => { // mount }, }); return ( ); } ``` ## useBuyCreditPackage Purchases a fixed-SKU credit package. Like `useTopUp`, the server dispatches on customer state — PLG with a payment method charges inline, SLG appends to the next invoice — and returns a [`SettleOutcome`](/react/settlement) you wire to your UX with `on*` callbacks. A package is a pre-configured SKU (its credit amount, price, and target wallet are fixed merchant-side), so you only pass the package id. **Parameters** — `useBuyCreditPackage(options?)` accepts an optional `HandleSettlementOptions` object of settlement callbacks (same set as `useTopUp`). The returned `buyCreditPackage(input)` alias takes: The credit package SKU to purchase. Override the auto-generated `Idempotency-Key`. Auto-generated by default. Force the inline `` even when a payment method is on file. PLG customers with **no** payment method throw — route the customer to payment-method setup first. **Returns** — a React Query mutation, plus a `buyCreditPackage` alias for `mutateAsync`: * `buyCreditPackage(input)` — `(input: UseBuyCreditPackageInput) => Promise` — performs the purchase; the `options` callbacks fire automatically on success. * `isPending` / `data` — standard mutation state. ```tsx theme={null} import { useBuyCreditPackage } from '@unitpay/react'; export default function BuyPackageButton({ creditPackageId }: { creditPackageId: string }) { const { buyCreditPackage, isPending } = useBuyCreditPackage({ onChargedInline: () => alert('Package purchased!'), onInvoiceAdded: ({ lineAmount }) => alert(`Added ${lineAmount} to your next invoice.`), onRequiresForm: ({ client }) => { // mount }, }); return ( ); } ``` ## useAutoTopUp Manages the auto-topup rule for one credit currency. Reads piggyback on `useCreditAccounts` (no extra fetch — `autoTopup` and `autoTopupRuntime` are already inline on each wallet row); `set()` and `disable()` upsert the rule and re-fetch the wallet so the card reflects the new state immediately. The wallet's `denomination` decides the rule shape: `'unit'` (SKU credits) → `set({ mode: 'package', … })`; a fiat code → `set({ mode: 'amount', … })`. The server validates `mode` against the target currency's denomination. All monetary fields (`threshold`, `topupAmount`, `monthlyChargeLimit`) are minor units of that denomination. **Parameters** The wallet currency whose auto-topup rule to manage. **Returns** * `rule` — `AutoTopupConfig | null` — current config (`enabled`, `threshold`, `packageId`, `amount`, `monthlyChargeLimit`), or `null` when no `credit_account` exists for this currency yet. * `runtime` — `AutoTopupRuntime | null` — derived counters (`lastFiredAt`, `lastFailedAt`, `consecutiveFailures`, `totalFiresCount`, `isPending`, `monthToDateCharge`), or `null` with no account. * `effectiveFundingMethod` — `'charge_automatically' | 'send_invoice' | null` — how the next auto-fire will fund (PSP off-session charge vs. one-off invoice), derived from the customer's collection method. * `currency` — `CreditAccountCurrency | null` — the wallet's currency metadata, or `null` when the customer hasn't touched this currency yet. Use `currency.denomination` to pick the form variant without a second hook call. * `denomination` — `'unit' | 'usd' | … | null` — convenience accessor for `currency?.denomination`. * `set(input)` — `(input: SetAutoTopUpInput) => Promise` — upserts the rule (auto-creates the credit account if missing). `input` is a discriminated union on `mode` (see below). * `disable()` — `() => Promise` — soft-disables the rule, keeping the rest of the config so a later re-enable pre-fills. * `isMutating` — `true` while a `set()` or `disable()` is in flight. `set(input)` fields: `'package'` for `unit`-denominated wallets (buy a fixed SKU); `'amount'` for fiat wallets (refill a free-form \$ amount). **`mode: 'package'` only.** The SKU to auto-purchase when the balance drops below `threshold`. **`mode: 'amount'` only.** Amount to add per fire, in minor units of the fiat denomination. Balance (minor units) at or below which the rule fires. Optional cap (minor units) on total auto-topup spend per calendar month. `null` to clear. Optional. Defaults to enabled on upsert; set `false` to stage a disabled rule. ```tsx theme={null} import { useAutoTopUp } from '@unitpay/react'; export default function AutoTopUpToggle({ creditCurrencyId }: { creditCurrencyId: string }) { const { rule, denomination, set, disable, isMutating } = useAutoTopUp(creditCurrencyId); const enable = () => denomination === 'unit' ? set({ mode: 'package', creditPackageId: 'cpkg_123', threshold: 1000 }) : set({ mode: 'amount', topupAmount: 2000, threshold: 1000, monthlyChargeLimit: 50000 }); return rule?.enabled ? ( ) : ( ); } ``` ## useTopupHistory Reads the customer's top-up attempt log — manual, auto, and sweeper-replay attempts unified in one append-only feed. This table is the runtime source of truth behind the auto-topup counters (last-fired, consecutive failures, monthly cap usage). **Parameters** Optional filter object. All fields are optional; omit it for the full history. Scope to a single wallet currency. Filter by attempt status. Filter by what triggered the attempt. Page size. The server caps at 100. Cursor from a previous page's `nextCursor`. **Returns** * `attempts` — `TopupAttempt[]` — the matching attempts (newest first), or `[]` while loading. Each row carries `status`, `triggerSource`, `fundingMethod`, `amount` / `chargeAmount`, `balanceAtFire`, `thresholdAtFire`, optional `invoiceId` / `paymentId`, and `errorCode` / `errorMessage` / `skipReason` on non-success. * `nextCursor` — `string | null` — pass to `filters.cursor` to fetch the next page, or `null` when none remain. ```tsx theme={null} import { useTopupHistory } from '@unitpay/react'; export default function TopUpHistory({ currencyId }: { currencyId: string }) { const { attempts, isLoading, error } = useTopupHistory({ currencyId, limit: 20 }); if (isLoading) return

Loading history…

; if (error) return

Failed to load history.

; if (attempts.length === 0) return

No top-up attempts yet.

; return (
    {attempts.map((a) => (
  • {a.triggerSource} · {a.status} · {a.chargeAmount ?? a.amount ?? '—'} {a.errorMessage ? ` — ${a.errorMessage}` : ''}
  • ))}
); } ``` ## useCreditLedger Reads the customer's credit ledger — the append-only record of grant, consumption, expiry, and adjustment rows. Backed by an infinite query: pages accumulate, `fetchNextPage` walks the server cursor for you, and changing a filter resets pages cleanly. The caller never threads cursor state. **Parameters** Optional filter object. All fields are optional; omit it to read the full ledger. Scope to one wallet / credit account. Scope to one currency (e.g. AI Credits) when the customer has many wallets. Filter by the entry `type` — `credit`, `debit`, `hold`, `release`, `expire`, or `adjustment` (matched case-insensitively). This is the `type` column, not the more specific `reason`. Source feature filter — narrows to consumption rows debited by one feature. ISO datetime — inclusive lower bound on `createdAt`. ISO datetime — inclusive upper bound on `createdAt`. Page size. The server caps at 100. **Returns** * `entries` — `CreditLedgerEntry[]` — flattened entries across all loaded pages, or `[]` while loading. Each entry's `type` is `'CREDIT'` (balance up) or `'DEBIT'` (balance down) — plus `'HOLD'` / `'RELEASE'` / `'EXPIRE'` / `'ADJUSTMENT'` — and a more specific `reason` (e.g. `'GRANT'`, `'USAGE_DEDUCTION'`, `'PACKAGE_PURCHASE'`, `'EXPIRATION'`), with `amount` and the `balanceBefore` / `balanceAfter` snapshot. * `fetchNextPage()` — loads the next page and appends it to `entries`. No-op when `hasNextPage` is `false`. * `hasNextPage` — `boolean` — `true` when more pages remain on the server. * `isFetchingNextPage` — `boolean` — `true` while a subsequent page fetch is in flight. ```tsx theme={null} import { useCreditLedger } from '@unitpay/react'; export default function LedgerDrawer({ creditCurrencyId }: { creditCurrencyId: string }) { const { entries, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useCreditLedger({ creditCurrencyId, limit: 25 }); if (isLoading) return

Loading ledger…

; if (error) return

Failed to load ledger.

; return (
    {entries.map((e) => (
  • {e.type === 'CREDIT' ? '+' : '-'} {e.amount} · {e.reason} · balance {e.balanceAfter}
  • ))}
{hasNextPage && ( )}
); } ``` ```json theme={null} { "id": "cle_01k4...", "customerId": "cus_01ktx...", "creditAccountId": "cacc_01k2v8...", "creditCurrencyId": "ccur_01k0ai...", "type": "DEBIT", "reason": "USAGE_DEDUCTION", "amount": 120, "balanceBefore": 4370, "balanceAfter": 4250, "grantId": "cgr_01k2...", "reservationId": null, "invoiceId": null, "featureId": "feat_01k9...", "usageEventId": "uev_01k9...", "metadata": {}, "createdAt": "2026-06-20T12:00:00.000Z" } ``` ## See also How grants, priority, FIFO consumption, and denominations work under the hood. Metered `remaining` and feature access — distinct from wallet balance. Credit burn-rate and runway derived from the ledger. Preview a top-up or package purchase before charging. # Customer Source: https://docs.useunitpay.com/react/customer Read the current customer and their entitlements, and run core account + billing actions. `useCustomer` is the root data hook for the customer portal. It loads the customer record and their full entitlement map in one place, exposes a synchronous cache-based `check()` for gating UI, and bundles the self-service write actions: update the account, start a hosted payment-method setup, or pay an invoice. Every hook returns `isLoading: boolean` and `error: Error | null` — see [Introduction](/react/introduction). The sections below list each hook's specific returns. ## useCustomer Loads the customer and their entitlements in a single fetch, and returns the core account + billing actions on top of them. Takes no arguments; the customer comes from `` context. **Returns** * `customer` — `Customer | null` — the customer record, or `null` while loading. Identity, locale, structured billing address, tax fields, `defaultPaymentMethodId`, `status`, `metadata`, timestamps. Edit it with `update()` below. * `entitlements` — `Record` — every feature the customer has access to, keyed by slug. This is the single fetch that powers [entitlement gates and the typed `use*Entitlement` hooks](/react/entitlements-and-gates) — one HTTP call serves the whole tree. Each value is discriminated by `type` (`boolean` / `metered` / `credit` / `config` / `enum`). * `check(featureSlug, options?)` — synchronous, cache-based access check (see below). * `update(input)` — patch the customer record (see below). * `setupPayment(options?)` — start a hosted payment-method setup. See [Payment methods](/react/payment-methods). * `payInvoice(invoiceId, options?)` — mint a hosted invoice-payment link. See [Invoices](/react/invoices). * `isUpdating` — `boolean` — `true` while an `update()` mutation is in flight. ```tsx theme={null} import { useCustomer } from '@unitpay/react'; export default function AccountName() { const { customer, isLoading, error } = useCustomer(); if (isLoading) return
Loading…
; if (error) return
Couldn’t load your account.
; return
{customer?.name}
; } ``` ### `check(featureSlug, options?)` Synchronous, cache-based access check. Reads the already-loaded `entitlements` map and returns `{ allowed, deniedReason }` — no network call. Use it to gate UI affordances inline. For metered and credit features, pass `{ requiredBalance }` to require more than the default of 1 unit/credit. `deniedReason` is one of `not_loaded`, `no_entitlement`, `usage_exceeded`, or `credit_exhausted`. **Parameters** The feature's stable slug. For metered/credit features, the amount the customer must have available for `allowed` to be `true`. Defaults to `1`. `check()` reads the local entitlement cache for instant UI decisions. Pair it with a server-side check (or `useMeteredEntitlement({ requestedUsage })`) before granting access to anything that must be secure. ```tsx theme={null} import { useCustomer } from '@unitpay/react'; export default function ExportButton() { const { check } = useCustomer(); const { allowed, deniedReason } = check('csv-export-ftr'); return ( ); } ``` ### `update(input)` Patch the customer record (the portal's Account Settings tab). Returns the updated `Customer` and seeds it into the cache, so the UI reflects the new values without a refetch. Server-side validation applies — an invalid email throws via the SDK error model. Track the in-flight state with `isUpdating`. **Parameters** Partial customer fields to patch. Supports identity (`name`, `email`, `phone`, `billingEmail`, `invoiceEmails`, `invoiceRemindersEnabled`, `legalName`, `taxId`, `registrationNumber`, `domain`), locale (`language`, `timezone`, `country`), the structured billing address (`addressLine1`/`addressLine2`/`addressCity`/`addressRegion`/`addressPostalCode`/`addressCountry` — pass `null` to clear an address field), and `metadata`. ```tsx theme={null} import { useCustomer } from '@unitpay/react'; import { useState } from 'react'; export default function BillingEmailForm() { const { customer, update, isUpdating } = useCustomer(); const [email, setEmail] = useState(customer?.billingEmail ?? ''); return (
{ e.preventDefault(); await update({ billingEmail: email }); }} > setEmail(e.target.value)} />
); } ``` ### `setupPayment(options?)` and `payInvoice(invoiceId, options?)` Convenience actions for the two hosted flows the account page needs: * **`setupPayment(options?)`** mints a hosted payment-method-update link and, by default, redirects the browser to it. Pass `{ redirect: false }` to receive `{ token, hostedUrl }` without navigating. For the full card-capture surface (inline ``, replace, detach), see [Payment methods](/react/payment-methods). * **`payInvoice(invoiceId, options?)`** mints a hosted invoice-payment link for the given invoice and redirects to it; `{ redirect: false }` returns `{ token, hostedUrl }` without navigating. For inline invoice payment and settlement handling, see [`usePayInvoice`](/react/invoices). ```tsx theme={null} import { useCustomer } from '@unitpay/react'; export default function AccountActions({ invoiceId }: { invoiceId: string }) { const { setupPayment, payInvoice } = useCustomer(); return ( <> ); } ``` ## See also Typed entitlement hooks and declarative gate components — all served from the `useCustomer` cache. List, read, create, and change the customer's subscriptions. List invoices, preview renewals, and collect payment. Capture, list, replace, and detach payment methods. # Entitlements & gates Source: https://docs.useunitpay.com/react/entitlements-and-gates Read a customer's feature entitlements — as typed hooks — and gate UI with declarative components. Entitlements answer "what does this customer get?" — for both dollar features and credit-backed features. Read them with hooks when you need the raw value to drive logic, labels, or meters; wrap UI in a **gate** component when you just want to render conditionally. Both resolve from the bulk entitlement cache populated by [`useCustomer`](/react/customer) — **one HTTP call serves every hook and gate on the page**, so you can scatter as many as you like without extra round-trips. Every hook returns `isLoading: boolean` and `error: Error | null` — see [Introduction](/react/introduction). The typed hooks and gates also share two conventions, so they're stated once here rather than repeated per feature type: * **`fallback`** — a type-safe default value returned while loading, on error, or when the customer has no entitlement for the slug. When a fallback value is returned instead of a live one, the hook's **`isFallback`** is `true`. * **Gate slots** — every gate renders `children` on access, `noAccessComponent` (default `null`) on denial, and `loadingComponent` (default `null`) while resolving. The typed gates take `fallback` for the same purpose as the hooks. (`FeatureGate` is the exception — it uses `fallback` for the denied slot; see below.) `FeatureGate` takes **`featureSlug`**. Every typed gate (`BooleanGate`, `MeteredGate`, `CreditGate`, `ConfigGate`, `EnumGate`) takes **`slug`**. ## The six gates | Component | Prop | Use when | | ------------- | ------------------------- | ---------------------------------------------------------------------------------------- | | `FeatureGate` | `featureSlug` | Generic gate for any feature type; gives you `onDenied(reason)` and a single `fallback`. | | `BooleanGate` | `slug` | An on/off feature flag (e.g. SSO, audit log). | | `MeteredGate` | `slug`, `requestedUsage` | A metered feature with a quota; probe remaining capacity before an action. | | `CreditGate` | `slug`, `requiredBalance` | A credit-backed feature; require a minimum wallet balance. | | `ConfigGate` | `slug` | A config-valued feature; render with the resolved value via a render-prop. | | `EnumGate` | `slug`, `requestedValues` | An enum feature; require specific values to be in the allowed set. | ## useEntitlement Reads one feature's entitlement by slug and returns the raw, type-discriminated value. By default it serves the read from the bulk `useCustomer` cache. Pass `requestedUsage` (metered) or `requestedValues` (enum) to force a live `POST /v1/customers/:id/check` capacity probe instead. Prefer one of the typed wrappers below when you know the feature type. **Parameters** The feature's stable slug. Metered features: ask "would I have access if I consumed N units now?". Forces a live `/check` capacity probe rather than a cache read. Enum features: check whether these requested values are in the allowed set. Also forces a live `/check`. Value used while loading, on error, or on a cache miss. When returned, `isFallback` is `true`. **Returns** * `entitlement` — `EntitlementValue | null` — the resolved value. Narrow on `entitlement.type` (`'boolean' | 'metered' | 'credit' | 'config' | 'enum'`) to read the type-specific fields — e.g. `remaining` for metered, `creditBalance` for credit, `value` for config, `enumValues` for enum. * `isFallback` — `boolean` — `true` when `entitlement` came from `options.fallback` rather than a live read. ```tsx theme={null} import { useEntitlement } from '@unitpay/react'; export default function SeatsLabel() { const { entitlement, isLoading } = useEntitlement('seats-ftr'); if (isLoading) return
Loading…
; if (entitlement?.type === 'metered') { return
{entitlement.remaining} seats left
; } return null; } ``` ## useEntitlements Reads multiple features at once. By default it adds zero HTTP — it derives from [`useCustomer().entitlements`](/react/customer), optionally filtered to the slugs you pass — and returns both the keyed map and a slug-sorted `list` for feature matrices. Pass `{ fresh: true }` to force a live batch check (e.g. right after a mutation). **Parameters** Optional filter. When provided, only these slugs are returned; omit it to get the customer's entire entitlement map. When `true`, skip the local cache and call `POST /v1/customers/:id/check/batch` for `slugs`. Requires a non-empty `slugs` argument (the server caps a batch at 50). Per-slug values used while loading, on error, or for slugs the customer doesn't have. **Returns** * `entitlements` — `Record` — the filtered map, each value discriminated by `type`. * `list` — `Array<{ featureSlug } & EntitlementValue>` — the same data sorted alphabetically by `featureSlug`. Ideal for iterating a table. ```tsx theme={null} import { useEntitlements } from '@unitpay/react'; export default function PlanTable() { const { list, error } = useEntitlements(); if (error) return
Couldn’t load features.
; return (
    {list.map((e) => (
  • {e.featureSlug}: {e.access ? 'on' : 'off'}
  • ))}
); } ``` ## FeatureGate The generic, type-agnostic gate: renders its children only when the customer has access to a feature, regardless of the feature's type. It resolves through `useEntitlement` (served from the `useCustomer` cache) and exposes an `onDenied` callback that fires once with a typed reason when access is refused. **Props** The feature's stable slug. Rendered when access is granted. Rendered when access is denied. Rendered while the entitlement is loading. Called once when access is denied (and again only after access is regained and lost again). The `reason` is one of: `no_entitlement`, `no_active_subscription`, `usage_exceeded`, `credit_exhausted`, `not_loaded`. Metered features: capacity probe — would access be granted after consuming this many units? Forces a live `POST /v1/customers/:id/check`. Type-safe entitlement default used while loading or on a cache miss. For ergonomics, prefer the typed gates and their `fallback` prop. ```tsx theme={null} import { FeatureGate } from '@unitpay/react'; export default function Example() { return ( }> ); } ``` Probe metered capacity before showing an action: ```tsx theme={null} } onDenied={(reason) => track('gate_denied', { reason })} > ``` ## Boolean features An on/off feature flag — SSO, audit log, priority support. `useBooleanEntitlement(slug, options?)` returns a flat **`access` boolean** — `true` when the customer has the feature, `false` otherwise (or for any non-boolean / missing entitlement). Its `options.fallback` is `{ access: boolean }`. `BooleanGate` renders `children` only when `access` is `true`. ```tsx Hook theme={null} import { useBooleanEntitlement } from '@unitpay/react'; export default function SsoSettings() { const { access, isLoading } = useBooleanEntitlement('sso-ftr', { fallback: { access: false }, }); if (isLoading) return
Loading…
; if (!access) return
SSO is available on the Enterprise plan.
; return ; } ``` ```tsx Gate theme={null} import { BooleanGate } from '@unitpay/react'; export default function Example() { return ( }> ); } ```
## Metered features A metered feature with a per-period quota — API calls, seats, exports. `useMeteredEntitlement(slug, options?)` returns the full meter picture: `access`, `remaining` (quota left this period — **granted − usage**, the canonical "what's left"), `usage`, `limit` (granted allowance), `percentage` (`usage / limit × 100`, `0` when unlimited), `isUnlimited` (when `true`, ignore `limit`/`remaining`/`percentage`), and `nextResetAt` (ISO timestamp of the next reset, or `null`). Pass `options.requestedUsage` to force a live `/check` capacity probe; `options.fallback` is `{ access: boolean; remaining?: number; isUnlimited?: boolean; limit?: number; usage?: number }`. `MeteredGate` renders `children` only when access exists; pass `requestedUsage` so it succeeds only when that many units could still be consumed this period. ```tsx Hook theme={null} import { useMeteredEntitlement } from '@unitpay/react'; export default function ApiQuota() { const { access, usage, limit, remaining, percentage, nextResetAt, isUnlimited, isLoading, error } = useMeteredEntitlement('api-calls-ftr'); if (isLoading) return
Loading…
; if (error) return
Couldn’t load usage.
; if (!access) return
Upgrade to call the API.
; if (isUnlimited) return
Unlimited API calls
; return (

{usage} / {limit} used · {remaining} left

{nextResetAt &&

Resets {new Date(nextResetAt).toLocaleDateString()}

}
); } ``` ```tsx Gate theme={null} import { MeteredGate } from '@unitpay/react'; export default function Example() { return ( }> ); } ```
Passing `requestedUsage` to either the hook or the gate forces a server-side check — "do we have headroom for N more units?" — instead of reading the static `remaining` from cache: ```tsx theme={null} import { useMeteredEntitlement } from '@unitpay/react'; export default function BulkImportButton({ rows }: { rows: number }) { const { access, isLoading } = useMeteredEntitlement('api-calls-ftr', { requestedUsage: rows, }); return ( ); } ``` ## Credit features A credit-backed feature drawing from a wallet — AI credits, generation credits, prepaid balance. `useCreditEntitlement(slug, options?)` returns `access` and **`creditBalance`** (the wallet balance backing this feature). Render `creditBalance` with `formatCredits(n)` for `unit` denominations or as money for fiat denominations — never mix the two. Its `options.fallback` is `{ access: boolean; creditBalance: number }`. `CreditGate` renders `children` only when access exists **and** the balance is at least `requiredBalance` (default `1`). ```tsx Hook theme={null} import { useCreditEntitlement } from '@unitpay/react'; export default function CreditBalance() { const { access, creditBalance, isLoading, error } = useCreditEntitlement('ai-credits-ftr', { fallback: { access: false, creditBalance: 0 }, }); if (isLoading) return
Loading…
; if (error) return
Couldn’t load credits.
; if (!access) return
No credit wallet on your plan.
; return
{creditBalance} credits remaining
; } ``` ```tsx Gate theme={null} import { CreditGate } from '@unitpay/react'; export default function Example() { return ( }> ); } ```
`CreditGate`'s extra prop: Minimum credit balance required to render children. ## Config features A config-valued feature whose entitlement carries a tunable `value` — "max file size", "support tier" — a plan sets per tier. `useConfigEntitlement(slug, options?)` returns `access` and **`value`** (a `number` or `string`, or `null` when there's no config entitlement). Its `options.fallback` is `{ access: boolean; value: number | string }`. `ConfigGate` renders a **render-prop** `children` — a function `(value: number | string) => ReactNode` called with the resolved value when access is granted; `noAccessComponent` renders when access is denied or the value is `null`. ```tsx Hook theme={null} import { useConfigEntitlement } from '@unitpay/react'; export default function UploadLimit() { const { access, value, isLoading, error } = useConfigEntitlement('max-upload-mb-ftr', { fallback: { access: false, value: 25 }, }); if (isLoading) return
Loading…
; if (error) return
Couldn’t load your plan limits.
; if (!access) return
Uploads aren’t included in your plan.
; return
Max upload size: {value} MB
; } ``` ```tsx Gate theme={null} import { ConfigGate } from '@unitpay/react'; export default function Example() { return ( }> {(value) => } ); } ```
## Enum features An enum feature whose entitlement is a set of allowed string values — allowed regions, export formats, model tiers. `useEnumEntitlement(slug, options?)` returns `access` and **`enumValues`** (the array of allowed strings, empty for any non-enum / missing entitlement). Pass `options.requestedValues` to probe whether specific values are permitted (forces a live `/check`) — `access` then reflects whether those values are in the allowed set. Its `options.fallback` is `{ access: boolean; enumValues: string[] }`. `EnumGate` renders `children` only when access exists; pass `requestedValues` to require specific values to be in the allowed set. ```tsx Hook theme={null} import { useEnumEntitlement } from '@unitpay/react'; export default function RegionPicker() { const { access, enumValues, isLoading, error } = useEnumEntitlement('regions-ftr', { fallback: { access: false, enumValues: [] }, }); if (isLoading) return
Loading…
; if (error) return
Couldn’t load regions.
; if (!access) return
Region selection isn’t in your plan.
; return ( ); } ``` ```tsx Gate theme={null} import { EnumGate } from '@unitpay/react'; export default function Example() { return ( }> ); } ```
## See also `useCustomer` loads the entitlement cache these hooks and gates read from, plus a synchronous `check()`. The subscriptions that grant these entitlements. # Error handling Source: https://docs.useunitpay.com/react/errors The SDK's typed error model Every request throws a typed error you can discriminate with `instanceof`. All extend `UnitPayError`. ## Usage ```tsx theme={null} import { HttpError, NetworkError, TimeoutError } from '@unitpay/react'; try { await client.get('/customers/cus_123'); } catch (e) { if (e instanceof HttpError && e.isAuthError) { // 401 / 403 — redirect to sign-in } else if (e instanceof HttpError && e.isRateLimited) { // 429 — back off (the client already honors Retry-After) } else if (e instanceof TimeoutError) { // exceeded the configured timeout } else if (e instanceof NetworkError) { // offline / DNS / TLS } } ``` ## Error types ### `HttpError` A non-2xx response. Carries `status`, `code`, `requestId`, and `details`, plus convenience getters: `true` for `401` / `403`. `true` for `429`. `true` for `408` / `429` / `5xx`. The client retries these automatically with jittered exponential backoff. ### `NetworkError` A failed fetch — offline, DNS, or TLS error. Carries the underlying `cause`. ### `TimeoutError` The request exceeded the configured timeout. ### `PmInUseError` Thrown by `usePaymentMethods().detach()` when the card is still in use. Carries `activeSubscriptionIds` and `autoTopupAccountIds` so you can tell the customer what's blocking removal — pair it with [`usePaymentMethodDependencies`](/react/payment-methods) to show the blockers before they try. Transient failures (`408` / `429` / `5xx` and network errors) are retried automatically with jittered exponential backoff. Retry budget and per-request timeout are configurable on the client. # Imperative API Source: https://docs.useunitpay.com/react/imperative Reach the underlying UnitPayClient for ad-hoc and non-React billing calls. The typed mutation hooks (`useChangePlan`, `useTopUp`, `useAttachAddon`, …) cover the common flows and integrate with React Query cache / loading / error state. `useBilling` is the escape hatch for everything else — imperative calls from event handlers, async flows, and non-render contexts. Every hook returns `isLoading: boolean` and `error: Error | null` (see [Introduction](/react/introduction)); the section below lists this hook's specific returns. ## useBilling Returns the underlying `UnitPayClient` bound to the active portal token. Use it for ad-hoc imperative calls and non-render contexts — event handlers, async flows, anything not tied to the render lifecycle. For form submissions and anything that should integrate with React Query cache / loading / error state, prefer the dedicated mutation hooks where they exist. **Parameters** — none. `useBilling` **throws** if no `portalToken` is set on ``. This surfaces the misconfiguration at first use rather than letting calls silently no-op. Only call it under a provider configured with a portal token. **Returns** * `UnitPayClient` — the imperative client instance. It exposes resource accessors (e.g. `billing.subscription(id)`, `billing.usage`) plus settlement helpers like `handleSettlement`. ```tsx theme={null} import { useBilling } from '@unitpay/react'; import { useRouter } from 'next/navigation'; export default function ChangePlanButton({ subId, newPlanId }: { subId: string; newPlanId: string }) { const billing = useBilling(); const router = useRouter(); async function handleChange() { const result = await billing.subscription(subId).change({ newPlanId }); billing.handleSettlement(result, { onChargedInline: () => router.push('/billing'), }); } return ; } ``` Reach for a dedicated hook first — `useBilling` is the escape hatch for flows the typed hooks don't cover. ## See also The `handleSettlement` outcome kinds and their callbacks. Configure the `portalToken` `useBilling` binds to. # Introduction Source: https://docs.useunitpay.com/react/introduction How the @unitpay/react hooks work `@unitpay/react` gives your app type-safe React hooks and components for building customer-facing billing UIs on **UnitPay** — the billing engine where **dollars and credits are both first-class**. One provider exposes a customer's subscriptions, invoices, payment methods, credit wallets, entitlements, and checkout. ## How hooks work Every hook is built on [TanStack Query](https://tanstack.com/query) and reads from a single shared cache scoped to the current customer: * **Auth** is a short-lived **portal-session token** (`portalToken`) minted on your server with [`@unitpay/node`](https://www.npmjs.com/package/@unitpay/node). The SDK attaches it as a bearer token and refreshes it for you — proactively (\~60s before it expires) and reactively (on a `401`). No secret keys ever reach the browser. * **One fetch, many gates.** `useCustomer()` loads the customer and their entitlements once; `useEntitlement` and the `` components then resolve from that cache. Pass `requestedUsage` (or `{ fresh: true }`) when you need a live server check. * **Mutations** (`useTopUp`, `useChangePlan`, …) resolve to a `SettleOutcome` describing how money moved, and auto-invalidate the affected queries. See [Settlement model](/react/settlement). ## Dollars and credits Both billing paths are modeled side by side — subscriptions and invoices on the dollar side; wallets, grants, and the ledger on the credit side. Credit affordances render on every relevant surface regardless of whether the current plan uses credits. ## Installation ```bash theme={null} npm install @unitpay/react react @tanstack/react-query ``` `react` (`>=18`) and `@tanstack/react-query` (`^5`) are peer dependencies — install one copy in your app so the SDK shares your `QueryClient`. ## Next steps Wrap your app and configure the SDK. The customer's state and core billing actions. Declarative access control components. Proxy requests through your own backend. # Invoices Source: https://docs.useunitpay.com/react/invoices List invoices, read one in detail, preview renewals, and collect payment. Hooks for the invoice surfaces of a customer portal — the history list, a single invoice detail page, the next-renewal preview, and paying an outstanding balance. Amounts are always in **minor units** (e.g. cents). Every hook returns loading/error state — reads via `isLoading: boolean`, the mutation via `isPending: boolean` — plus `error: Error | null`. See [Introduction](/react/introduction). The sections below list each hook's specific returns. ## useInvoices Fetches the full invoice history for the customer in context as one cached list, plus a `downloadPdf(id)` helper that opens a row's finalized PDF in a new tab. Takes no arguments; the customer comes from `` context. **Returns** * `invoices` — `Invoice[]` — every invoice on the customer, or `[]` while loading or when none exist. `status` mirrors the DB enum (`draft` · `issued` · `partially_paid` · `paid` · `overdue` · `void` · `uncollectible`); amounts (`totalAmount`, `amountDue`, `amountPaid`) are in minor units. * `downloadPdf(invoiceId)` — opens the matching invoice's `pdfUrl` in a new tab. No-op when the row has no link yet (still finalizing) or when called outside the browser — grey out the button on `!invoice.pdfUrl`. ```tsx theme={null} import { useInvoices } from '@unitpay/react'; export default function InvoiceList() { const { invoices, downloadPdf, isLoading, error } = useInvoices(); if (isLoading) return

Loading…

; if (error) return

Failed to load invoices.

; if (invoices.length === 0) return

No invoices yet.

; return (
    {invoices.map((inv) => (
  • {inv.invoiceNumber ?? 'Draft'} — {inv.status}
  • ))}
); } ``` ```json theme={null} { "id": "inv_01k2x7m3q0abcdef", "customerId": "cus_01ktxbxs2re8nsdfmzxw0gh988", "subscriptionId": "sub_01k2x6...", "status": "issued", "invoiceNumber": "INV-1042", "currency": "usd", "totalAmount": 4900, "amountDue": 4900, "amountPaid": 0, "collectionMethod": "charge_automatically", "netPaymentTermDays": null, "dueDate": "2026-07-01T00:00:00.000Z", "paidAt": null, "createdAt": "2026-06-01T00:00:00.000Z", "pdfUrl": "https://files.useunitpay.com/invoices/INV-1042.pdf" } ``` ## useInvoice Loads the full payload for a single invoice — the header fields plus its line `items` and successful `payments` — for an invoice detail page. The server filters payments to `status = 'succeeded'`, so the portal never has to hide retry noise. **Parameters** The invoice to fetch. The query is disabled (and `invoice` stays `null`) while this is `null` / `undefined`. **Returns** * `invoice` — `InvoiceDetail | null` — the invoice plus `items: InvoiceItem[]` and `payments: InvoicePayment[]`, or `null` while loading. Amounts are in minor units. * `downloadPdf()` — opens the invoice's `pdfUrl` in a new tab. No-op until the PDF finalizes server-side or when called outside the browser — grey out the button on `!invoice.pdfUrl`. ```tsx theme={null} import { useInvoice } from '@unitpay/react'; export default function InvoiceDetail({ invoiceId }: { invoiceId: string }) { const { invoice, downloadPdf, isLoading, error } = useInvoice(invoiceId); if (isLoading) return

Loading…

; if (error || !invoice) return

Couldn’t load this invoice.

; return (

{invoice.invoiceNumber ?? 'Draft'}

Status: {invoice.status}

    {invoice.items.map((item) => (
  • {item.description} — {item.quantity} × {item.unitAmount}
  • ))}
); } ``` ```json theme={null} { "id": "inv_01k2x7m3q0abcdef", "customerId": "cus_01ktxbxs2re8nsdfmzxw0gh988", "subscriptionId": "sub_01k2x6...", "status": "partially_paid", "invoiceNumber": "INV-1042", "currency": "usd", "totalAmount": 4900, "amountDue": 1900, "amountPaid": 3000, "collectionMethod": "charge_automatically", "netPaymentTermDays": null, "dueDate": "2026-07-01T00:00:00.000Z", "paidAt": null, "createdAt": "2026-06-01T00:00:00.000Z", "pdfUrl": "https://files.useunitpay.com/invoices/INV-1042.pdf", "items": [ { "id": "ili_01k2x7...", "invoiceId": "inv_01k2x7m3q0abcdef", "description": "Pro plan — June", "quantity": 1, "unitAmount": 4900, "amount": 4900, "currency": "usd" } ], "payments": [ { "id": "pay_01k2x8...", "amount": 3000, "currency": "usd", "source": "online", "paymentMethodType": "card", "externalReference": null, "paidAt": "2026-06-05T12:00:00.000Z" } ] } ``` ## useUpcomingInvoice Previews the next renewal for a subscription — the "you'll be charged \$X on date Y" portal card. It's a pure read (`GET /v1/subscriptions/:id/upcoming-invoice`) that never mutates state; the server computes the line items from the current sub config (plan + addons + usage so far) at request time. **Parameters** The subscription to preview. The query is disabled (and `invoice` stays `null`) while this is omitted. **Returns** — `invoice: UpcomingInvoice | null` — the renewal preview, or `null` when there is no upcoming renewal (canceled / ended subs, or before trial end) and while loading. Amounts are in minor units. `creditApplied` is always `0` in the preview. ```tsx theme={null} import { useUpcomingInvoice } from '@unitpay/react'; export default function NextCharge({ subscriptionId }: { subscriptionId: string }) { const { invoice, isLoading, error } = useUpcomingInvoice(subscriptionId); if (isLoading) return

Loading…

; if (error) return

Couldn’t load your next invoice.

; if (!invoice) return

No upcoming renewal.

; return (

Next charge: {invoice.amountDue} {invoice.currency} on{' '} {new Date(invoice.billingDate).toLocaleDateString()}

); } ``` ```json theme={null} { "subscriptionId": "sub_01k2x6...", "planId": "plan_01k2x5...", "planName": "Pro", "currency": "usd", "billingPeriodStart": "2026-07-01T00:00:00.000Z", "billingPeriodEnd": "2026-07-31T23:59:59.999Z", "billingDate": "2026-07-01T00:00:00.000Z", "items": [ { "description": "Pro plan", "quantity": 1, "unitPrice": 4900, "amount": 4900, "kind": "plan_fee" }, { "description": "API calls", "quantity": 12000, "unitPrice": 1, "amount": 12000, "kind": "metered_usage" } ], "subtotal": 16900, "discountAmount": 0, "taxAmount": 0, "totalAmount": 16900, "amountDue": 16900, "minimumSpend": 0, "creditApplied": 0 } ``` ## usePayInvoice Mutation that pays one outstanding invoice. The server picks the path — inline charge of a saved card, a hosted recovery form, or no action — and resolves to a [`SettleOutcome`](/react/settlement). Pass `on*` settlement handlers to wire your UX to each outcome. **Parameters** The invoice to collect payment for. Settlement callbacks dispatched after the mutation resolves — `onChargedInline`, `onRequiresForm`, `onNoAction`, etc. See the [Settlement model](/react/settlement). The `pay(input?)` alias accepts an optional input: Override the auto-generated `Idempotency-Key`. Force the server to mint `requires_form` even when a payment method is on file — the customer always sees the inline `` and explicitly confirms, instead of a silent off-session debit. Portal callers typically set this `true`. **Returns** — a TanStack `UseMutationResult` extended with `pay(input?) => Promise`. On success the hook invalidates the single-invoice cache and the full customer scope (settling an invoice can move money, change credit balance, and unblock a `past_due` sub), then dispatches your `options` handlers. The server picks the path: * Customer has a PM **and** `amountDue > 0` **and** no `forceForm` → inline charge → `charged_inline` * No PM, or `forceForm`, or the inline charge fails → mints a recovery checkout → `requires_form` * Already paid, or `amountDue === 0` → `no_action` ```tsx theme={null} import { usePayInvoice } from '@unitpay/react'; import { useState } from 'react'; export default function PayInvoiceButton({ invoiceId }: { invoiceId: string }) { const [form, setForm] = useState(null); const { pay, isPending } = usePayInvoice(invoiceId, { onChargedInline: () => alert('Paid!'), onRequiresForm: (result) => setForm({ token: result.client.token }), onNoAction: () => alert('Nothing to pay.'), }); if (form) { // Mount (Stripe Elements). return ; } return ( ); } ``` `forceForm` is a per-call input — pass it to `pay({ forceForm: true })`, not to the hook's options argument. The second argument (`UsePayInvoiceOptions`, which equals `HandleSettlementOptions`) carries only your settlement callbacks. The `pay()` input is where `idempotencyKey` and `forceForm` overrides live. ## See also The full `SettleOutcome` union and `handleSettlement` dispatch behind `usePayInvoice`. Capture a card before paying, or replace an in-use default. The subscriptions these invoices bill for. `payInvoice()` on `useCustomer` mints a hosted-link alternative. # Next.js adapter Source: https://docs.useunitpay.com/react/next-adapter Proxy SDK requests through your own backend The `unitpayHandler` from `@unitpay/react/next` lets you route SDK calls through your Next.js backend instead of calling the UnitPay API directly. The secret key stays server-side, and requests never leave your domain. ## Usage Add a catch-all route handler: ```ts theme={null} // app/api/unitpay/[...path]/route.ts import { unitpayHandler } from '@unitpay/react/next'; const handler = unitpayHandler({ apiKey: process.env.UNITPAY_SECRET_KEY!, apiBaseUrl: 'https://api.useunitpay.com/v1', allowedOrigins: [process.env.NEXT_PUBLIC_APP_URL!], getCustomerId: async (request) => resolveCustomerFromSession(request), }); export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE, }; ``` Then point the provider at the proxy instead of the API: ```tsx theme={null} import { UnitPayProvider } from '@unitpay/react'; ``` ## Parameters Your UnitPay secret key. Read it from a server-only environment variable — never expose it to the browser. Resolve the authenticated customer for the incoming request (from your session/auth). Return `null` for an unauthenticated request — the handler responds `401`. UnitPay API base URL. Defaults to `https://api.useunitpay.com/v1`. Origins permitted to call the proxy. Requests with an `Origin` outside this list are rejected with `403`. Pass `'*'` to disable the check (not recommended in production). When omitted, the `Origin` check is skipped. Maximum request body size. Defaults to 1 MB; larger bodies are rejected with `413`. **Security defaults:** disallowed methods → `405`; off-allowlist `Origin` → `403`; oversized bodies → `413`. Only `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` are forwarded. # Payment methods Source: https://docs.useunitpay.com/react/payment-methods Capture, list, replace, and detach the customer's payment methods. Hooks for the wallet surface of a customer portal — list saved methods and set-default / detach them, preflight what a detach would break, capture a new method, and atomically swap an in-use default. Together they cover the "you can't just delete the card that's paying for things" flow safely. Every hook returns loading/error state — reads via `isLoading: boolean`, mutations via `isPending: boolean` — plus `error: Error | null`. See [Introduction](/react/introduction). The sections below list each hook's specific returns. ## usePaymentMethods Loads the customer's saved payment methods and exposes the two write actions a wallet UI needs: `setDefault(id)` and `detach(id)`. Both invalidate the customer scope on success so the list re-renders. Takes no arguments; the customer comes from `` context. **Returns** * `paymentMethods` — `PaymentMethod[]` — the customer's saved methods, or `[]` while loading or when none exist. Card fields (`cardLast4`, `cardBrand`, `expMonth`, `expYear`) are `null` for non-card types. * `setDefault(paymentMethodId)` — `(id: string) => Promise` — points the customer default at this method. Invalidates the customer scope on success. * `detach(paymentMethodId)` — `(id: string) => Promise` — removes the method. Invalidates the customer scope on success. Throws `PmInUseError` when the method is a default still powering an active sub or auto-topup rule (see the warning below). * `isActing` — `boolean` — `true` while a `setDefault` or `detach` mutation is in flight. ```tsx theme={null} import { usePaymentMethods } from '@unitpay/react'; export default function Wallet() { const { paymentMethods, setDefault, detach, isLoading, isActing } = usePaymentMethods(); if (isLoading) return

Loading…

; return (
    {paymentMethods.map((pm) => (
  • {pm.cardBrand} •••• {pm.cardLast4}
  • ))}
); } ``` `detach()` throws **`PmInUseError`** when the method is the customer default and is still powering an active subscription or auto-topup rule (server `409 pm_in_use`). The error carries `paymentMethodId`, `activeSubscriptionIds`, `autoTopupAccountIds`, and `requestId` from `error.details` — render the block reason and route the user to the replace flow. Preflight it with [`usePaymentMethodDependencies`](#usepaymentmethoddependencies) so you never show a "Remove" button that's doomed to throw. ```tsx theme={null} import { usePaymentMethods, PmInUseError } from '@unitpay/react'; async function remove(detach: (id: string) => Promise, id: string) { try { await detach(id); } catch (e) { if (e instanceof PmInUseError) { // e.activeSubscriptionIds / e.autoTopupAccountIds → offer the replace flow. console.warn('Still in use:', e.activeSubscriptionIds); return; } throw e; } } ``` ```json theme={null} { "id": "pm_01j9x...", "customerId": "cus_01ktxbxs2re8nsdfmzxw0gh988", "organizationId": "org_01j9x...", "paymentProviderId": "psp_01j9x...", "providerMethodId": "pm_1Pabc...", "type": "card", "status": "active", "cardLast4": "4242", "cardBrand": "visa", "expMonth": 12, "expYear": 2028, "createdAt": "2026-01-04T10:00:00.000Z", "updatedAt": "2026-06-20T08:15:00.000Z" } ``` ## usePaymentMethodDependencies The preflight query for a "remove card" confirmation modal. It returns whether the method is the customer default and, if so, the active subscriptions and auto-topup credit accounts that a detach would orphan. `data.blocksDetach` mirrors the server-side guardrail — when `true`, route the user to the replace flow instead of letting them click "Delete". **Parameters** The method to inspect. The query is disabled while this is `null` / `undefined`. When `false`, the query is disabled — useful for a modal that fetches on open (`enabled: isOpen`). Defaults to `true`. **Returns** — a TanStack `UseQueryResult` (short `staleTime` of 10s, because dependency state changes whenever subs / auto-topup rules change). `data` once loaded is `PaymentMethodDependencies`: * **`paymentMethodId`** `string` * **`isDefault`** `boolean` — whether this is the customer default. * **`blocksDetach`** `boolean` — `true` when detaching would orphan an active sub or auto-topup rule. * **`activeSubscriptions`** — `Array<{ id, status, planId, collectionMethod, currentBillingPeriodEnd }>`. * **`autoTopupAccounts`** — `Array<{ id, creditCurrencyId, autoTopupThreshold, autoTopupAmount, autoTopupPackageId }>`. ```tsx theme={null} import { usePaymentMethodDependencies } from '@unitpay/react'; export default function ConfirmDeletePM({ paymentMethodId, isOpen, }: { paymentMethodId: string; isOpen: boolean; }) { const { data, isLoading } = usePaymentMethodDependencies(paymentMethodId, { enabled: isOpen, }); if (isLoading || !data) return

Checking…

; if (data.blocksDetach) { return (

This card powers {data.activeSubscriptions.length} subscription(s) and{' '} {data.autoTopupAccounts.length} auto-topup rule(s). Replace it instead.

); } return ; } ``` ```json theme={null} { "paymentMethodId": "pm_01j9x...", "isDefault": true, "blocksDetach": true, "activeSubscriptions": [ { "id": "sub_01k2x6...", "status": "active", "planId": "plan_01k2x5...", "collectionMethod": "charge_automatically", "currentBillingPeriodEnd": "2026-07-31T23:59:59.999Z" } ], "autoTopupAccounts": [ { "id": "ca_01k2x9...", "creditCurrencyId": "ccur_01k2x4...", "autoTopupThreshold": 1000, "autoTopupAmount": 5000, "autoTopupPackageId": "pkg_01k2x3..." } ] } ``` ## useSetupPaymentMethod Mutation that captures a new payment method via a PSP SetupIntent. It always resolves to a `requires_form` [`SettleOutcome`](/react/settlement) — the server mints a SetupIntent and returns the PSP client envelope, which you mount in ``. After the customer completes setup and the PSP webhook attaches the method, the payment-methods cache is invalidated so the next render shows it. **Parameters** Settlement callbacks dispatched after the mutation resolves — wire `onRequiresForm` to mount the inline form. See the [Settlement model](/react/settlement). The `setupPaymentMethod(input?)` alias accepts an optional input: Where to send the customer after a successful hosted-mode setup. Where to send the customer if they abandon a hosted-mode setup. Whether to render the form inline (`embedded`) or redirect to the PSP-hosted page (`hosted`). **Returns** — a TanStack `UseMutationResult` extended with `setupPaymentMethod(input?)`, which triggers the SetupIntent and resolves to a `requires_form` outcome whose `client.token` is the Stripe Elements `clientSecret`. ```tsx theme={null} import { useSetupPaymentMethod } from '@unitpay/react'; import { useState } from 'react'; export default function AddCard() { const [clientSecret, setClientSecret] = useState(null); const { setupPaymentMethod, isPending } = useSetupPaymentMethod({ onRequiresForm: (result) => setClientSecret(result.client.token), }); if (clientSecret) { // Mount Stripe Elements in save-only mode with this client secret. return ; } return ( ); } ``` ## useReplacePaymentMethod Mutation that performs an atomic "swap A for B" — it flips the customer default to the new method and detaches the old one in one server transaction. It's the resolution path when `usePaymentMethodDependencies` reports `blocksDetach`: the old default is still powering active subs / auto-topup rules, so you can't just detach it. **Parameters** — the hook takes no arguments; the replacement pair goes to the `replacePaymentMethod(input)` alias: The method being replaced — typically the customer's current default. The method to become the new default. Must already be attached and active (capture one first with [`useSetupPaymentMethod`](#usesetuppaymentmethod)). **Returns** — a TanStack `UseMutationResult` extended with `replacePaymentMethod(input)`, which runs the swap and resolves to a `ReplacePaymentMethodResult`. On success it invalidates all customer-scoped queries (the PM list, default pointer, sub references). The result carries: * **`customerId`** `string` * **`oldPaymentMethodId`** `string` * **`newPaymentMethodId`** `string` * **`becameDefault`** `boolean` — `true` iff the default pointer actually moved this call. * **`clearedOldDefault`** `boolean` — `true` iff the old method was the default (and is now cleared). ```tsx theme={null} import { useReplacePaymentMethod } from '@unitpay/react'; export default function SwapCard({ oldId, newId }: { oldId: string; newId: string }) { const { replacePaymentMethod, isPending } = useReplacePaymentMethod(); return ( ); } ``` ```json theme={null} { "customerId": "cus_01ktxbxs2re8nsdfmzxw0gh988", "oldPaymentMethodId": "pm_01j9x...", "newPaymentMethodId": "pm_01k2xa...", "becameDefault": true, "clearedOldDefault": true } ``` ## See also Collect payment with `usePayInvoice` once a method is on file. The `requires_form` outcome and `client` envelope behind card capture. The SDK error model and `PmInUseError`. `setupPayment()` on `useCustomer` mints a hosted-link alternative. # UnitPayProvider Source: https://docs.useunitpay.com/react/provider Provider component that configures the SDK for your React app The `UnitPayProvider` wraps your application and provides the UnitPay client to every hook and component. It manages the [TanStack Query](https://tanstack.com/query) cache and the portal-session token lifecycle (automatic refresh). ## Usage Mint a portal token on your server, then pass it — along with the `customerId` — to the provider: ```tsx theme={null} // app/providers.tsx import { UnitPayProvider } from '@unitpay/react'; export function Providers({ children, customerId, portalToken, }: { children: React.ReactNode; customerId: string; portalToken: string; }) { return ( { window.location.href = '/login'; }, }} > {children} ); } ``` The `portalToken` is a short-lived JWT minted server-side with `@unitpay/node` (`unitpay.portalSessions.create({ customerId })`). The SDK refreshes it automatically — you do nothing after the initial mint. `onSessionExpired` fires only when refresh fails for good. ## Props Configuration object (see below). Your own TanStack Query client. The provider creates a default one if omitted. ## `config` (UnitPayConfig) The customer this provider acts for. Required for customer-scoped hooks. Portal-session JWT minted server-side by your backend. Refreshed automatically by the SDK. UnitPay API base URL. Defaults to `https://api.useunitpay.com/v1`. Route calls through a same-origin proxy (see the [Next.js adapter](/react/next-adapter)). Takes precedence over `apiBaseUrl`. Pin a product for multi-product orgs. Hooks like `usePricing()` prefer this; single-product orgs can omit it (the SDK auto-resolves the only product). Custom `fetch` implementation (SSR, instrumentation). Fires once when token refresh fails terminally (revoked, expired beyond max TTL, customer archived). Flip your UI to a "session expired" screen. # Settlement model Source: https://docs.useunitpay.com/react/settlement How money-moving mutations resolve Money-moving mutations — `useCreateSubscription`, `useChangePlan`, `useTopUp`, `usePayInvoice`, and friends — resolve to a **`SettleOutcome`**: a discriminated union on `kind` that tells you exactly how the money moved. Pass the matching `on*` handler in the hook's options, or call `client.handleSettlement(result, handlers)`. ## Outcomes | `kind` | Meaning | Handler | | ------------------- | ------------------------------------------------------------------- | ----------------- | | `charged_inline` | Card charged + invoice paid immediately | `onChargedInline` | | `requires_form` | A hosted PSP form is needed (3DS / new card) — open `result.client` | `onRequiresForm` | | `invoice_sent` | NET-terms invoice issued + emailed (send-invoice customers) | `onInvoiceSent` | | `invoice_added` | Charge appended to the next invoice | `onInvoiceAdded` | | `deferred` | Scheduled for a future date | `onDeferred` | | `created_no_charge` | Created with nothing to charge | `onCreated` | | `no_action` | Already settled / zero amount | `onNoAction` | ## Usage ```tsx theme={null} import { useCreateSubscription } from '@unitpay/react'; export default function Subscribe() { const { createSubscription, isPending } = useCreateSubscription({ onChargedInline: ({ subscription }) => router.push(`/welcome/${subscription?.id}`), onRequiresForm: ({ client, reason }) => openHostedCheckout(client, reason), onInvoiceSent: ({ invoice }) => toast(`Invoice ${invoice.invoiceNumber} on its way`), }); return ( ); } ``` `requires_form` carries a PSP `client` payload and a `reason` code — hand it to your hosted checkout / Stripe Elements flow to collect authentication or a new card. The other outcomes are terminal: the money has already moved (or there was nothing to do). On success, the hook also invalidates the affected queries (customer, subscriptions, invoices, wallets) so the rest of your UI refreshes automatically. # Subscriptions Source: https://docs.useunitpay.com/react/subscriptions List, read, create, and change the current customer's subscriptions. Hooks for reading and mutating the subscriptions of the customer in [``](/react/provider) context — list them, resolve the live one, create a new subscription, change plans, attach addons, and cancel or resume. Every hook returns loading/error state — reads via `isLoading: boolean`, mutations via `isPending: boolean` — plus `error: Error | null`. See [Introduction](/react/introduction). The sections below list each hook's specific returns. The four mutation hooks that move money (`useCreateSubscription`, `useChangePlan`, `useAttachAddon`) resolve to a [`SettleOutcome`](/react/settlement) — the server decides whether to charge inline, mint an inline ``, send an invoice, or take no action — which you handle via the `on*` settlement callbacks in `options`. `useCancelSubscription` and `useUncancelSubscription` move no money, so they return a plain `SubscriptionMutationResult` instead. ## useSubscriptions Lists every subscription belonging to the customer — across every product, plan, and status — as one cached list. Takes no arguments; the customer comes from `` context. **Returns** — `subscriptions: Subscription[]` — every subscription on the customer (up to 50), or `[]` while loading or when none exist. ```tsx theme={null} import { useSubscriptions } from '@unitpay/react'; export default function SubscriptionList() { const { subscriptions, isLoading, error } = useSubscriptions(); if (isLoading) return

Loading…

; if (error) return

Failed to load subscriptions.

; if (subscriptions.length === 0) return

No subscriptions yet.

; return (
    {subscriptions.map((sub) => (
  • {sub.planId} — {sub.status}
  • ))}
); } ``` ## useActiveSubscription Answers "what is the customer billing on right now?" It filters `useSubscriptions` (no extra request) to live statuses — `active`, `trialing`, `pending`, `past_due` — returning both the first match for single-sub portals and the full filtered list for multi-sub customers. **Parameters** Restrict to subscriptions whose `planId` is in this list. Use when the portal is product-scoped — pass the product's plan ids (e.g. from `usePricing(productId)`). Omit to consider every plan the customer has. **Returns** * `subscription` — `Subscription | null` — the first live subscription matching the filter, or `null` while loading or when none are live. * `subscriptions` — `Subscription[]` — every live subscription matching the filter. Use this when a customer legitimately has more than one (different products, currencies, addons). ```tsx theme={null} import { useActiveSubscription } from '@unitpay/react'; export default function CurrentPlan() { const { subscription, isLoading } = useActiveSubscription(); if (isLoading) return

Loading…

; if (!subscription) return

No active subscription.

; return (

On {subscription.planId} ({subscription.status}) until{' '} {subscription.currentBillingPeriodEnd}

); } ``` Scope to one product by passing its plan ids: ```tsx theme={null} import { useActiveSubscription, usePricing } from '@unitpay/react'; export default function ProductPlan({ productId }: { productId: string }) { const { pricing } = usePricing(productId); const planIds = pricing?.tiers.flatMap((t) => t.plans.map((p) => p.id)) ?? []; const { subscription } = useActiveSubscription({ planIds }); return

{subscription ? subscription.planId : 'Not subscribed to this product.'}

; } ``` ## useSubscription A read-only hook that fetches one subscription by id and exposes `previewChange`, a parameterized read that previews what switching to another plan would cost. Mutations live in the dedicated hooks below. **Parameters** The subscription to fetch. The query stays disabled until an id is provided, so it is safe to pass `undefined` while the id is still loading. **Returns** * `subscription` — `Subscription | null` — the subscription, or `null` while loading or when no id is set. * `previewChange(newPlanId)` — `(newPlanId: string) => Promise` — previews switching to `newPlanId`. Returns the full change breakdown: `direction` (`upgrade` / `downgrade` / `lateral`), `timing` (`immediate` / `deferred`), `dueNow`, `effectiveAt`, entitlement diffs, and credit deltas. Memoized, so it is safe in a `useEffect` dependency array. ```tsx theme={null} import { useEffect, useState } from 'react'; import { useSubscription } from '@unitpay/react'; import type { SubscriptionPreviewChange } from '@unitpay/react'; export default function PlanSwitcher({ subscriptionId }: { subscriptionId: string }) { const { subscription, isLoading, previewChange } = useSubscription(subscriptionId); const [preview, setPreview] = useState(null); useEffect(() => { previewChange('pln_pro_monthly').then(setPreview); }, [previewChange]); if (isLoading) return

Loading…

; if (!subscription) return

Not found.

; return (

Current plan: {subscription.planId}

{preview && (

Switch is a {preview.direction}; due now {preview.dueNow.amount} {preview.dueNow.currency}.

)}
); } ``` ## useCreateSubscription Mutation that subscribes the customer in context to a plan. `salesMotion` defaults to `self_serve` server-side. `useCreateSubscription(options?)` takes no id; `options` is a [`HandleSettlementOptions`](/react/settlement) object of settlement callbacks (`onChargedInline`, `onRequiresForm`, `onInvoiceSent`, `onCreated`, `onDeferred`, `onNoAction`, …). The returned `createSubscription(input)` alias accepts: The plan to subscribe to. Override the billing currency. Defaults to the plan's currency. Arbitrary metadata to attach to the subscription. Force the inline `` even when a payment method is already on file. Override the auto-generated `Idempotency-Key`. **Returns** — a React Query mutation plus `createSubscription(input) => Promise` (an ergonomic alias for `mutateAsync`; the `on*` callbacks have already been dispatched against the outcome). ```tsx theme={null} import { useState } from 'react'; import { useCreateSubscription } from '@unitpay/react'; import type { RequiresFormClient } from '@unitpay/react'; export default function Subscribe({ planId }: { planId: string }) { const [formClient, setFormClient] = useState(null); const { createSubscription, isPending } = useCreateSubscription({ onChargedInline: () => alert('Subscribed and paid.'), onRequiresForm: (r) => setFormClient(r.client), onCreated: () => alert('Subscription created (no charge).'), }); return ( ); } ``` ## useChangePlan Mutation that moves an existing subscription to a new plan (upgrade, downgrade, or lateral). The server dispatches inline charge (PLG with a payment method), recovery checkout (no PM or decline), an SLG invoice, or a deferred change. Preview the change first with [`useSubscription`](#usesubscription)'s `previewChange`. `useChangePlan(subscriptionId, options?)` — the subscription id is the first argument; `options` is a [`HandleSettlementOptions`](/react/settlement) object. The returned `changePlan(input)` alias accepts: The plan to switch to. Force the inline `` even when a payment method is already on file. Override the auto-generated `Idempotency-Key`. **Returns** — a React Query mutation plus `changePlan(input) => Promise`. ```tsx theme={null} import { useState } from 'react'; import { useChangePlan } from '@unitpay/react'; import type { RequiresFormClient } from '@unitpay/react'; export default function UpgradeButton({ subscriptionId }: { subscriptionId: string }) { const [formClient, setFormClient] = useState(null); const { changePlan, isPending } = useChangePlan(subscriptionId, { onChargedInline: () => alert('Plan upgraded.'), onRequiresForm: (r) => setFormClient(r.client), onDeferred: (r) => alert(`Scheduled for ${r.scheduledFor}`), }); return ( ); } ``` ## useAttachAddon Mutation that attaches an addon plan to an existing subscription. Portal callers typically set `forceForm` so addon purchases always confirm through the inline `` rather than silently debiting the saved card. `useAttachAddon(subscriptionId, options?)` — the parent subscription id is the first argument; `options` is a [`HandleSettlementOptions`](/react/settlement) object (also supports `onInvoiceAdded`). The returned `attachAddon(input)` alias accepts: The addon plan to attach. Force the dispatcher to mint `requires_form` even when a payment method is on file, so the purchase always shows the inline `` and is explicitly confirmed — no silent off-session debit of the default card. Override the auto-generated `Idempotency-Key`. **Returns** — a React Query mutation plus `attachAddon(input) => Promise`. ```tsx theme={null} import { useState } from 'react'; import { useAttachAddon } from '@unitpay/react'; import type { RequiresFormClient } from '@unitpay/react'; export default function AddSeats({ subscriptionId }: { subscriptionId: string }) { const [formClient, setFormClient] = useState(null); const { attachAddon, isPending } = useAttachAddon(subscriptionId, { onChargedInline: () => alert('Addon attached.'), onRequiresForm: (r) => setFormClient(r.client), }); return ( ); } ``` ## useCancelSubscription Mutation that cancels a subscription. Cancellation moves no money, so — unlike the mutations above — it does **not** return a `SettleOutcome`; it resolves to a plain `SubscriptionMutationResult`. `useCancelSubscription(subscriptionId, options?)` — the subscription id is the first argument. `options` carries a single `onSuccess(result: SubscriptionMutationResult) => void` callback. The returned `cancel(input?)` alias accepts (all optional — call `cancel()` for a default period-end cancel): Cancel right now instead of at the end of the current billing period. Machine-readable reason code stored on the subscription. Free-text note explaining the cancellation. **Returns** — a React Query mutation plus `cancel(input?) => Promise`, resolving to `{ subscriptionId, status, cancelAtPeriodEnd, effectiveAt }`. ```tsx theme={null} import { useCancelSubscription } from '@unitpay/react'; export default function CancelButton({ subscriptionId }: { subscriptionId: string }) { const { cancel, isPending } = useCancelSubscription(subscriptionId, { onSuccess: (result) => alert( result.cancelAtPeriodEnd ? `Cancels on ${result.effectiveAt}` : 'Canceled immediately.', ), }); return ( ); } ``` ## useUncancelSubscription Mutation that reverses a pending cancellation, keeping the subscription live. Like cancel, it moves no money and resolves to a plain `SubscriptionMutationResult`. `useUncancelSubscription(subscriptionId, options?)` — the subscription id is the first argument. The `uncancel` alias takes no input (call `uncancel()`). `options` carries a single `onSuccess(result: SubscriptionMutationResult) => void` callback. **Returns** — a React Query mutation plus `uncancel() => Promise`, resolving to `{ subscriptionId, status, cancelAtPeriodEnd, effectiveAt }`. ```tsx theme={null} import { useUncancelSubscription } from '@unitpay/react'; export default function ResumeButton({ subscriptionId }: { subscriptionId: string }) { const { uncancel, isPending } = useUncancelSubscription(subscriptionId, { onSuccess: (result) => alert(`Subscription is ${result.status} again.`), }); return ( ); } ``` ## See also The `SettleOutcome` union and `on*` handlers the mutation hooks dispatch. Preview renewals with `useUpcomingInvoice` and collect payment. What each subscription grants the customer. The customer these subscriptions belong to. # Usage & analytics Source: https://docs.useunitpay.com/react/usage-and-analytics Point-in-time usage meters, daily usage history, and credit burn-rate — for the customer in context. Hooks for the "X of Y used" reading of a single metric, the daily usage trajectory a point-in-time meter can't show, and how fast a credit wallet is being consumed. All three are scoped to the portal customer and read server-aggregated endpoints, so they stay correct at any event or ledger volume. Every hook returns `isLoading: boolean` and `error: Error | null` (see [Introduction](/react/introduction)); the sections below list each hook's specific returns. ## useUsageMeter Reads a single metric's current-period usage against its entitlement limit — the live "X of Y used" reading that powers progress bars and quota warnings. It hits `GET /v1/usage?metricId=…` scoped to the portal customer. **Parameters** The billable metric to meter. The query stays disabled until a non-empty `metricId` (and a customer) is present. **Returns** * `meter` — the `UsageMeter` for this metric, or `null` while loading: The metric identifier. Human-readable metric name for display. Units consumed this period. The period allowance, or `null` when unlimited. `true` when the plan grants unlimited usage of this metric. Server-computed percent consumed (`0`–`100`). `0` when unlimited. ```tsx theme={null} import { useUsageMeter } from '@unitpay/react'; export default function ApiCallsMeter() { const { meter, isLoading, error } = useUsageMeter('api_calls'); if (isLoading) return
Loading…
; if (error || !meter) return
Couldn’t load usage.
; if (meter.isUnlimited) { return
{meter.metricName}: {meter.usage.toLocaleString()} (unlimited)
; } return (
{meter.metricName}: {meter.usage} / {meter.limit}
); } ``` ## useUsageHistory Returns daily usage totals over a trailing window, one continuous series per event name. It reads the server-aggregated `GET /v1/usage/aggregate` endpoint (one row per UTC day × event, summed in Postgres) so it's correct at any event volume — a customer with a million events returns the same \~30 rows as one with ten. The hook returns the raw series only; charting is the caller's job. **Parameters** Window length in trailing UTC days. The series is zero-filled across the whole window so charts render a continuous day axis even on quiet days. **Returns** * `series` — `Record>` — for each event name, a continuous daily series ordered oldest → today. Each point has `date` (UTC day, `YYYY-MM-DD`) and `total` (total quantity for that event on that day, `0` on days with no events). Day buckets are UTC-pinned on the server and the axis is built in UTC too, so they line up exactly. * `hasData` — `true` when the server returned at least one aggregate row in the window. Use it to switch between an empty state and a populated chart. ```tsx theme={null} import { useUsageHistory } from '@unitpay/react'; export default function UsageTable() { const { series, hasData, isLoading, error } = useUsageHistory(30); if (isLoading) return
Loading…
; if (error) return
Couldn’t load usage history.
; if (!hasData) return
No usage in the last 30 days.
; return (
{Object.entries(series).map(([eventName, points]) => (

{eventName}

{points.reduce((sum, p) => sum + p.total, 0).toLocaleString()} total {/* hand `points` to your chart library of choice */}
))}
); } ``` ## useCreditBurn Reports how fast one credit wallet is being consumed over a trailing window. It reads the server-aggregated `GET /v1/credits/stats` endpoint scoped to a single credit currency — the daily `consumed` series is summed in Postgres from the append-only ledger, so it's correct at any ledger volume. **Parameters** The credit wallet (currency) to measure. The query stays disabled until this and a customer are present. Window length in trailing UTC days. The series is zero-filled across the whole window. **Returns** * `series` — `Array<{ date, consumed }>` — zero-filled daily consumption ordered oldest → today. `date` is the UTC day (`YYYY-MM-DD`), `consumed` is credits consumed that day (`0` on quiet days). * `avgPerDay` — `number` — mean consumed per day over the window (`windowSum / days`, **including zero days**). The honest steady-state rate, not a cherry-picked active-day average. * `windowSum` — `number` — total consumed across the window. * `peak` — `number` — highest single-day consumption in the window. * `days` — `number` — the window length echoed back. * `hasBurn` — `boolean` — `true` when `windowSum > 0`. Use it to switch between an idle-wallet state and the burn chart. Runway is intentionally left to the caller: divide the wallet balance you already show on the card by `avgPerDay`, so the number matches the displayed balance exactly. Read the balance from [`useCreditAccounts`](/react/credits-and-wallets). ```tsx theme={null} import { useCreditAccounts, useCreditBurn } from '@unitpay/react'; export default function WalletBurn({ creditCurrencyId }: { creditCurrencyId: string }) { const { accounts } = useCreditAccounts(); const { avgPerDay, windowSum, peak, hasBurn, isLoading, error } = useCreditBurn(creditCurrencyId, 30); if (isLoading) return
Loading…
; if (error) return
Couldn’t load burn.
; if (!hasBurn) return
No credits consumed in the last 30 days.
; const balance = accounts.find((a) => a.creditCurrencyId === creditCurrencyId)?.balance ?? 0; const runwayDays = avgPerDay > 0 ? Math.floor(balance / avgPerDay) : Infinity; return (
{windowSum.toLocaleString()} burned · {avgPerDay.toFixed(1)}/day · peak {peak}
~{Number.isFinite(runwayDays) ? `${runwayDays} days left` : 'no burn'}
); } ``` ## See also Wallet balances and the ledger `useCreditBurn` aggregates. The entitlement limit `useUsageMeter` reads against. # Welcome to UnitPay Source: https://docs.useunitpay.com/welcome The billing engine for dollars and credits — both first-class, same platform, same API. ## What is UnitPay? UnitPay is the billing engine for products that charge in **dollars**, in **credits**, or in both. Most billing tools pick a side. Usage-meters (Orb, Metronome) are built to turn usage into a dollar invoice at the end of the month. Credit and entitlement tools are built to hand out balances and gate access. Real products need both — a plan price *and* a credit allowance, a prepaid balance *and* a dollar overage — and end up stitching two systems together. UnitPay is one system where both are first-class. You model what you sell once. Usage flows in once. From there it either becomes a **dollar invoice** you collect on, or **deducts credits** from a balance — often both, in the same plan. Same objects, same API, same dashboard. ## The two paths Everything in UnitPay comes down to how value moves *out* after a customer uses your product: | | **Dollar billing** | **Credit billing** | | --------------- | ------------------------------------------------- | ---------------------------------------------------- | | When you charge | At invoice time — usage adds up, you bill later | At usage time — credits come off a balance instantly | | The result | An **invoice**, collected by card or on NET terms | A **balance** that goes down and refills or tops up | | Feels like | A monthly bill | A prepaid wallet or an allowance | | Classic for | API metering, enterprise contracts | AI products, packs, plan allowances | You don't choose one for your whole business. You choose per plan, and you can use both at once. ## What you can build These aren't hypothetical models — they're how products you already know bill their customers. Each one maps cleanly onto UnitPay: A monthly credit allowance plus daily free credits, burned per message. Pure **credit billing** with recurring and drip grants. A prepaid balance burned down per token. **Both paths at once** — metered usage drawn from a prepaid balance, with dollar overage. Flat tiers — Free, Plus, Pro — with usage caps. **Dollar subscription** plus entitlement gating, no metering. Flat Pro and Max subscriptions. The simplest **dollar billing** case — a recurring price and access. Numbers and exact tiers for these products change over time. UnitPay docs model the *shape* of each pricing pattern, not their live prices — the shape is what's stable and what you'll reuse. ## Why use UnitPay? Billing starts as one checkout and balloons in complexity as you scale. The list below is the work you'd otherwise build and maintain yourself — across both billing paths: | Area | What you'd build | | --------------- | -------------------------------------------------------------------------------------------------- | | Subscriptions | Checkout, proration, upgrades, downgrades, add-ons, trials. | | Credit ledgers | Real-time deduction, recurring and one-time grants, rollovers, expiry, FIFO priority, concurrency. | | Invoicing | Usage aggregation, line items, NET terms, dunning, collection, multi-PSP. | | Entitlements | Feature gating per plan, boolean and metered features, seat allowances. | | Controls | Spend caps, auto top-ups, overage handling, usage alerts. | | Pricing changes | Versioning, grandfathering, migrations, backwards compatibility. | | Enterprise | Contracts, rate overrides, minimum spend, custom invoice schedules. | You can build all of this twice — once for dollars, once for credits — or model it once in UnitPay. ## How is this different? Usage-meters are built for post-hoc invoicing: you send events, they generate a dollar invoice at period end, and your app still owns access control and balances. Entitlement tools own balances and gating but don't run real billing and collection. UnitPay does both as one system of record. Query it inline for a customer's plan, entitlements, and balances; track usage once and have it land as an invoice line *or* a credit deduction. Because UnitPay owns the state, proration, failed payments, rollovers, and concurrency are handled for you. Pricing changes become configuration, not code. UnitPay connects to your payment processor (Stripe, Razorpay) for moving money — your customers and payment details stay in your own processor account. ## Next steps The one model behind both billing paths, and the objects you'll use. Model a plan, grant credits or set a price, and see it work end to end.