siteConfig.payment.provider,
fill in its env vars, and the rest of the codebase keeps using the same
paymentManager API. Webhooks from every provider get normalized to a single
NormalizedEvent shape, so business code (credit grants, license issuance,
affiliate commissions) is written once and runs the same regardless of who
took the money.
How PaymentManager works
The facade lives at src/payment/index.ts. It picks the active provider at
boot time based on siteConfig.payment.provider:
PaymentProvider contract from
src/payment/types.ts: createCheckout(opts) and createPortalLink(opts).
Consumers (server actions, settings pages) only ever import paymentManager
— they never know which provider is live.
Picking a provider
| Provider | Best for | Fees | MoR? | Notes |
|---|---|---|---|---|
| Stripe | NA / EU / global cards | 2.9% + 30¢ | No | You handle VAT/sales tax. Best DX. |
| Paddle | Global, regulatory-heavy | ~5% + 50¢ | Yes | Handles EU VAT + US sales tax for you. |
| Lemon Squeezy | Indie hackers wanting MoR | 5% + 50¢ | Yes | Friendliest onboarding. Now Stripe-owned. |
| Creem | Mainland China users | varies | Yes (cn) | Alipay + WeChat Pay. Requires Chinese business entity. |
| NOWPayments | Crypto-native buyers | 0.5% | No | BTC/ETH/USDC/300+ coins. No KYC. One-time only — no subscriptions. |
Webhook URLs
Each provider has its own webhook route underapp/api/webhooks/:
| Provider | URL |
|---|---|
| Stripe | /api/webhooks/stripe |
| Paddle | /api/webhooks/paddle |
| Lemon Squeezy | /api/webhooks/lemonsqueezy |
| Creem | /api/webhooks/creem |
| NOWPayments | /api/webhooks/nowpayments |
Normalized event shape
Every provider’sparseWebhook produces a NormalizedEvent from
src/payment/types.ts:
processNormalizedEvent in src/payment/handlers/core.ts
reads only the normalized fields. To add a new provider, you implement
createCheckout, createPortalLink, and a normalize*Event function — no
business logic is duplicated.
Idempotency
Stripe (and others) retry webhook deliveries on any non-2xx response, so the core handler is idempotent onpayment.invoiceId (unique index in
src/db/app.schema.ts) and falls back to payment.sessionId when no invoice
is involved:
payment row and grants credits.
Verify it works
- Set
siteConfig.payment.providerto your provider of choice. - Set the matching env vars (see the per-provider doc).
pnpm dev, open/pricing, click checkout — you should land on the provider’s hosted checkout page.- Complete a test payment.
- The provider’s webhook should hit your route; check Postgres:
- Replay the webhook — you should see the same
paymentrow, no duplicate.
Common pitfalls
- Switching providers mid-flight without clearing checkout sessions — in-flight checkouts will 404 against the new provider. Run a manual cancel pass before swapping.
- Forgetting to update
priceIdEnvper provider — the active provider’s promo + standard env vars must both resolve, even if one is empty. - Local dev without a tunnel — webhooks can’t reach
localhost. Use Stripe CLI / ngrok / cloudflared. - Mixing test and live mode keys — every provider has separate test/live keys AND test/live webhook secrets. Mismatches fail signature verification.
- Returning early from the route on parsing errors — providers retry, and you’ll grant credits twice once you fix the bug. Always insert idempotently.
Official docs
- Stripe: stripe.com/docs
- Paddle: developer.paddle.com
- Lemon Squeezy: docs.lemonsqueezy.com
- Creem: creem.io/docs
- NOWPayments: nowpayments.io · API ref