Skip to content

Architecture

How the pieces fit, and why the seams are where they are.

 browser                     your server                    third parties
┌──────────────────┐        ┌───────────────────────┐      ┌─────────────────┐
│ @0billing/react  │──────▶ │ billingRoutes         │      │                 │
│  <Checkout/>     │  /v1   │   ├─ public           │      │   swap router   │
│  (public only)   │◀────── │   └─ admin (key/SSO)  │─────▶│  (cross-chain)  │
└──────────────────┘        │ BillingService        │      │                 │
                            │   quote → watch →     │      └─────────────────┘
┌──────────────────┐        │   settle → emit       │               │
│ dashboard (SSO)  │──────▶ │ BillingStore          │               ▼ swap
│  catalog, codes  │        └──────────┬────────────┘      ┌─────────────────┐
└──────────────────┘                   │                   │ your treasury   │
                                   Postgres                └─────────────────┘

                        signed webhook ▼
                              ┌─────────────────┐
                              │ your app grants │
                              └─────────────────┘

Packages

@0billing/core — the domain, with no I/O. Payment and catalog types, the pricing math (quotePrice, validateCode), and the SwapRouter interface with a default cross-chain implementation. Importable in a browser, a test, or a marketing site.

@0billing/serverBillingService (the lifecycle and its poller), the BillingStore interface with Postgres and memory implementations, webhook signing and dispatch, and billingRoutes as a Fastify plugin. The HTTP layer is thin: every route is a few lines onto the service, so a host app can skip HTTP and call the service directly.

@0billing/react — the customer-facing widget plus a dependency-free fetch client for the public API.

The payment lifecycle

  1. Quote. POST /v1/checkout resolves a priceId server-side, applies any discount, and asks the router for an EXACT_OUTPUT swap: this many USDC must land, whatever it takes. Back comes a one-time deposit address and the exact input amount.
  2. Wait. Status is pending. Two things advance it: the in-process poller (pollIntervalMs, default 10s) and any GET /v1/payments/:id — so an open checkout tab drives its own payment.
  3. Detected. The deposit landed; solvers are executing the swap.
  4. Confirmed. Funds are in the treasury. The transition stamps confirmedAt and the txId, redeems the promo code, and emits payment.confirmed.
  5. Or not. expired (nothing arrived before the quote lapsed), failed (the swap failed; funds went to refundTo), refunded.

All four end states are terminal — the payment is frozen and its single webhook has fired.

Why these seams

The router is an interface. A cross-chain swap router is what lets a customer pay in any asset from any chain — but a payment product can't have its liveness pinned to one vendor's API. SwapRouter is three methods (tokens, quote, status); a second implementation is a day's work, and everything above it is unaffected — so routers can be swapped, or run in parallel to take the better quote.

The store is an interface. Postgres for real deployments, memory for tests. More importantly it's where the concurrency guarantees live, in SQL rather than in application locks:

sql
UPDATE payments SETWHERE id = $1 AND status IN ('pending','detected')

That predicate is what makes terminal states final. Two pollers can race on the same payment and only one update lands, so exactly one webhook fires. There's no leader election because there's nothing to elect.

Deposit addresses carry a unique index. A collision would attribute one person's money to another's payment, which is the worst bug this system could have, so the database refuses it outright.

Prices are data, not arguments. Checkout takes a priceId. A raw amountUsd is honoured only when no price is given, for one-off charges a server computes. The catalog isn't a convenience feature — it's what makes the amount owed unforgeable.

The widget is self-styled. Inline styles from a theme object, one keyframe of global CSS. A component that lands in someone else's app can't require their CSS framework, and shouldn't leak class names into it.

The demo is a merchant, not a mode. apps/demo uses the packages exactly as a third party would, including writing its own webhook receiver and entitlement map. If something can't be done from there, it can't be done by a customer either.

What it doesn't do

  • Direct same-chain payments. Paying USDC-on-Solana into a USDC-on-Solana treasury still routes through a swap. A short-circuit is obvious and not yet built.
  • Recurring auto-renewal. Prices carry intervals and payments carry grants, but nothing charges a card on file — there is no card, and no key to spend from. Renewal today means asking the customer to pay again.
  • Fiat. Crypto in, crypto out. No off-ramp, no payouts, nothing custodial.

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