patterntypescriptCritical
Stripe Webhook Handling with Signature Verification
Viewed 0 times
stripe v14
stripewebhookconstructEventsignature verificationHMACidempotencyraw body
Error Messages
Problem
Stripe webhooks are HTTP POST requests to a public endpoint. Without signature verification, any actor can send fake webhook events to trigger fulfillment, subscription upgrades, or other business logic.
Solution
Verify every incoming webhook using stripe.webhooks.constructEvent() with the raw request body (not parsed JSON) and the STRIPE_WEBHOOK_SECRET from the Stripe CLI or dashboard. Handle idempotency by checking the event ID before processing.
Why
Stripe signs every webhook with an HMAC-SHA256 signature using your webhook signing secret. constructEvent() verifies the signature and the timestamp to prevent replay attacks. Using the parsed body breaks the signature check.
Gotchas
- You MUST use the raw Buffer/string body — body parsers that convert to JSON break the signature check
- In Next.js App Router, call req.text() not req.json() before passing to constructEvent()
- Always handle the event idempotently — Stripe retries failed webhooks, so the same event can arrive multiple times
- The webhook signing secret differs between the Stripe CLI (for local dev) and the dashboard (for production)
Code Snippets
Next.js App Router Stripe webhook handler
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text(); // raw body — do NOT use req.json()
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return NextResponse.json({ error: 'Webhook signature verification failed' }, { status: 400 });
}
// Idempotency check
const processed = await db.stripeEvent.findUnique({ where: { id: event.id } });
if (processed) return NextResponse.json({ received: true });
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
}
await db.stripeEvent.create({ data: { id: event.id } });
return NextResponse.json({ received: true });
}Revisions (0)
No revisions yet.