Stripe done right is a product surface, not a checkout button
Subscriptions with prorating, Customer Portal for self-service, an idempotent webhook router that survives the day Stripe retries an event four times. The billing layer that grows with the product instead of asking for a rewrite at series A.
Why this stack
Customer Portal is the cheapest feature you do not build
Plan changes, cancellation flows, receipt downloads, invoice history, payment method updates. All of it ships free with Customer Portal. Building it in house is two engineer-months of work that nobody wants to maintain.
Subscriptions handle the edges that bury indie billing
Prorating on upgrade, downgrade at the period boundary, mid-cycle add-ons, trial extensions, retries on failed card, dunning emails. Stripe handles every one. We wire the listener so the database reflects what Stripe knows.
Webhooks as the source of truth
The client never decides whether a subscription is active. The webhook receives the event, validates the signature, writes the canonical row, and the application reads it. No race condition between checkout success and database state.
Tax and Radar are compliance you would otherwise punt
Stripe Tax handles VAT, GST, sales tax automatically for the regions your buyer cares about. Radar handles fraud rules without you writing a heuristic engine. Both ship as configuration, not as a parallel project.
Sigma and reports for finance without a custom dashboard
Your finance team writes SQL against the live Stripe data. No CSV export, no monthly reconciliation script, no shadow ledger drifting from the truth.
What we build with it
Stripe Checkout integration
Hosted Checkout for fast launches, or embedded Elements for fully branded flows. We pick based on conversion data, not aesthetic preference.
Subscriptions with prorating
Plan tiers, monthly and annual, usage-based add-ons, prorating on every upgrade, downgrade at the period boundary, paused subscriptions.
Customer Portal embed
Self-service plan management, cancellation flows, invoice history, payment method updates. Configured to match the brand and the product surface.
Webhook event router
Idempotent handler that dedupes by event ID, type-safe routing by event name, DB transaction per event, dead-letter queue for poison messages.
Stripe Tax wiring
Automatic VAT, GST and sales tax calculation per region. Tax IDs collected at checkout, invoice formatting that complies with the buyer jurisdiction.
Radar fraud rules
Block rules, review rules, allow lists for repeat customers. Tuned for the actual fraud patterns the product sees, not a generic ruleset.
Stripe Connect for marketplaces
Express, Standard, or Custom connected accounts depending on the marketplace model. Onboarding, payouts, KYC, 1099 reporting where applicable.
Usage-based billing
Metering, aggregate windows, tiered pricing, overage thresholds. Stripe Meters or custom usage records depending on the model.
Invoice customisation
Branded invoice templates, line item formatting, multi-currency, tax line breakdowns, custom footer copy.
Dunning and smart retries
Failed payment recovery, smart retry windows, dunning email sequence wired to your transactional email provider.
Reconciliation jobs
A nightly job that compares Stripe events to the database and surfaces drift before finance does.
Migration from PayPal, Paddle, Lemon Squeezy
Existing subscriptions migrated with billing cycle preserved, customer payment methods imported where the source provider allows it.
An idempotent webhook router that survives Stripe retries
Stripe retries failed webhook deliveries for up to three days. Every handler we ship is idempotent by event ID, type-safe by event name, and transactional with the database write.
Most Stripe tutorials wire a Checkout button and stop. Production billing breaks on the day Stripe retries an event four times and the database ends up with three half-updated rows. The webhook router below is the one we ship: idempotent by event ID, type-safe by event name, transactional with the database, and observable when an event fails.
1. The route handler
The webhook arrives at a Node-runtime route. We need the raw body to validate the signature, so we read it as text before parsing. Idempotency check happens before the event is processed.
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/server'
import { recordWebhookEvent, isAlreadyProcessed } from '@/lib/stripe/events'
import { routeEvent } from '@/lib/stripe/router'
export const runtime = 'nodejs'
export async function POST(request: Request) {
const signature = (await headers()).get('stripe-signature')
if (!signature) return new Response('missing signature', { status: 400 })
const body = await request.text()
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response('invalid signature', { status: 400 })
}
// Idempotency: bail if we have already processed this event ID.
if (await isAlreadyProcessed(event.id)) {
return new Response(null, { status: 200 })
}
try {
await routeEvent(event)
await recordWebhookEvent(event.id, event.type, 'processed')
return new Response(null, { status: 200 })
} catch (err) {
await recordWebhookEvent(
event.id,
event.type,
'failed',
err instanceof Error ? err.message : 'unknown',
)
// Returning 500 makes Stripe retry. Returning 200 acks; we choose based
// on whether the failure is recoverable.
return new Response('handler failed', { status: 500 })
}
}
2. The idempotency table
A single table with a unique constraint on event_id is the entire mechanism. A duplicate retry hits the constraint and exits early.
-- supabase/migrations/0010_stripe_webhook_events.sql
create table stripe_webhook_events (
event_id text primary key,
type text not null,
status text not null,
error text,
received_at timestamptz not null default now()
);
create index stripe_webhook_events_received_idx
on stripe_webhook_events(received_at desc);
// src/lib/stripe/events.ts
import { adminClient } from '@/lib/supabase/admin'
export async function isAlreadyProcessed(eventId: string): Promise<boolean> {
const { data } = await adminClient
.from('stripe_webhook_events')
.select('event_id')
.eq('event_id', eventId)
.maybeSingle()
return data !== null
}
export async function recordWebhookEvent(
eventId: string,
type: string,
status: 'processed' | 'failed',
error?: string,
): Promise<void> {
await adminClient.from('stripe_webhook_events').upsert({
event_id: eventId,
type,
status,
error: error ?? null,
})
}
3. The type-safe router
A discriminated union maps event types to handlers. TypeScript narrows the event shape per case, so the handler receives a fully typed payload without a runtime cast.
// src/lib/stripe/router.ts
import type Stripe from 'stripe'
import { handleCheckoutCompleted } from './handlers/checkout-completed'
import { handleSubscriptionUpdated } from './handlers/subscription-updated'
import { handleInvoicePaymentFailed } from './handlers/invoice-payment-failed'
import { handleCustomerDeleted } from './handlers/customer-deleted'
export async function routeEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'checkout.session.completed':
return handleCheckoutCompleted(event.data.object)
case 'customer.subscription.updated':
case 'customer.subscription.created':
case 'customer.subscription.deleted':
return handleSubscriptionUpdated(event.data.object)
case 'invoice.payment_failed':
return handleInvoicePaymentFailed(event.data.object)
case 'customer.deleted':
return handleCustomerDeleted(event.data.object)
default:
// Unhandled events return 200 without work; the table still records them
// so we can audit traffic without losing the event.
return
}
}
4. A handler that is transactional
The subscription handler upserts the canonical row inside a single transaction. Either the database matches Stripe, or the write rolls back and Stripe retries.
// src/lib/stripe/handlers/subscription-updated.ts
import type Stripe from 'stripe'
import { adminClient } from '@/lib/supabase/admin'
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
): Promise<void> {
const tenantId =
subscription.metadata.tenant_id ??
(await lookupTenantByCustomer(subscription.customer as string))
if (!tenantId) {
throw new Error(`no tenant for subscription ${subscription.id}`)
}
const { error } = await adminClient.from('subscriptions').upsert({
id: subscription.id,
tenant_id: tenantId,
status: subscription.status,
current_period_end: new Date(
subscription.current_period_end * 1000,
).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
price_id: subscription.items.data[0]?.price.id ?? null,
quantity: subscription.items.data[0]?.quantity ?? 1,
updated_at: new Date().toISOString(),
})
if (error) throw error
}
async function lookupTenantByCustomer(customerId: string): Promise<string | null> {
const { data } = await adminClient
.from('tenants')
.select('id')
.eq('stripe_customer_id', customerId)
.maybeSingle()
return data?.id ?? null
}
5. What this buys you
When Stripe retries an event, the unique constraint on event_id short-circuits the duplicate work. When the database write fails, the entire handler rolls back and Stripe retries. When a new event type ships, you add a case in the router and TypeScript tells you the payload shape. The audit table records every event the application has ever seen, so on the day a customer asks "why is my subscription showing the wrong plan", you have the receipts.
Production billing is not a Checkout button. It is the discipline of treating Stripe events as the source of truth and making the database catch up, retry-safe, type-safe, every time.
Frequently asked questions
Stripe Checkout or Elements?
Checkout for most SaaS — faster to ship, fewer conversion regressions, hosted by Stripe so PCI scope stays small. Elements when the buyer needs a fully branded in-app checkout (often premium consumer or marketplace flows). We pick based on conversion-rate evidence, not preference.
How do you handle webhook idempotency?
Every event has a Stripe event ID. We persist that ID with a unique constraint before processing the event; a duplicate retry hits the constraint and returns 200 without re-running the work. The handler is also wrapped in a database transaction so a partial write never leaves the row in a half-state.
Can you migrate from PayPal, Paddle, or Lemon Squeezy?
Yes. Active subscriptions migrate with their existing billing cycle preserved; payment methods import via the provider's customer migration tools where supported (Stripe has direct importers for some). The cutover happens on a single day with a freeze window and a runbook the team executes step-by-step.
How do you handle multi-currency?
One Stripe account, multiple presentment currencies. Prices are quoted in the buyer's currency at checkout, the payout currency is set per region, and the FX is handled by Stripe. The dashboard reports always show the canonical currency for reconciliation.
Do you do Stripe Connect for marketplaces?
Yes. Express for fast onboarding (default), Custom when the marketplace needs full control over the connected account experience. We have wired payouts, 1099 reporting where applicable, KYC retries and connected-account dashboards for marketplaces in three different verticals.
What about PCI compliance and EU VAT?
PCI scope stays at SAQ A when checkout happens on Stripe Hosted Checkout or via Elements with iframed inputs. EU VAT is handled by Stripe Tax with the buyer's tax ID collected at checkout. We document the compliance posture in the technical handover.
Dunning and revenue recovery?
Smart retries configured to your customer base, dunning emails wired into your transactional provider (Resend by default), in-app banners when a payment method needs attention. Recovered MRR is reported back into the admin dashboard.
Tell us how your product bills
A scoping call, a concrete number in the first reply, no agency theater. A billing layer in two to four weeks.