payments · tutorial
Three payment rails, one checkout function
Stripe, LemonSqueezy and Dodo behind a single interface — switch without touching your UI.

Boring Team
September 9, 2026 · 1 min read
Every SaaS eventually needs a second payment provider. Stripe doesn't do merchant-of-record taxes everywhere. LemonSqueezy takes a bigger cut. Dodo handles UPI and global tax but is newer. So you integrate all three — and your checkout code turns into spaghetti.
Boring gives every rail the same shape:
// providers/billing/<rail>.js
createCheckoutSession({ priceId, mode, successUrl, cancelUrl, userId, email, name, user })
createPortal({ customerId, returnUrl })
verifyAndParse({ rawBody, headers }) // → { action: "grant" | "revoke" | "ignore", ... }
One route to charge them all
POST /api/billing/create-checkout
{ "provider": "dodo", "successUrl": "...", "cancelUrl": "..." }
Omit provider and it uses BILLING_PROVIDER from env. Stripe keeps its SDK (it's stable — don't fix it). LemonSqueezy and Dodo are plain fetch, no SDK, with HMAC webhook verification built in.
Webhooks that grant access
Every webhook funnels into two functions:
grantAccess({ userId, email, customerId, priceId, provider })
revokeAccess({ userId, customerId })
A verified payment.succeeded sets hasAccess, customerId and billingProvider on the user. A cancellation clears it. Your paywall — if (!user.hasAccess) — never learns which rail was used.
Which rail when?
- Stripe — default. Best dashboard, most docs, you handle taxes.
- LemonSqueezy — merchant-of-record. They handle VAT/GST, you pay a higher cut.
- Dodo — merchant-of-record with strong global coverage (UPI and friends). Great if your customers aren't all in the US/EU.
Pick one per environment, even. Test on Dodo, run prod on Stripe. The UI can't tell.