Boring Docs
Payments — Three Rails, One Ledger
Checkout, portal, cancel, webhooks, idempotency, price-from-row.
Payments
One variable: BILLING_PROVIDER=stripe|lemonsqueezy|dodo. Unified surface per rail: createCheckoutSession, createPortal, verifyAndParse (+ getSubscription, cancelSubscription).
Checkout
- Platform sale (template/subscription, guest OK):
POST /api/billing/create-checkoutwith{ successUrl, cancelUrl, priceId?/variantId?, mode? }. Price falls back to env/config per rail. - Marketplace sale (seller product, login required):
{ productDbId (preferred) | productId, successUrl, cancelUrl? }. Server loads the product row, uses itsprovider+providerRef, derivesmodefrompricingType. Amount always comes from the row — never the client. - Legacy Stripe shim
POST /api/stripe/create-checkoutallowlistspriceIdagainstconfig/billing.jsplans.
Validate successUrl/cancelUrl as same-origin relative URLs before sending to the PSP.
Portal + cancel
POST /api/billing/create-portal— resolves rail viabody.provider ?? user.billingProvider, opens PSP portal, backfillscustomerId. ValidatereturnUrlsame-origin.POST /api/billing/cancel-subscription— requires ownership: order buyer match orcustomerIdmatch or admin; then PSP cancel +revokeAccess. Returns 403 otherwise (anti-IDOR).
Webhooks (verify → parse → grant/revoke → 200/500 correctly)
- Dodo
POST /api/webhook/dodo, LS/api/webhook/lemonsqueezy, Stripe/api/webhook/stripe. All verify signatures (Standard Webhooks / HMAC / Stripe SDK), enforce replay tolerance, then callproviders/billing/access.js. grantAccess: platform sale → sethasAccess/customerId/priceId/billingProvider; marketplace → dedupe onproviderPaymentRef,findBuyer, create exactly oneOrderwithamountCentsfrom the product row.revokeAccess: mark order reason, then clearhasAccessonly when no other completed orders remain (lifetime users preserved).- Return 200 only on success; 4xx on bad signature, 500 on processing failure so PSPs retry. Never trust webhook amounts.
Ops
Sync helper: POST /api/product/sync-dodo (paid owner, sellerId match) creates the Dodo product and saves providerRef. Creation script: node scripts/dodo-create-product.js.