Payments
Provider-agnostic checkout and webhooks for the TanStack Start template — Creem or Stripe, persisted in D1.
Payments are an end-to-end billing flow backed by D1 that you can build on. The template ships two interchangeable providers behind one interface — Creem and Stripe — selected with a single environment variable. Checkout, the orders and billing pages, subscription state, and the provider webhooks (including the subscription lifecycle and refunds) all run against real data.
Pick a provider
Creem
Hosted checkout + signed webhooks. Default provider.
Stripe
Stripe Checkout, subscriptions, lifetime, and the billing portal.
Architecture
A small registry resolves the active provider; the server routes and the pricing UI never import a provider directly.
| File | Role |
|---|---|
src/config/payment-config.ts | Provider selection (getPaymentProvider) and env-driven product IDs (getPaymentProducts) |
src/lib/payment/index.ts | Provider registry + getProvider() (memoized instances) |
src/lib/payment/types.ts | The PaymentProvider interface and shared param/result types |
src/lib/payment/providers/creem.ts | Creem implementation |
src/lib/payment/providers/stripe.ts | Stripe implementation |
src/lib/payment/order-no.ts | Order-number generation (buildOrderNo) |
src/routes/api/payment/create.ts | Authenticated checkout creation |
src/routes/api/payment/notify/$provider.ts | Single webhook receiver for every provider |
src/routes/api/payment/portal.ts | Self-service billing portal (provider-dependent) |
src/routes/api/payment/query.ts | Order-status lookup by orderId |
src/lib/db/schema/payment.ts | payment and subscription tables (D1) |
src/routes/{-$locale}/_marketing/pay-success.tsx | Checkout return page |
Every provider implements one interface:
interface PaymentProvider {
readonly type: PaymentProviderType;
createCheckout(params: CreateCheckoutParams): Promise<CreateCheckoutResult>;
handleWebhook(request: WebhookRequest): Promise<void>;
createPortal?(customerId: string, returnUrl: string): Promise<string>;
}Switching providers
Set the provider and supply that provider's product/price IDs:
# creem (default) or stripe
VITE_PAYMENT_PROVIDER=stripegetPaymentProducts() reads the VITE_STRIPE_PRICE_* IDs when the provider is
stripe, and the VITE_CREEM_PRODUCT_* IDs otherwise.
VITE_* values are build-time inlined by Vite (public). Server secrets
(STRIPE_API_KEY, CREEM_API_KEY, the webhook secrets) are read from the
Worker environment — set them with wrangler secret put.
The display prices live in src/config/price-config.ts and are independent of
what the provider actually charges. See
Environment Variables.
Shared flow
Pricing CTA → create checkout
POST /api/payment/create requires an authenticated session, validates
{ productId } with Zod, resolves the product, generates an order number,
inserts a payment row (status: paying, provider: <active>), then calls the
active provider's createCheckout. Returns { orderNo, checkoutUrl }.
Redirect to hosted checkout
The browser is sent to checkoutUrl. The success URL is
{origin}/pay-success?orderId={orderNo}; cancel returns to {origin}/#pricing.
Success page → query order
/pay-success can call GET /api/payment/query?orderId=... to read the order
status from the payment table.
Webhook → update tables
The provider calls POST /api/payment/notify/{provider}. The route looks up the
provider by the path segment and calls its handleWebhook, which verifies the
signature (a failure answers 400, not 500) and updates the
payment / subscription tables.
Data model
Both providers write the same D1 tables. The subscription row is the user's
single source of entitlement; provider records who owns it so
/api/payment/portal can route the billing portal to the right SDK.
| Column | Notes |
|---|---|
payment.provider / subscription.provider | creem | stripe |
payment.thirdOrderNo | Provider checkout/session ID |
subscription.customerId | Provider customer ID — required for the billing portal |
subscription.subscriptionId | Provider subscription ID (null for one-time/lifetime) |
subscription.productType | month | year | one_time |
Add another provider
- Add its value to
paymentProvidersinpayment-config.ts. - Implement the
PaymentProviderinterface insrc/lib/payment/providers/. - Register the class in the registry in
src/lib/payment/index.ts.
The $provider webhook route and getProvider() pick it up automatically.
What's wired up
- The orders page (
account/order) lists the user's realpaymenthistory. - The subscription page (
account/subscription) shows live plan, status, period, and cancel state from thesubscriptiontable. - The billing page (
setting/billing) renders the plan summary and billing history, and links Stripe customers to the Billing Portal for payment methods, invoices, and cancellation. GET /api/payment/queryand/api/payment/ordersare authenticated and scoped to the signed-in user.
