productId, and let the starter handle checkout, webhooks,
orders, credits, licenses, and the billing portal.
The quick path
For most products you only touch three places:- Create Products + Prices in Stripe.
- Paste the price IDs into
.env.local. - Adjust plans and packs in
src/config/site.ts.
/checkout
and sent to the hosted checkout page automatically.
Config layers
siteConfig.billing is the beginner-facing surface:
siteConfig.demoPlans defines the four demo SaaS tiers your app sells. siteConfig.payment is the advanced provider layer: Stripe is enabled
by default; Creem and NOWPayments can be added when you need alternate rails.
Provider facade
Provider code still lives behind one contract:NormalizedEvent shape. The shared handler in
src/payment/handlers/core.ts inserts the payment row, grants credits, issues
licenses, and records affiliate commission.
Idempotency
Provider retries are expected. The payment table has unique indexes on bothinvoiceId and sessionId; the handler inserts with conflict protection and
then grants entitlements idempotently by paymentId.
That means replaying a webhook should not create duplicate payments, credits,
licenses, or affiliate commissions.
Provider notes
| Provider | Use it for | Notes |
|---|---|---|
| Stripe | Default SaaS checkout | Subscriptions, one-time payments, credit packs, portal. |
| Creem | Alternate card/local rails | Useful when Stripe is not ideal for your market. |
| NOWPayments | Crypto one-time payments | No subscriptions; invoice flow only. |
Verify it works
- Set Stripe keys and price IDs in
.env.local. - Run
pnpm dev. - Open
/checkout?productId=pro. - Complete a test payment.
- Confirm a row appears in
paymentand the expected credits/license are granted. - Replay the webhook; the database should stay unchanged.