SmithGuidesStripe Webhooks Not Firing — The Complete Fix

Any platformPayment FixIntermediate

Stripe Webhooks Not Firing — The Complete Fix

Webhooks work in the Stripe dashboard test but never hit your app. Here's why, and how to fix signature verification, endpoint registration, and the silent failure patterns AI-generated code creates.

12 min read·By Smith Community·July 7, 2026·Edit on GitHub

The problem

Stripe webhooks are the most common payment integration failure in AI-built apps. The symptoms:

  • Test payments succeed in Stripe dashboard but your database never updates
  • Subscription status stays "pending" even after a user pays
  • checkout.session.completed events show as "delivered" in Stripe dashboard but your handler never runs
  • You see 400 or 500 responses in the Stripe webhook logs
  • The classic: it works with stripe listen --forward-to locally but breaks in production

All of these trace back to a small number of root causes that AI tools get wrong almost every time.

Why AI tools get this wrong

AI code generators know Stripe's API surface but don't know your deployment environment. They generate webhook handlers that:

  1. Miss signature verification entirely — or verify it incorrectly, causing all webhooks to return 400
  2. Parse the request body before Stripe's SDK can read it — Next.js body parsing strips the raw bytes needed for signature verification
  3. Register the wrong endpoint URL — dev vs production, missing /api prefix, wrong path
  4. Handle only one event type — miss the 5 other events that actually matter for subscriptions

The fix

Step 1: Install and set up the Stripe SDK

npm install stripe

You need two Stripe keys in your environment:

# .env.local
STRIPE_SECRET_KEY=sk_test_...        # Server-only, never exposed to client
STRIPE_WEBHOOK_SECRET=whsec_...      # From your webhook endpoint config (next step)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...  # Safe for client

Step 2: Create the webhook endpoint in Stripe

In Stripe Dashboard → Developers → Webhooks → Add endpoint:

  • Endpoint URL: https://yourdomain.com/api/webhooks/stripe
  • Events to listen for (at minimum):
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_succeeded
    • invoice.payment_failed

After saving, click Reveal signing secret and copy it to STRIPE_WEBHOOK_SECRET.

For local development, use the Stripe CLI:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI prints a webhook secret starting with whsec_ — use this as your local STRIPE_WEBHOOK_SECRET.

Step 3: Write the webhook handler correctly

The most important thing: you must use the raw request body for signature verification. Next.js's default body parser converts it to JSON first, which changes the byte representation and breaks the signature check.

Create app/api/webhooks/stripe/route.ts:

import { headers } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-06-30',
})
 
export async function POST(request: NextRequest) {
  const body = await request.text() // ← Raw text, NOT request.json()
  const headersList = await headers()
  const signature = headersList.get('stripe-signature')
 
  if (!signature) {
    return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 })
  }
 
  let event: Stripe.Event
 
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Signature verification failed'
    console.error('Webhook signature verification failed:', message)
    return NextResponse.json({ error: message }, { status: 400 })
  }
 
  // Handle events
  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session
        await handleCheckoutCompleted(session)
        break
      }
 
      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription
        await handleSubscriptionChange(subscription)
        break
      }
 
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription
        await handleSubscriptionCancelled(subscription)
        break
      }
 
      case 'invoice.payment_succeeded': {
        const invoice = event.data.object as Stripe.Invoice
        await handlePaymentSucceeded(invoice)
        break
      }
 
      case 'invoice.payment_failed': {
        const invoice = event.data.object as Stripe.Invoice
        await handlePaymentFailed(invoice)
        break
      }
 
      default:
        // Acknowledge unknown events — don't return 4xx or Stripe will retry
        console.log(`Unhandled event type: ${event.type}`)
    }
  } catch (err) {
    console.error(`Error handling ${event.type}:`, err)
    // Return 500 so Stripe retries — only do this for transient errors
    return NextResponse.json(
      { error: 'Handler failed' },
      { status: 500 }
    )
  }
 
  return NextResponse.json({ received: true })
}

Step 4: Implement the handlers

Here's a complete example using Supabase, which is the most common database in Bolt/Loveable projects:

import { createClient } from '@supabase/supabase-js'
 
// Use service role key here — webhook runs server-side and needs to bypass RLS
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)
 
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  if (session.mode === 'payment') {
    // One-time payment — record it
    await supabase.from('orders').insert({
      stripe_session_id: session.id,
      customer_email: session.customer_details?.email,
      amount: session.amount_total,
      status: 'paid',
      metadata: session.metadata,
    })
  }
  // For subscription mode, wait for customer.subscription.created instead
}
 
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string
 
  // Look up the user by their Stripe customer ID
  const { data: profile } = await supabase
    .from('profiles')
    .select('id')
    .eq('stripe_customer_id', customerId)
    .single()
 
  if (!profile) {
    console.error(`No user found for Stripe customer: ${customerId}`)
    return
  }
 
  await supabase.from('subscriptions').upsert({
    user_id: profile.id,
    stripe_subscription_id: subscription.id,
    stripe_customer_id: customerId,
    status: subscription.status,
    price_id: subscription.items.data[0]?.price.id,
    current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
    current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
    cancel_at_period_end: subscription.cancel_at_period_end,
  }, {
    onConflict: 'stripe_subscription_id',
  })
}
 
async function handleSubscriptionCancelled(subscription: Stripe.Subscription) {
  await supabase
    .from('subscriptions')
    .update({ status: 'canceled' })
    .eq('stripe_subscription_id', subscription.id)
}
 
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
  // Update subscription status in case it was past_due
  if (invoice.subscription) {
    await supabase
      .from('subscriptions')
      .update({ status: 'active' })
      .eq('stripe_subscription_id', invoice.subscription)
  }
}
 
async function handlePaymentFailed(invoice: Stripe.Invoice) {
  if (invoice.subscription) {
    await supabase
      .from('subscriptions')
      .update({ status: 'past_due' })
      .eq('stripe_subscription_id', invoice.subscription)
  }
  // TODO: send "payment failed" email to customer
}

Step 5: Make your webhook route opt out of body parsing

In Next.js App Router, API routes handle their own body parsing and you read the raw body with request.text() as shown above. No extra config needed in App Router.

If you're on Pages Router (older Bolt projects sometimes use this), add:

// pages/api/webhooks/stripe.ts
export const config = {
  api: {
    bodyParser: false, // ← Critical for Pages Router
  },
}

Step 6: Make the route public

If you have middleware that requires authentication, the webhook route must be excluded. Stripe calls it without any auth headers.

In middleware.ts, add the webhook path to your public routes:

const PUBLIC_PATHS = [
  '/login',
  '/auth',
  '/api/webhooks', // ← Add this
]
 
if (!user && !PUBLIC_PATHS.some(path => request.nextUrl.pathname.startsWith(path))) {
  return NextResponse.redirect(new URL('/login', request.url))
}

Verify it works

Local testing:

# Terminal 1 — run your app
npm run dev
 
# Terminal 2 — forward Stripe events
stripe listen --forward-to localhost:3000/api/webhooks/stripe
 
# Terminal 3 — trigger a test event
stripe trigger checkout.session.completed

You should see:

  1. The Stripe CLI show 200 OK
  2. Your app's terminal show the event type being handled
  3. Your database updated

Production testing:

In Stripe Dashboard → Webhooks → your endpoint → Send test webhook. Start with checkout.session.completed. Watch the response code — 200 means it worked. 400 means signature verification failed (wrong secret). 500 means your handler threw an error (check your logs).

  • stripe trigger checkout.session.completed returns 200 locally
  • Database row created/updated after trigger
  • Production webhook shows 200 in Stripe dashboard
  • Subscription status updates correctly after test checkout

Common mistakes

Mistake 1: Using request.json() instead of request.text()

// ❌ Breaks signature verification — body has already been parsed
const body = await request.json()
 
// ✅ Raw text for Stripe to verify
const body = await request.text()

Mistake 2: Using the wrong webhook secret

Your Stripe dashboard has one signing secret per webhook endpoint. Your local Stripe CLI has a different one. If you paste the dashboard secret into .env.local while using the CLI, every webhook returns 400. Keep them separate:

# .env.local (development)
STRIPE_WEBHOOK_SECRET=whsec_abc123...  # from stripe listen output
 
# .env.production (Vercel / production)
STRIPE_WEBHOOK_SECRET=whsec_xyz789...  # from Stripe dashboard endpoint

Mistake 3: Returning 4xx for events you don't handle

Stripe retries webhooks that return 4xx or 5xx. If you return 400 for unknown event types, Stripe will retry them repeatedly. Always return 200 for events you don't handle — you're acknowledging receipt, not saying you processed it.

Mistake 4: Not storing the Stripe customer ID on the user

You'll need to look up users by their Stripe customer ID in almost every webhook handler. If you didn't store stripe_customer_id on your profiles table when the customer was created, you have no way to link Stripe events to users. Add it during checkout:

// When creating a Stripe checkout session
const customer = await stripe.customers.create({ email: user.email })
 
await supabase
  .from('profiles')
  .update({ stripe_customer_id: customer.id })
  .eq('id', user.id)
 
const session = await stripe.checkout.sessions.create({
  customer: customer.id,
  // ...
})

Going further

  • Set up Stripe's idempotency keys if retries could cause duplicate records
  • Add webhook event deduplication — Stripe can deliver the same event more than once
  • Monitor failed webhooks: Stripe retries for 3 days, but you want to know about failures faster than that

Payment integration is where most AI-built apps lose real money. If you'd rather have it done right, post a Payment Fix job on Smith.

Tags:stripewebhookspaymentsnextjs

Rather have it done for you?

This is exactly what Smith rescues.

Vetted AI builders who specialize in payment fix work. Escrow-protected, done in days — not weeks.