Boring Docs

Payments — Three Rails, One Ledger

Checkout, portal, cancel, webhooks, idempotency, price-from-row.

Payments

Listen — payments is the one place you don't get to "move fast." A bug anywhere else costs you an afternoon. A bug here charges the wrong card, grants access that was never paid for, or silently drops a paying customer into the free tier. So we're going to do this slowly, once, in the right order. Pour something warm. Let's go.

First, the one decision that matters: pick ONE rail

Open your env and look at BILLING_PROVIDER. It takes exactly one value — dodo, stripe, or lemonsqueezy — and the entire checkout surface follows it: checkout sessions, customer portal, cancel flows, webhooks. Same function names on every rail (createCheckoutSession, createPortal, verifyAndParse), so your buttons and routes never change when you switch.

My advice, friend to friend: start with the provider you already have an account with. Dodo is the default and the Merchant of Record path (they handle VAT/GST headaches for you). Stripe if you want the battle-tested docs and ecosystem. LemonSqueezy if you like their storefront handling. There is no wrong answer here — that's the whole point of the switch. Pick one, and don't look back until real customers force you to.

Set it, then run the provider's own health check before you touch any button:

# Dodo: creates a test product to prove your key works
node scripts/billing/dodo-create-product.js

If that script throws Missing DODO_PAYMENTS_API_KEY, good — that's the system telling you the truth early (see 03-config-env). Fix the key now, not at 11pm with a customer waiting.

Test mode will betray you (so we test twice)

Here's the trap I warned you about: everything passes in test mode, you launch, and the first real card fails. Test and live are different keys, different product IDs, different webhook secrets — sometimes different API hosts entirely (DODO_MODE=test vs live).

So here's the ritual. Do the full click path in test mode first: landing → buy button → checkout → pay with the provider's test card → land back → check you have access. Watch it work. Smile. Then switch to live keys, buy your own product for real (a $1 product you refund right after), and watch it work again. Only the second run counts. If live fails where test passed, I promise you: it's keys or mode, never your code. Check DODO_MODE, check for a stray test key in prod env, compare webhook secrets byte-by-byte (trailing newlines from copy-paste are the usual villain).

The three money rules (tattoo these somewhere)

One: the price lives in YOUR product row. Look at grantAccess in providers/billing/access.js — the order records product.priceCents, never anything the browser sent, never anything the webhook claimed. If someone edits the checkout HTML to say $1, your ledger still says the real price. Any tutorial that trusts client-side amounts is teaching you to get robbed. We don't do that here.

Two: every payment gets exactly one order row. Providers retry webhooks — same event delivered twice, three times, during their outages. access.js looks up Order.findByPaymentRef(paymentRef) first, and if the row exists it returns { duplicate: true } and stops. No double-grants, no double-counted revenue. When you write your own webhook handler for anything else, steal this pattern first and ask questions later.

Three: webhooks verify signatures or die 401. Each rail's route (app/api/webhook/dodo/route.js and siblings) reads the raw body — await req.text(), never parsed JSON, because signature verification needs the exact bytes — and calls verifyAndParse. Bad signature? 401, plus a scream to your alerts. If your prod webhooks start 401ing after months of green, the dashboard secret got rotated and env went stale. Compare both sides, byte for byte.

What happens when money moves (follow one payment)

Let's trace a Dodo purchase together so the machinery stops being magic:

  1. Your buy button calls the checkout route, which calls createCheckoutSession({ email, userId, ... }). The product ID comes from env (or override), the amount from your product row.
  2. Customer pays on the provider's hosted page. You never touch card numbers — that's the entire point of hosted checkout.
  3. Provider POSTs to /api/webhook/dodo. Route reads raw body, verifies signature, parses the event into { action: "grant" | "revoke", ... }.
  4. grantAccess finds or creates the buyer (by userId, email, or customerId — in that order), flips hasAccess, stamps billingProvider, customerId, priceId, writes the order row, records revenue for analytics, and even fires the GitHub invite if they gave a username.
  5. Cancel or failed renewal? Same pipe, action: "revoke" → revokeAccess flips hasAccess off. Failed payments un-person themselves automatically. Beautiful.

Read that route file once — it's under 40 lines. That's the whole money pipeline. If you can read it top to bottom without scrolling sideways, you understand your billing. That's deliberate.

Portal and cancel: don't build what they give you free

Every rail has createPortal (billing dashboard hosted by the provider: invoices, card updates, cancel) and cancelSubscription. Wire your "Manage billing" button to the portal and walk away. I have watched founders burn two weekends building invoice tables the provider already renders better. Your job is access control, not accounting software. The portal handles the paperwork; your webhook handles the truth.

When it breaks (and it will, once)

Keep this checklist taped to your monitor:

  • Checkout button does nothing → provider not configured: isConfigured() false means key or product ID missing. The error names it.
  • Paid but no access → webhook never arrived or 401'd. Check provider dashboard delivery logs first, your logs second. Local dev? Webhooks can't reach localhost — use the provider's test-event forwarder or test against a tunnel.
  • Double orders → your dedupe lookup drifted (custom handler without the paymentRef check). Go add it now; I'll wait.
  • Live fails, test passed → keys or mode. Always keys or mode.

You made it. Payments done right is boring — and boring is the goal. Next: 07-paywall, where we turn hasAccess into locked pages.