payments · tutorial

Three payment rails, one checkout function

Stripe, LemonSqueezy and Dodo behind a single interface — switch without touching your UI.

Boring Team

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.

paymentstutorial