Skip to content

Checkout

The customer-facing half. @0billing/react ships a drop-in component; if it doesn't fit your design, the same flow is four public HTTP calls and you can build your own. Either way it runs on your origin — the API answers cross-origin requests out of the box (see CORS).

bash
pnpm add @0billing/react
tsx
import { Checkout } from "@0billing/react";

export function Upgrade({ user }) {
  return (
    <Checkout
      baseUrl="https://billing.example.com"
      customerId={user.id}
      priceId="price_monthly"
      metadata={{ orderId: order.id }}
      onConfirmed={() => router.push("/welcome")}
    />
  );
}

Props

PropTypeNotes
baseUrlstringWhere the 0billing API is mounted. Required.
publishableKeystring?Your merchant's pk_… from the dashboard — safe in frontend code. Required on multi-tenant deployments; optional when the deployment has one merchant.
customerIdstringYour id for the payer. Comes back on the webhook — how you know who paid. Required.
priceIdstring?What they're buying. Omit and the customer picks from your active catalog.
quantitynumber?Seats/units. Multiplies both the amount and the grant.
walletWalletOptions?Wallet-pay wiring. Pass { walletConnectProjectId: "…" } (a public Reown id) and payers with no extension get a QR / mobile deep-link flow; pass your own providers to reuse an existing wallet stack; { disabled: true } leaves copy-paste only.
promoCodestring?Pre-fill the discount field, e.g. from a campaign link.
allowPromoCodeboolean?false hides the field entirely.
collectBillingDetailsboolean?Ask for name/company/address to print on the receipt. Off by default.
metadataRecord<string,string>?Echoed back on the payment and webhook.
themePartial<CheckoutTheme>?Colours, radius, fonts.
walletWalletOptions?Wallet-pay behaviour — see Paying from a wallet. Omit for injected wallets.
onConfirmed(payment) => voidFires once, when the payment settles.
onError(error) => voidNetwork or validation failures. Errors are also shown in the widget.

onConfirmed is for UX — a redirect, a confetti burst. Grant access from the webhook, which is server-to-server and can't be closed, blocked or forged.

What the customer sees

  1. Picks a network and a token — ~35 chains, ~175 assets, searchable, with logos.
  2. Provides a refund address on that chain — where funds go back if the swap fails. When a reachable wallet can pay, there's nothing to type: the widget connects the wallet at pay time and uses its own address. The field only appears on chains without wallet support (or if connection is declined), and it's cleared whenever the network changes, since an address is only valid on its own chain.
  3. Sees the total, with any discount applied.
  4. Pays. On chains with injected-wallet support the default is a Pay from wallet button — one click, the wallet opens with the exact amount and the one-time deposit address already filled in. "Pay manually instead" is one click away and shows the address and amount to copy; it's the only route from an exchange account or hardware wallet, and it stays first-class. Because the address is unique to this payment, both paths converge on the same settlement — nothing depends on matching an amount to a payer.
  5. Watches it go pending → detected → confirmed while the widget polls every 4 seconds.

Paying from a wallet

Zero configuration: with no wallet prop, the widget uses the injected providers — window.ethereum (MetaMask, Rabby, …) on 12 EVM chains, and Phantom/Solflare on Solana. No wagmi, no WalletConnect, no wallet-adapter. EVM support is dependency-free (raw EIP-1193 with hand-encoded transfer calldata); Solana uses @solana/web3.js, which is dynamically imported in its own chunk — merchants whose customers never pay from a Solana wallet never ship it.

ts
interface WalletOptions {
  evmProvider?: () => Eip1193Provider | undefined;    // instead of window.ethereum
  solanaProvider?: () => SolanaWalletProvider | undefined;
  solanaRpcUrl?: string;    // default: publicnode's keyless endpoint (rate-limited)
  disabled?: boolean;       // copy-paste only
}

Three things worth setting deliberately:

  • Your app already connects wallets? Pass its provider through evmProvider / solanaProvider (e.g. wagmi's connector.getProvider()) so the widget never opens a competing connection.
  • Solana at real volume? Supply solanaRpcUrl. The default (publicnode's keyless endpoint) works from browsers but is rate-limited, and a keyed URL belongs in your app — never inside a published npm package. Don't point it at api.mainnet-beta.solana.com: the official endpoint 403s requests that carry a browser Origin header.
  • Chains without wallet support (Bitcoin, Tron, XRP, …) and payments made from exchanges fall back to the manual view automatically — the wallet button only renders when a reachable wallet can actually pay this payment.

The building blocks are exported for custom UIs: walletSupported(chain, options) (should you even ask for a refund address?), connectAddress(chain, options) (the wallet's own address — the refund default), walletPayable(payment, options) and payWithWallet(payment, options) (resolves to the tx signature; detection still comes from the server watching the deposit address).

Theming

No CSS framework is assumed — a widget landing in someone else's app can't require Tailwind, and shouldn't leak class names into it either. Everything is inline-styled from a theme object, and the only global CSS is one keyframe for the "waiting" dot (which respects prefers-reduced-motion).

tsx
import { Checkout, lightTheme, defaultTheme } from "@0billing/react";

<Checkout theme={lightTheme}  />
<Checkout theme={{ ...defaultTheme, accent: "#ff5c00", radius: 16 }}  />
ts
interface CheckoutTheme {
  background: string;   raised: string;      border: string;
  text: string;         muted: string;
  accent: string;       accentText: string;  // the pay button
  success: string;      danger: string;
  radius: number;       fontFamily: string;  monoFamily: string;
}

Chain and token logos come from public CDNs (DefiLlama, CoinGecko) and fall back to a letter avatar, so a blocked CDN degrades quietly rather than breaking layout.

Building your own UI

Four public endpoints, no key involved — see the API reference for full shapes:

ts
import { createBillingClient } from "@0billing/react";

const billing = createBillingClient("https://billing.example.com");

const assets = await billing.assets();                       // what they can pay with
const quote  = await billing.quote("price_monthly", 1, "LAUNCH20");
const payment = await billing.checkout({
  customerId: user.id,
  priceId: "price_monthly",
  originAssetId: assets[0].assetId,
  refundTo: "<their address on that chain>",
});
// show payment.origin.amount + payment.origin.depositAddress, then poll:
const latest = await billing.payment(payment.id);

The client is dependency-free and framework-agnostic despite living in the React package — it's just fetch.

Two rules if you roll your own:

  • Poll GET /v1/payments/:id rather than watching the chain yourself. That call also drives settlement forward, so an open checkout tab keeps its own payment moving even between server polls.
  • Never send an amountUsd from the browser when a catalog price exists. It is honoured only when no priceId is given, for one-off charges your server computes.

Statuses

StatusMeaningTerminal
pendingWaiting for the deposit
detectedDeposit seen, swap in flight
confirmedSettled into your treasury; payment.confirmed fired
expiredQuote lapsed untouched — see expiresAt
failedThe swap failed; funds went to refundTo
refundedReturned to the payer

Terminal states never change again, and each fires exactly one webhook. A quote that has already been paid never expires — only untouched ones do — so a slow deposit won't be timed out from under the customer.

0billing — crypto billing for your app · pay from any chain, settle in one