DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Stripe Webhook Security: Verifying Signatures and Handling Retries

Stripe Webhook Security: Verifying Signatures and Handling Retries

Stripe webhooks without signature verification are a security hole.
Anyone can POST to your endpoint and fake a payment. Here's the right way.

Why Signature Verification Matters

Without it:

POST /api/webhooks/stripe
{ "type": "checkout.session.completed", "data": { ... } }
Enter fullscreen mode Exit fullscreen mode

Anyone can send this. Stripe signs every webhook with your webhook secret.
Only Stripe has the secret, so only Stripe can produce a valid signature.

Correct Webhook Handler (Next.js)

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const body = await request.text()  // MUST be raw text, not parsed JSON
  const signature = headers().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 Response.json({ error: 'Invalid signature' }, { status: 400 })
  }

  // Handle the event
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutCompleted(event.data.object)
      break
    case 'customer.subscription.created':
      await handleSubscriptionCreated(event.data.object)
      break
    case 'invoice.payment_failed':
      await handlePaymentFailed(event.data.object)
      break
    default:
      console.log(`Unhandled event type: ${event.type}`)
  }

  return Response.json({ received: true })
}
Enter fullscreen mode Exit fullscreen mode

Critical: Raw Body

The most common mistake — parsing the body before verification:

// WRONG — destroys the signature
const body = await request.json()  // Do NOT do this

// CORRECT — raw text
const body = await request.text()
Enter fullscreen mode Exit fullscreen mode

Idempotency: Handle Retries Safely

Stripe retries failed webhooks for up to 3 days. Your handler must be idempotent:

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  // Check if already processed
  const existing = await db.order.findUnique({
    where: { stripeSessionId: session.id }
  })

  if (existing) {
    console.log(`Order ${existing.id} already processed for session ${session.id}`)
    return  // Safe to return 200 — Stripe will stop retrying
  }

  // Process the order
  await db.order.create({
    data: {
      stripeSessionId: session.id,
      customerEmail: session.customer_email!,
      amount: session.amount_total!,
      status: 'paid',
    }
  })

  await deliverProduct(session)
  await sendConfirmationEmail(session.customer_email!)
}
Enter fullscreen mode Exit fullscreen mode

Webhook Event Store (Audit Trail)

async function processWebhook(event: Stripe.Event) {
  // Store every event (useful for debugging)
  await db.webhookEvent.upsert({
    where: { stripeEventId: event.id },
    create: {
      stripeEventId: event.id,
      type: event.type,
      payload: event as unknown as Prisma.JsonObject,
      processedAt: null,
    },
    update: {},  // Don't overwrite if exists
  })

  // Handle the event
  await handleEvent(event)

  // Mark as processed
  await db.webhookEvent.update({
    where: { stripeEventId: event.id },
    data: { processedAt: new Date() },
  })
}
Enter fullscreen mode Exit fullscreen mode

Local Testing with Stripe CLI

# Forward Stripe webhooks to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger a test event
stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

Common Event Handlers

// Subscription events
case 'customer.subscription.created':
case 'customer.subscription.updated':
  await syncSubscriptionToDb(event.data.object)
  break

case 'customer.subscription.deleted':
  await cancelUserSubscription(event.data.object.customer as string)
  break

case 'invoice.payment_failed':
  await notifyPaymentFailed(event.data.object.customer_email!)
  break

// One-time payment
case 'checkout.session.completed':
  if (event.data.object.mode === 'payment') {
    await deliverProduct(event.data.object)
  }
  break
Enter fullscreen mode Exit fullscreen mode

The AI SaaS Starter Kit ships with a complete Stripe webhook handler: signature verification, idempotency checks, subscription sync, and product delivery. $99 one-time.

Top comments (0)