@unitpay/react
v1.32.0
Published
UnitPay React SDK — hooks, feature gates, and Next.js adapter for billing UIs
Readme
@unitpay/react
React hooks, components, and a Next.js adapter for building customer-facing billing UIs on UnitPay — the billing engine where dollars and credits are both first-class. One provider gives your app type-safe access to a customer's subscriptions, invoices, payment methods, credit wallets, entitlements, and checkout — all backed by TanStack Query.
- 💳 Dollars and credits — subscriptions, metered usage, prepaid wallets, and credit grants, side by side.
- 🪝 40+ typed hooks — one hook per job (
useCustomer,useMeteredEntitlement,useTopUp, …), fully typed end to end. - 🚪 Entitlement gates — declarative
<FeatureGate>/<MeteredGate>/<CreditGate>components for access control. - ⚡ One fetch, many gates — entitlements load once per customer and serve every gate from cache.
- 🔐 Server-minted sessions — auth is a short-lived portal token; the SDK refreshes it for you, no secret keys in the browser.
- ▲ Next.js adapter — optional server proxy so requests never leave your domain.
Requirements
- React
>=18 - @tanstack/react-query
^5 - A UnitPay backend and a portal session token minted server-side with
@unitpay/node(unitpay.portalSessions.create({ customerId })).
react and @tanstack/react-query are peer dependencies — install one copy in your app so the SDK shares your QueryClient.
Installation
npm install @unitpay/react react @tanstack/react-queryyarn add @unitpay/react react @tanstack/react-querypnpm add @unitpay/react react @tanstack/react-querybun add @unitpay/react react @tanstack/react-queryQuick Start
1. Mint a portal token on your server
The browser never sees your secret key. Mint a short-lived portal session for the signed-in customer with the server SDK and pass it to your frontend (SSR prop, route handler, etc.):
// app/api/portal-token/route.ts (server-side)
import { UnitPay } from '@unitpay/node';
const unitpay = new UnitPay({ apiKey: process.env.UNITPAY_SECRET_KEY! });
export async function GET() {
const customerId = await getCustomerIdFromSession();
const { token } = await unitpay.portalSessions.create({ customerId });
return Response.json({ token, customerId });
}2. Wrap your app with UnitPayProvider
import { UnitPayProvider } from '@unitpay/react';
export function Providers({
children,
customerId,
portalToken,
}: {
children: React.ReactNode;
customerId: string;
portalToken: string;
}) {
return (
<UnitPayProvider
config={{
apiBaseUrl: 'https://api.useunitpay.com/v1',
customerId,
portalToken,
onSessionExpired: () => {
// refresh failed terminally — send the user back to sign-in
window.location.href = '/login';
},
}}
>
{children}
</UnitPayProvider>
);
}The SDK refreshes the token automatically — proactively (~60s before it expires) and reactively (on a 401). You do nothing after the initial mint; onSessionExpired fires only when refresh fails for good.
3. Use a hook
import { useCustomer, useMeteredEntitlement } from '@unitpay/react';
export function Dashboard() {
const { customer, isLoading } = useCustomer();
const apiCalls = useMeteredEntitlement('api-calls');
if (isLoading) return <Spinner />;
return (
<section>
<h1>Welcome, {customer?.name}</h1>
<p>
API calls: {apiCalls.usage} / {apiCalls.limit}
{' · '}
{apiCalls.remaining} remaining
</p>
</section>
);
}Note — every hook must be called inside
UnitPayProvider. Hooks read from a shared TanStack Query cache, so the same customer data is fetched once and reused across your whole tree.
How it works
- Auth is a portal-session JWT (
portalToken). The SDK attaches it as a bearer token and transparently refreshes it. No publishable or secret keys live in the browser. - Caching is TanStack Query.
useCustomer()loads the customer + their entitlements once;useEntitlement/<FeatureGate>then resolve from that cache — one HTTP call serves N gates. PassrequestedUsage(or{ fresh: true }) when you need a live server check. - Mutations (
useTopUp,useChangePlan, …) return aSettleOutcomedescribing how money moved (charged inline, hosted-form required, invoice sent, deferred…) and auto-invalidate the affected queries. See Settlement model. - Dollars and credits are both modeled: subscriptions/invoices on the dollar side, wallets/grants/ledger on the credit side. Credit affordances render on every relevant surface regardless of whether the current plan uses credits.
Provider & configuration
<UnitPayProvider config={config} queryClient={optionalClient}>UnitPayProvider accepts a config: UnitPayConfig and an optional queryClient (it creates one if you don't pass yours).
| Field | Type | Description |
| --- | --- | --- |
| customerId | string | The customer this provider acts for. Required for customer-scoped hooks. |
| portalToken | string | Portal-session JWT minted server-side by your backend. Refreshed automatically. |
| apiBaseUrl | string | UnitPay API base URL. Defaults to https://api.useunitpay.com/v1. |
| proxyBaseUrl | string | Route calls through a same-origin proxy (see Next.js adapter). Takes precedence over apiBaseUrl. |
| productId | string | Pin a product for multi-product orgs. Hooks like usePricing() prefer this; single-product orgs can omit it. |
| fetch | typeof fetch | Custom fetch implementation (SSR, instrumentation). |
| onSessionExpired | () => void | Fires once when token refresh fails terminally (revoked, expired beyond max TTL, customer archived). Flip your UI to a "session expired" screen. |
Hooks reference
All hooks return { isLoading, error, ... } and are grouped by domain below. Query hooks read live data; mutation hooks return a SettleOutcome and invalidate affected caches on success.
Customer & entitlements
| Hook | Returns |
| --- | --- |
| useCustomer() | { customer, entitlements, check(slug, opts?), update(input), setupPayment(), payInvoice(id), isLoading, … } |
| useEntitlement(slug, opts?) | { entitlement, isFallback, isLoading, error } — generic, type-narrowed by entitlement.type |
| useEntitlements(slugs?, opts?) | { entitlements, list, isLoading, error } — bulk map + sorted list |
| useBooleanEntitlement(slug, opts?) | { access, isFallback, … } |
| useMeteredEntitlement(slug, opts?) | { access, usage, limit, remaining, isUnlimited, percentage, nextResetAt, … } |
| useCreditEntitlement(slug, opts?) | { access, creditBalance, … } |
| useConfigEntitlement(slug, opts?) | { access, value, … } |
| useEnumEntitlement(slug, opts?) | { access, enumValues, … } |
import { useMeteredEntitlement } from '@unitpay/react';
function UsageMeter() {
// `remaining` = granted − usage (the quota left this period).
const { usage, limit, remaining, percentage, nextResetAt } =
useMeteredEntitlement('api-calls');
return (
<div>
<progress value={percentage} max={100} />
<span>{remaining} of {limit} left · resets {nextResetAt}</span>
</div>
);
}useCustomer().check(slug) is a synchronous, cache-based check returning { allowed, deniedReason } — ideal for cheap UI branching. For a capacity probe ("would N more be allowed right now?"), pass requestedUsage to useMeteredEntitlement / useEntitlement, which forces a live server check.
Note —
check()reads local cache. For security-critical gating, also verify on your server with the UnitPaycheckendpoint.
Subscriptions
| Hook | Returns / action |
| --- | --- |
| useSubscriptions() | { subscriptions, … } — all subscriptions for the customer |
| useSubscription(id?) | { subscription, previewChange(newPlanId), … } |
| useActiveSubscription(opts?) | { subscription, subscriptions, … } — live statuses only |
| useCreateSubscription(opts?) | createSubscription({ planId, currency?, metadata? }) → SettleOutcome |
| useChangePlan(id, opts?) | changePlan({ newPlanId }) → SettleOutcome |
| useAttachAddon(id, opts?) | attachAddon({ addonPlanId }) → SettleOutcome |
| useCancelSubscription(id, opts?) | cancel({ cancelImmediately?, cancellationReason? }) → SubscriptionMutationResult |
| useUncancelSubscription(id, opts?) | uncancel() → SubscriptionMutationResult |
import { useChangePlan } from '@unitpay/react';
function UpgradeButton({ subscriptionId }: { subscriptionId: string }) {
const { changePlan, isPending } = useChangePlan(subscriptionId, {
onChargedInline: () => toast.success('Upgraded!'),
onRequiresForm: ({ client }) => openHostedCheckout(client),
});
return (
<button disabled={isPending} onClick={() => changePlan({ newPlanId: 'pro' })}>
Upgrade to Pro
</button>
);
}Invoices
| Hook | Returns / action |
| --- | --- |
| useInvoices() | { invoices, downloadPdf(id), … } |
| useInvoice(id) | { invoice, downloadPdf(), … } — full detail (items + payments) |
| useUpcomingInvoice(subscriptionId?) | { invoice, … } — next-cycle preview |
| usePayInvoice(id, opts?) | pay() → SettleOutcome |
Payment methods
| Hook | Returns / action |
| --- | --- |
| usePaymentMethods() | { paymentMethods, setDefault(id), detach(id), isActing } — detach throws PmInUseError on conflict |
| usePaymentMethodDependencies(id, opts?) | What blocks detaching a card (active subs, auto-topup) |
| useSetupPaymentMethod(opts?) | setupPaymentMethod() → SettleOutcome (always a hosted form / SetupIntent) |
| useReplacePaymentMethod() | replacePaymentMethod({ oldPaymentMethodId, newPaymentMethodId }) — atomic swap |
Credits & wallets
| Hook | Returns / action |
| --- | --- |
| useCreditAccounts(customerId?) | { accounts } — one wallet per credit currency, with embedded grants |
| useCreditCurrencies() | { currencies } — active credit currencies |
| useCreditLedger(filters?) | { entries, fetchNextPage, hasNextPage, … } — infinite ledger feed |
| useTopUp(opts?) | topUp({ creditCurrencyId, amountCents }) → SettleOutcome |
| useBuyCreditPackage(opts?) | buyCreditPackage({ creditPackageId }) → SettleOutcome |
| useAutoTopUp(creditCurrencyId) | { rule, runtime, set(input), disable(), … } |
| useTopupHistory(filters?) | { attempts, nextCursor, … } |
import { useTopUp } from '@unitpay/react';
function TopUpButton({ creditCurrencyId }: { creditCurrencyId: string }) {
const { topUp, isPending } = useTopUp({
onChargedInline: () => toast.success('Credits added'),
onInvoiceSent: ({ invoice }) => toast(`Invoice ${invoice.invoiceNumber} sent`),
});
return (
<button
disabled={isPending}
onClick={() => topUp({ creditCurrencyId, amountCents: 5000 })}
>
Add $50 of credits
</button>
);
}Usage & analytics
| Hook | Returns |
| --- | --- |
| useUsageMeter(metricId) | { meter } — point-in-time usage for one metric |
| useUsageHistory(days?) | { series, hasData, … } — server-aggregated daily usage per event |
| useCreditBurn(creditCurrencyId, days?) | { series, avgPerDay, windowSum, peak, hasBurn, … } — daily consumption + burn-rate |
Catalog & pricing
| Hook | Returns |
| --- | --- |
| usePlans() | { plans } |
| useProducts() | { products } |
| usePricing(productId?) | { pricing } — full matrix: tiers, plans, entitlements, credit packages, addons |
| useBranding() | { branding } — merchant name, logo, website |
Checkout
| Hook | Returns |
| --- | --- |
| useCheckoutPreview(input) | { preview, refresh() } — totals/proration/credit impact before committing |
| useCheckoutSession(id, opts?) | { status, data } — polls a hosted-form session to completed / expired |
Imperative client
useBilling() returns the underlying UnitPayClient for ad-hoc calls not covered by a hook. Prefer the dedicated hooks where they exist.
Entitlement gate components
Wrap UI with a declarative access check. The typed gates (BooleanGate, etc.) take a slug prop; the generic FeatureGate takes featureSlug.
import {
FeatureGate,
BooleanGate,
MeteredGate,
CreditGate,
} from '@unitpay/react';
// Generic — works for any feature type
<FeatureGate featureSlug="export-csv" fallback={<Upgrade />} loading={<Spinner />}>
<ExportButton />
</FeatureGate>
// Boolean flag
<BooleanGate slug="sso" noAccessComponent={<Upgrade />}>
<SSOSettings />
</BooleanGate>
// Metered — only render if there's quota for `requestedUsage` more units
<MeteredGate slug="api-calls" requestedUsage={1} noAccessComponent={<OutOfQuota />}>
<RunQueryButton />
</MeteredGate>
// Credit — require a minimum wallet balance
<CreditGate slug="ai-credits" requiredBalance={10} noAccessComponent={<TopUp />}>
<GenerateButton />
</CreditGate>| Component | Key props |
| --- | --- |
| FeatureGate | featureSlug, fallback?, loading?, onDenied?, requestedUsage?, fallbackEntitlement? |
| BooleanGate | slug, noAccessComponent?, loadingComponent?, fallback? |
| MeteredGate | slug, requestedUsage?, noAccessComponent?, loadingComponent?, fallback? |
| CreditGate | slug, requiredBalance? (default 1), noAccessComponent?, loadingComponent?, fallback? |
| ConfigGate | slug, children: (value) => ReactNode, noAccessComponent?, loadingComponent?, fallback? |
| EnumGate | slug, requestedValues?, noAccessComponent?, loadingComponent?, fallback? |
Settlement model
Money-moving mutations resolve to a SettleOutcome — a discriminated union on kind. Pass the matching on* handler in the hook's options (or call client.handleSettlement(result, handlers)):
| 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 |
const { createSubscription } = useCreateSubscription({
onChargedInline: ({ subscription }) => router.push(`/welcome/${subscription?.id}`),
onRequiresForm: ({ client, reason }) => openHostedCheckout(client, reason),
onInvoiceSent: ({ invoice }) => toast(`Invoice ${invoice.invoiceNumber} on its way`),
});Error handling
Every request throws a typed error you can discriminate:
import { HttpError, NetworkError, TimeoutError, UnitPayError } from '@unitpay/react';
try {
await client.get('/customers/cus_123');
} catch (e) {
if (e instanceof HttpError && e.isAuthError) {
// 401 / 403
} 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
}
}HttpError carries status, code, requestId, and details, plus isAuthError / isRateLimited / isRetryable getters. The client retries transient failures (408/429/5xx + network) with jittered exponential backoff. usePaymentMethods().detach() throws a specialized PmInUseError (with activeSubscriptionIds / autoTopupAccountIds) when a card is still in use.
Utilities
Helpers exported for rendering money, credits, and invoices consistently:
| Export | Purpose |
| --- | --- |
| formatCurrency(cents, currency?) | Fiat money → "$50.00" |
| formatBalance(amount, currency) | Denomination-aware credit/fiat balance |
| formatDate(iso) | "Jun 27, 2026" |
| amountOwed(invoice) | Balance due on an invoice |
| overdueDaysFor(dueDate) | Whole days past due |
| selectUnpaidSorted(invoices) | Unpaid invoices with a balance, oldest first |
| UNPAID_INVOICE_STATUSES | The set of statuses that mean "still owed" |
| computeRelativeDirection(target, current) | 'upgrade' \| 'downgrade' \| 'lateral' \| 'current' for plan-change UX |
| invalidateCustomerData(queryClient, customerId) | Invalidate every customer-scoped query after an out-of-band change |
| queryKeys | The SDK's TanStack Query key factory |
Next.js adapter
Route SDK calls through your own backend so the secret key never reaches the browser. The adapter validates Origin, caps body size, and whitelists methods.
// 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:
<UnitPayProvider config={{ customerId: 'cus_...', proxyBaseUrl: '/api/unitpay' }}>
<App />
</UnitPayProvider>Security defaults: disallowed methods → 405; off-allowlist Origin → 403; bodies over maxBodyBytes (default 1 MB) → 413.
TypeScript
The package ships its own types — no @types/* needed. Response shapes (Customer, Subscription, Invoice, CreditAccount, EntitlementValue, SettleOutcome, …) mirror the server contracts exactly. Entitlement values are a discriminated union on type ('boolean' | 'metered' | 'credit' | 'config' | 'enum'), so narrowing one tells the compiler which fields are present (e.g. remaining on 'metered', creditBalance on 'credit').
License
MIT
