Skip to content

Fix: Stripe Integration Not Working — Checkout Failing, Webhooks Not Firing, or Subscriptions Not Updating

FixDevs ·

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 null

Or 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" tier

Why This Happens

Stripe’s integration has several components that must work together — the API, Checkout, webhooks, and your database:

  • Checkout Sessions require success_url and cancel_url — these must be absolute URLs. In development, localhost works, but Stripe requires HTTPS in production. A missing or malformed URL causes session.url to 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.

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 });
  }

  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}`);
    }
  } 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_xxxxxxxx

Fix 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.

For related payment and backend issues, see Fix: Stripe Webhook Signature Verification Failed and Fix: Next.js App Router Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles