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

Architecture

A small registry resolves the active provider; the server routes and the pricing UI never import a provider directly.

FileRole
src/config/payment-config.tsProvider selection (getPaymentProvider) and env-driven product IDs (getPaymentProducts)
src/lib/payment/index.tsProvider registry + getProvider() (memoized instances)
src/lib/payment/types.tsThe PaymentProvider interface and shared param/result types
src/lib/payment/providers/creem.tsCreem implementation
src/lib/payment/providers/stripe.tsStripe implementation
src/lib/payment/order-no.tsOrder-number generation (buildOrderNo)
src/routes/api/payment/create.tsAuthenticated checkout creation
src/routes/api/payment/notify/$provider.tsSingle webhook receiver for every provider
src/routes/api/payment/portal.tsSelf-service billing portal (provider-dependent)
src/routes/api/payment/query.tsOrder-status lookup by orderId
src/lib/db/schema/payment.tspayment and subscription tables (D1)
src/routes/{-$locale}/_marketing/pay-success.tsxCheckout 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=stripe

getPaymentProducts() 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.

ColumnNotes
payment.provider / subscription.providercreem | stripe
payment.thirdOrderNoProvider checkout/session ID
subscription.customerIdProvider customer ID — required for the billing portal
subscription.subscriptionIdProvider subscription ID (null for one-time/lifetime)
subscription.productTypemonth | year | one_time

Add another provider

  1. Add its value to paymentProviders in payment-config.ts.
  2. Implement the PaymentProvider interface in src/lib/payment/providers/.
  3. 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 real payment history.
  • The subscription page (account/subscription) shows live plan, status, period, and cancel state from the subscription table.
  • 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/query and /api/payment/orders are authenticated and scoped to the signed-in user.

On this page