Appearance
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/reacttsx
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
| Prop | Type | Notes |
|---|---|---|
baseUrl | string | Where the 0billing API is mounted. Required. |
publishableKey | string? | Your merchant's pk_… from the dashboard — safe in frontend code. Required on multi-tenant deployments; optional when the deployment has one merchant. |
customerId | string | Your id for the payer. Comes back on the webhook — how you know who paid. Required. |
priceId | string? | What they're buying. Omit and the customer picks from your active catalog. |
quantity | number? | Seats/units. Multiplies both the amount and the grant. |
wallet | WalletOptions? | 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. |
promoCode | string? | Pre-fill the discount field, e.g. from a campaign link. |
allowPromoCode | boolean? | false hides the field entirely. |
collectBillingDetails | boolean? | Ask for name/company/address to print on the receipt. Off by default. |
metadata | Record<string,string>? | Echoed back on the payment and webhook. |
theme | Partial<CheckoutTheme>? | Colours, radius, fonts. |
wallet | WalletOptions? | Wallet-pay behaviour — see Paying from a wallet. Omit for injected wallets. |
onConfirmed | (payment) => void | Fires once, when the payment settles. |
onError | (error) => void | Network 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
- Picks a network and a token — ~35 chains, ~175 assets, searchable, with logos.
- 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.
- Sees the total, with any discount applied.
- 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.
- Watches it go
pending → detected → confirmedwhile 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'sconnector.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 atapi.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/:idrather 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
amountUsdfrom the browser when a catalog price exists. It is honoured only when nopriceIdis given, for one-off charges your server computes.
Statuses
| Status | Meaning | Terminal |
|---|---|---|
pending | Waiting for the deposit | |
detected | Deposit seen, swap in flight | |
confirmed | Settled into your treasury; payment.confirmed fired | ✓ |
expired | Quote lapsed untouched — see expiresAt | ✓ |
failed | The swap failed; funds went to refundTo | ✓ |
refunded | Returned 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.