Fix: Stripe Integration Not Working — Checkout Failing, Webhooks Not Firing, or Subscriptions Not Updating
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Stripe issues in Node.js and Next.js — Checkout Sessions, webhook signature verification, subscription lifecycle, customer portal, price management, and idempotent API calls.
The Problem
Stripe Checkout creates a session but the redirect fails:
const session = await stripe.checkout.sessions.create({
line_items: [{ price: 'price_xxx', quantity: 1 }],
mode: 'subscription',
success_url: 'http://localhost:3000/success',
cancel_url: 'http://localhost:3000/cancel',
});
// session.url is nullOr webhooks are received but signature verification fails:
Stripe webhook signature verification failed.
No signatures found matching the expected signature for payload.Or a subscription is created but the user’s account isn’t updated:
// Webhook fires but the database still shows the user as "free" tierProduction Incident: When Checkout Breaks, Revenue Stops
Treat broken Stripe paths as P1 incidents. The blast radius is your entire conversion funnel. A 500 on /api/checkout means every new signup hits a dead end. A failing webhook means money was charged but access was never granted — and you find out hours later when support tickets arrive.
The most painful failure mode is silent: the Checkout Session redirect works, the card is charged, the user lands on /success, but the webhook handler is throwing 500s in the background. Stripe retries the webhook with exponential backoff for up to three days, which means your dashboard fills with “customer paid but never got access” reports while the real bug is a typo in your database schema. Always alert on webhook 4xx/5xx responses in the Stripe Dashboard. Mean-time-to-detect should be minutes, not the next morning.
Set a SLO for the checkout endpoint: p99 latency under 1.5s, error rate under 0.1%. Page on-call when the rate of checkout.session.completed events drops more than 30% versus the same hour last week. That single alert catches most regressions before customers notice.
Why This Happens
Stripe’s integration has several components that must work together — the API, Checkout, webhooks, and your database:
- Checkout Sessions require
success_urlandcancel_url— these must be absolute URLs. In development,localhostworks, but Stripe requires HTTPS in production. A missing or malformed URL causessession.urlto be null. - Webhook signatures use the raw request body — Stripe signs the raw body string. If your framework parses the body as JSON before you verify the signature, the signature check fails because the parsed-then-re-stringified body doesn’t match the original raw bytes.
- Webhooks are the source of truth for subscription state — Checkout redirects happen after payment, but the webhook confirms it. If your webhook handler doesn’t update the database, the user’s subscription state is out of sync. Never trust the client redirect alone.
- Test mode and live mode are separate — API keys, webhook secrets, and prices exist in both test and live mode. Using a test price with a live API key (or vice versa) causes “resource not found” errors.
A second class of failures comes from idempotency. Stripe will deliver the same webhook event more than once whenever your endpoint times out, returns a non-2xx response, or even just on Stripe’s internal retries. If your handler upserts a record on checkout.session.completed and also debits a credit balance, double delivery doubles the credits. Every handler must be idempotent — either by checking event.id against a processed-events table, or by using ON CONFLICT DO NOTHING semantics on the database write.
A third class is the test-mode/live-mode split. Most production outages on Stripe come from deploying with a pk_live_... publishable key while the server still has sk_test_..., or vice versa. The session creation throws with a generic “resource not found” error, but the real cause is environment mixing. Lock keys per environment, and add a startup check that asserts both keys are from the same mode.
Fix 1: Stripe Checkout Integration
npm install stripe// lib/stripe.ts — Stripe client
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});// app/api/checkout/route.ts — create a Checkout Session
import { stripe } from '@/lib/stripe';
import { auth } from '@/auth';
export async function POST(req: Request) {
const session = await auth();
if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const { priceId } = await req.json();
// Find or create Stripe customer
let customerId = await getStripeCustomerId(session.user.id);
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email!,
metadata: { userId: session.user.id },
});
customerId = customer.id;
await saveStripeCustomerId(session.user.id, customerId);
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
// Metadata for webhook processing
metadata: {
userId: session.user.id,
},
subscription_data: {
metadata: {
userId: session.user.id,
},
},
// Allow promotion codes
allow_promotion_codes: true,
// Collect billing address
billing_address_collection: 'required',
});
return Response.json({ url: checkoutSession.url });
}// Client — redirect to Checkout
'use client';
async function handleCheckout(priceId: string) {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
if (url) window.location.href = url;
}Fix 2: Webhook Handler with Signature Verification
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';
// CRITICAL: disable body parsing — Stripe needs the raw body
export const dynamic = 'force-dynamic';
export async function POST(req: Request) {
const body = await req.text(); // Raw body — NOT req.json()
const headersList = await headers();
const signature = headersList.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return new Response('Webhook signature verification failed', { status: 400 });
}
// Idempotency — return early if we've already processed this event
const alreadyProcessed = await db.query.processedEvents.findFirst({
where: eq(processedEvents.id, event.id),
});
if (alreadyProcessed) return new Response('Already processed', { status: 200 });
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutComplete(session);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdate(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCanceled(subscription);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
default:
console.log(`Unhandled event: ${event.type}`);
}
// Record the event after successful processing
await db.insert(processedEvents).values({ id: event.id, type: event.type });
} catch (error) {
console.error('Webhook handler error:', error);
return new Response('Webhook handler error', { status: 500 });
}
return new Response('OK', { status: 200 });
}// Webhook handler functions
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) return;
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string,
);
await db.update(users).set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
plan: determinePlan(subscription.items.data[0].price.id),
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
}).where(eq(users.id, userId));
}
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
const userId = subscription.metadata?.userId;
if (!userId) return;
await db.update(users).set({
stripePriceId: subscription.items.data[0].price.id,
plan: determinePlan(subscription.items.data[0].price.id),
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
}).where(eq(users.id, userId));
}
async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
const userId = subscription.metadata?.userId;
if (!userId) return;
await db.update(users).set({
plan: 'free',
subscriptionStatus: 'canceled',
}).where(eq(users.id, userId));
}Fix 3: Customer Portal (Manage Subscriptions)
// app/api/portal/route.ts
import { stripe } from '@/lib/stripe';
import { auth } from '@/auth';
export async function POST() {
const session = await auth();
if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const user = await getUser(session.user.id);
if (!user.stripeCustomerId) {
return Response.json({ error: 'No subscription' }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return Response.json({ url: portalSession.url });
}Fix 4: One-Time Payments
// One-time payment Checkout Session
const session = await stripe.checkout.sessions.create({
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: 'Premium Template',
description: 'A beautiful website template',
images: ['https://myapp.com/template-preview.jpg'],
},
unit_amount: 4999, // $49.99 in cents
},
quantity: 1,
}],
mode: 'payment', // Not 'subscription'
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { userId: user.id, productId: 'template-premium' },
});
// Verify payment on success page
// app/success/page.tsx
export default async function SuccessPage({ searchParams }) {
const sessionId = (await searchParams).session_id;
if (!sessionId) redirect('/');
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status !== 'paid') redirect('/');
// Grant access
return <h1>Payment successful! Access granted.</h1>;
}Fix 5: Local Webhook Testing
# Install Stripe CLI
# macOS: brew install stripe/stripe-cli/stripe
# Windows: scoop install stripe
# Login
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Output: whsec_xxxxxxxx — use this as STRIPE_WEBHOOK_SECRET in .env.local
# Trigger a test event
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed# .env.local
STRIPE_SECRET_KEY=sk_test_xxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxx # From stripe listen output
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxFix 6: Usage-Based Billing
// Report usage for metered billing
import { stripe } from '@/lib/stripe';
async function reportUsage(subscriptionItemId: string, quantity: number) {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment', // or 'set' to override
});
}
// Example: track API calls
export async function apiMiddleware(req: Request) {
const user = await getUser(req);
// Process the API request
const result = await handleRequest(req);
// Report 1 API call to Stripe
if (user.stripeSubscriptionItemId) {
await reportUsage(user.stripeSubscriptionItemId, 1);
}
return result;
}Still Not Working?
Webhook signature verification fails — the most common cause is the body being parsed before verification. In Next.js App Router, use await req.text() (not await req.json()). In Express, use express.raw({ type: 'application/json' }) for the webhook route. The raw bytes must match exactly what Stripe signed.
session.url is null — this happens when mode is missing or invalid, or when success_url/cancel_url are relative paths instead of absolute URLs. Always use full URLs: https://myapp.com/success, not /success.
Subscription created but user still shows as “free” — the webhook handler isn’t updating the database. Check the Stripe Dashboard → Developers → Webhooks to see if the event was delivered and what response your endpoint returned. A 500 response means your handler threw an error. Also verify the webhook secret matches between Stripe and your .env.
Test mode payment succeeds but live mode fails — test and live mode have separate API keys, products, prices, and webhook secrets. When switching to live, update all environment variables. Also ensure your live Stripe account has completed identity verification and has a valid bank account for payouts.
Webhook timing out behind a slow database — Stripe expects a 2xx response within 30 seconds, but in practice you want under 5 seconds to avoid retries. If your handler runs heavy work (sending email, generating invoices, syncing to a CRM), enqueue a background job and respond 200 immediately. The webhook endpoint must be fast and idempotent — actual work belongs in your queue.
Duplicate charges from double-fired events — Stripe will redeliver any event whose handler returned a non-2xx response, and sometimes redelivers even after a 2xx response when the network drops. Use an event.id deduplication table with a unique constraint. Insert the ID first; if the insert fails on conflict, return 200 without re-processing. This pattern survives both client and server crashes.
api_key and publishable_key from different modes — a deploy that mixes sk_live_... with pk_test_... (or vice versa) produces confusing “No such price” or “No such customer” errors. At server startup, parse both keys and assert they share the same prefix. Fail loudly on mismatch instead of letting the first paying user discover it.
Subscription status drifts from Stripe over time — webhooks can be missed during deploys, downtime, or signature verification regressions. Schedule a daily reconciliation job that lists active subscriptions from Stripe and compares them against your database. Any drift (Stripe shows active, your DB shows canceled) generates an alert and corrects the record. This is your safety net when the webhook path silently breaks; without it, the gap stays open until a customer complains.
current_period_end shifts after a plan change — when a user upgrades mid-cycle, Stripe creates a new subscription item and current_period_end may shift to the new billing anchor. If your handler only reads the old item, you end up with a stale period end date and accidentally lock the user out at the wrong time. Always re-read the full subscription on customer.subscription.updated and use the highest current_period_end across items.
For related payment and backend issues, see Fix: Stripe Webhook Signature Verification Failed, Fix: Stripe Connect Not Working, Fix: Next.js API Route Not Working, and Fix: Inngest Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
Fix: BullMQ Not Working — Jobs Not Processing, Workers Not Starting, or Redis Connection Failing
How to fix BullMQ issues — queue and worker setup, Redis connection, job scheduling, retry strategies, concurrency, rate limiting, event listeners, and dashboard monitoring.
Fix: GraphQL Yoga Not Working — Schema Errors, Resolvers Not Executing, or Subscriptions Failing
How to fix GraphQL Yoga issues — schema definition, resolver patterns, context and authentication, file uploads, subscriptions with SSE, error handling, and Next.js integration.