npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@unitpay/react

v1.32.0

Published

UnitPay React SDK — hooks, feature gates, and Next.js adapter for billing UIs

Readme

@unitpay/react

React TypeScript License

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-query
yarn add @unitpay/react react @tanstack/react-query
pnpm add @unitpay/react react @tanstack/react-query
bun add @unitpay/react react @tanstack/react-query

Quick 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. Pass requestedUsage (or { fresh: true }) when you need a live server check.
  • Mutations (useTopUp, useChangePlan, …) return a SettleOutcome describing 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.

Notecheck() reads local cache. For security-critical gating, also verify on your server with the UnitPay check endpoint.

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 Origin403; 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