Stripe Subscriptions + Webhooks (Next.js)
Overview
A production Stripe integration has four moving parts:
- Checkout Session — hosted Stripe page that takes the payment / starts the subscription.
- Customer Portal — hosted Stripe page for the user to manage their subscription.
- Webhooks — Stripe POSTs events (
customer.subscription.updated,invoice.paid, etc.) to your backend. This is the source of truth, not the Checkout response. - Local DB mirror — a
subscriptionstable that you keep in sync with Stripe via webhooks. Your app queries your DB, never Stripe's API in request paths.
Skip the local mirror and you'll regret it: every page that needs to check entitlements would hit Stripe's API (slow, rate-limited, fragile).
When to Use
- Adding paid plans, subscriptions, or one-time payments
- Building a self-serve cancel / upgrade flow
- Handling webhook events for subscription lifecycle
- Implementing trials, prorations, multi-currency, metered billing
- Debugging "webhook signature verification failed"
- Migrating from Paddle / Lemon Squeezy / Gumroad to Stripe
Don't use when:
- You're in the EU and need Merchant-of-Record handling for VAT — Lemon Squeezy / Paddle is simpler
- The product is one purchase, lifetime access, no recurring — Stripe Checkout in
paymentmode is overkill; consider PayPal / direct bank - Selling physical goods — use Shopify or Medusa
Setup
npm install stripe @stripe/stripe-js
Env:
STRIPE_SECRET_KEY=sk_test_... # server-only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # client-safe
STRIPE_WEBHOOK_SECRET=whsec_... # server-only, from CLI/dashboard
NEXT_PUBLIC_SITE_URL=https://example.com
Server client (singleton):
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-09-30.acacia', // pin a version
typescript: true,
});
Pin the API version — Stripe's API changes; pinning prevents silent breakage.
Pattern: Create Checkout Session (subscription)
// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { createClient } from '@/lib/supabase/server';
export async function POST(req: Request) {
const { priceId } = await req.json();
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
// ensure customer exists; cache stripe_customer_id on your user row
let customerId = user.user_metadata.stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { user_id: user.id },
});
customerId = customer.id;
await supabase.auth.updateUser({
data: { stripe_customer_id: customerId },
});
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing?canceled=true`,
allow_promotion_codes: true,
subscription_data: {
trial_period_days: 14, // optional
metadata: { user_id: user.id }, // surface on subscription too
},
automatic_tax: { enabled: true }, // EU VAT etc.
// For B2B: collect tax IDs
tax_id_collection: { enabled: true },
customer_update: { address: 'auto', name: 'auto' },
});
return NextResponse.json({ url: session.url });
}
Client redirect — no Stripe.js JS needed when using hosted Checkout:
'use client';
export function CheckoutButton({ priceId }: { priceId: string }) {
return (
<button
() => {
const res = await fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ priceId }) });
const { url } = await res.json();
window.location.href = url;
}}
>
Subscribe
</button>
);
}
Pattern: Customer Portal
// app/api/portal/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { createClient } from '@/lib/supabase/server';
export async function POST() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const customerId = user?.user_metadata?.stripe_customer_id;
if (!customerId) return NextResponse.json({ error: 'no_customer' }, { status: 400 });
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/billing`,
});
return NextResponse.json({ url: session.url });
}
Configure the portal: Stripe Dashboard → Settings → Billing → Customer Portal. Enable: update payment method, cancel/pause subscription, view invoices, change plan, tax IDs.
Pattern: Webhooks — The Critical Piece
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
export const runtime = 'nodejs'; // need Node for raw body
// IMPORTANT: don't use req.json() — we need the raw body for signature verification
export async function POST(req: Request) {
const body = await req.text();
const signature = (await headers()).get('stripe-signature');
if (!signature) return new NextResponse('No signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error('Webhook signature failed:', err);
return new NextResponse('Invalid signature', { status: 400 });
}
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await upsertSubscription(event.data.object);
break;
case 'customer.subscription.deleted':
await markSubscriptionCanceled(event.data.object);
break;
case 'invoice.paid':
await recordInvoice(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
default:
// Log unhandled events for visibility
console.log('Unhandled event:', event.type);
}
} catch (err) {
console.error(`Webhook handler error for ${event.type}:`, err);
// 500 → Stripe will retry. 200 → marked delivered, no retry.
return new NextResponse('Handler error', { status: 500 });
}
return NextResponse.json({ received: true });
}
Crucial details
- Raw body: Stripe signature is computed over the unmodified bytes. If you parse JSON first, signature verification fails.
- Idempotency: Stripe retries failed deliveries. Make handlers idempotent (e.g.
upsertnotinsert). - Fast response: handler must return < 30s. For heavy work, push to a queue (Inngest, Trigger.dev) and return 200 immediately.
- Status codes: return 200 only when fully processed. Return 5xx if you want Stripe to retry.
Pattern: Sync to Local DB
create table subscriptions (
user_id uuid primary key references auth.users(id),
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
status text not null, -- active, trialing, past_due, canceled, etc.
price_id text not null,
current_period_end timestamptz not null,
cancel_at_period_end boolean not null default false,
trial_end timestamptz,
updated_at timestamptz not null default now()
);
create index on subscriptions(stripe_customer_id);
async function upsertSubscription(sub: Stripe.Subscription) {
const userId = sub.metadata.user_id;
if (!userId) {
console.warn('Sub without user_id metadata:', sub.id);
return;
}
await db.subscriptions.upsert({
user_id: userId,
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
status: sub.status,
price_id: sub.items.data[0].price.id,
current_period_end: new Date(sub.current_period_end * 1000),
cancel_at_period_end: sub.cancel_at_period_end,
trial_end: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
});
}
Check entitlements from this table — never call Stripe in request paths.
Pattern: Entitlement Check
// lib/billing.ts
export async function hasActiveSubscription(userId: string) {
const sub = await db.subscriptions.findUnique({ where: { user_id: userId } });
if (!sub) return false;
return ['active', 'trialing'].includes(sub.status)
&& sub.current_period_end > new Date();
}
// usage in a Server Action / page
const hasPaid = await hasActiveSubscription(user.id);
if (!hasPaid) redirect('/upgrade');
Pattern: Local Dev with Stripe CLI
Webhooks won't reach localhost from production Stripe. Use the CLI:
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# prints a `whsec_...` — paste into .env.local as STRIPE_WEBHOOK_SECRET (dev)
Then trigger events:
stripe trigger customer.subscription.created
stripe trigger invoice.paid
The CLI replays events to your local handler with valid signatures.
Pattern: Multi-Currency
Stripe supports per-customer presentment currency:
// at customer creation
await stripe.customers.create({
email,
preferred_locales: ['it'],
});
// when creating prices, define a price object per currency
// or use Adaptive Pricing in the Dashboard (auto-converts).
For most SaaS: define one price-per-currency-per-product (e.g. price_eur_pro, price_usd_pro) and pick at Checkout time based on user locale.
Pattern: Stripe Tax (EU VAT)
Enable in Dashboard → Tax. Add automatic_tax: { enabled: true } to Checkout Sessions (shown above). Stripe validates customer address and applies correct VAT. Combined with tax_id_collection, business customers get their VAT number on the invoice for reverse-charge.
For Italian projects: add your IVA number in Dashboard → Tax → Registrations.
Pattern: Metered Billing (Usage)
Define a metered price in Dashboard (per-unit pricing without quantity). Report usage:
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity: 42, // e.g. API calls made
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
});
For real-time accuracy, send usage events as they happen. For cost, batch at end of billing period.
Pattern: Test Cards
4242 4242 4242 4242 → success
4000 0000 0000 9995 → declined (insufficient funds)
4000 0027 6000 3184 → 3DS required
Use any future expiry + any 3-digit CVC + any postcode. Full list: docs.stripe.com/testing.
Using with Next.js
- Webhook route must be on Node runtime (
export const runtime = 'nodejs'). Edge runtime breaks raw body access. - Don't import
stripefrom a Client Component — leaks the secret key into the bundle. Always wrap in a Server Action or Route Handler. - Vercel limits: webhook handler has 10s timeout on Hobby. Move heavy work to a queue.
stripe-signatureheader: read viaheaders()(async in Next 15). Don't rely on directreq.headers.get.- CSRF: webhook routes don't need CSRF; signature is the auth.
- Coolify: ensure your reverse proxy passes the raw body (don't compress/transform).
Examples
Example 1: 3-plan SaaS pricing page
3 Stripe Prices (monthly + annual variants). Pricing page calls /api/checkout with the chosen priceId. Webhook sync ensures subscriptions.status reflects reality. App uses hasActiveSubscription() to gate features.
Example 2: Annual upgrade in Customer Portal
Stripe Customer Portal configured to allow plan switching. User clicks upgrade → portal handles proration → webhook arrives → DB synced. Zero custom UI needed.
Example 3: Metered API SaaS
Free tier 1K calls/mo, then $0.001/call metered. Each request increments usage via createUsageRecord. Stripe bills at month end. Webhook on invoice.created lets you email the customer a heads-up.
Troubleshooting
"Webhook signature verification failed"
Cause: body was parsed (lost raw bytes) or wrong secret.
Fix: use await req.text(), never req.json(). Verify STRIPE_WEBHOOK_SECRET matches the endpoint (dashboard "Reveal" button or CLI stripe listen output). Test in CLI: stripe events resend evt_xxx.
"Customer has no payment method"
Cause: tried to create a subscription without a successful Checkout.
Fix: always go through Checkout for subscription creation. Don't try stripe.subscriptions.create directly from your app — that requires PaymentIntent setup and SCA handling.
Subscription stays in incomplete status
Cause: SCA (Strong Customer Authentication) required, user didn't complete 3DS.
Fix: this is normal during Checkout flow. If it persists, surface subscription.latest_invoice.payment_intent.next_action to prompt the user.
Local webhook handler not receiving events
Cause: Stripe CLI not running, or webhook secret mismatch.
Fix: keep stripe listen running in a terminal. The secret it prints must match STRIPE_WEBHOOK_SECRET in .env.local (different from production secret).
Duplicate subscription rows in DB
Cause: handler processed the same webhook event twice (Stripe retries).
Fix: use upsert on stripe_subscription_id unique constraint. Optionally check event.id against a "processed events" table.
Webhook timeout in production
Cause: handler doing heavy work (sending email, updating CRM, etc.). Fix: push side effects to a queue. Return 200 immediately. Queue worker handles slow stuff.
Customer charged but webhook never arrived
Cause: handler returned 5xx (Stripe gave up after retries), or endpoint URL wrong. Fix: check Dashboard → Webhooks → Endpoint → Recent deliveries. Replay failed events. Add monitoring on the route (Sentry / log alerts).
Tax not calculated
Cause: missing automatic_tax: { enabled: true } or customer address invalid.
Fix: add to Checkout Session. Ensure customer has full address (line1, city, postal_code, country). Verify your business location is set in Dashboard → Tax.
Test mode bleeds into production
Cause: env vars not separated.
Fix: use STRIPE_SECRET_KEY_TEST + STRIPE_SECRET_KEY_LIVE and pick at runtime, OR keep test/prod in different env files and never mix. Webhook endpoints are also separate (test/live).
Prices look correct in code but customer sees different amount
Cause: presentment vs settlement currency, or Adaptive Pricing FX conversion.
Fix: log session.amount_total and session.currency from the webhook. Disable Adaptive Pricing if you want exact local control.
Cancel link doesn't show in portal
Cause: portal configuration disables it. Fix: Dashboard → Settings → Customer Portal → Subscriptions → "Customers can cancel" → on.
Trial doesn't start
Cause: trial_period_days set on Checkout but customer already used trial.
Fix: Stripe blocks repeat trials per customer by default. Override with subscription_data.trial_period_days + trial_settings.end_behavior if you need to.