Back to Playbook
PlaybookPayments

Webhooks

Webhooks are like a doorbell for your app. When something happens in Stripe (payment completed, subscription canceled), Stripe "rings" your server with the details. You don't poll—you react.

What Are Webhooks?

Instead of constantly asking Stripe "did anything happen?" (polling), Stripe pushes events to you. Your server has an endpoint like /api/stripe/webhook that receives these notifications.

The Flow

Stripe→ POST →/api/stripe/webhookYour code runs

Why Not Just Check After Checkout?

Subscriptions renew automatically. Payments fail. Users cancel. Webhooks catch events that happen without user action.

Key Stripe Events

EventWhen it fires
checkout.session.completedUser finished checkout
customer.subscription.createdNew subscription started
customer.subscription.updatedPlan changed, renewed, etc.
customer.subscription.deletedSubscription canceled/expired
invoice.paidRecurring payment succeeded
invoice.payment_failedRecurring payment failed

Building a Webhook Handler

// app/api/stripe/webhook/route.ts

import

Stripe

from

"stripe"

;

import

{ headers }

from

"next/headers"

;

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

export async function POST(req: Request) {

const body = await req.text();

const signature = headers().get("stripe-signature")!;

let event: Stripe.Event;

try {

// Verify the webhook is really from Stripe

event = stripe.webhooks.constructEvent(

body,

signature,

process.env.STRIPE_WEBHOOK_SECRET!

);

} catch (err) {

return new Response("Invalid signature", { status: 400 });

}

// Handle specific events

switch (event.type) {

case "checkout.session.completed":

const session = event.data.object;

await activateSubscription(session.metadata?.userId);

break;

case "customer.subscription.deleted":

const sub = event.data.object;

await deactivateSubscription(sub.metadata?.userId);

break;

}

return new Response("OK", { status: 200 });

}

Signature Verification (Critical!)

Anyone could POST to your webhook endpoint and fake an event. Signature verification ensures the request really came from Stripe.

Without Verification

Attackers can send fake "payment completed" events and get free access.

With Verification

constructEvent() cryptographically verifies Stripe signed the payload.

Get Your Webhook Secret

In Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret. Add as STRIPE_WEBHOOK_SECRET in your env vars.

Testing with Stripe CLI

You can't test webhooks by refreshing a page. Use the Stripe CLI to forward webhook events to your local server.

# Install Stripe CLI

brew install stripe/stripe-cli/stripe # Mac

scoop install stripe # Windows

# Login to your Stripe account

stripe login

# Forward webhooks to localhost

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

# Trigger a test event

stripe trigger checkout.session.completed

Local Webhook Secret

When you run stripe listen, it prints a webhook signing secret starting with whsec_. Use that for local testing.

Handling Retries (Idempotency)

Stripe retries webhooks if your server doesn't respond with 200. The same event might hit your endpoint multiple times.

// Check if we've already processed this event

const eventId = event.id;

const existing = await db.query.webhookEvents.findFirst({

where: eq(webhookEvents.stripeEventId, eventId)

});

if (existing) {

// Already processed - return 200 so Stripe stops retrying

return new Response("Already processed", { status: 200 });

}

// Process the event...

// Then record that we processed it

await db.insert(webhookEvents).values({ stripeEventId: eventId });

Why This Matters

Without idempotency, a retry could credit a user twice, send duplicate emails, or corrupt your data.

Common Pitfalls

Skipping signature verification

"It works without it!" Yes, and so does leaving your front door unlocked.

Fix: Always use stripe.webhooks.constructEvent(). It's one line of code.

Returning errors for events you don't handle

Stripe sends many event types. Returning 500 for unknown types = infinite retries.

Fix: Always return 200 at the end, even for events you ignore. Use a switch with a default case.

Slow webhook handlers

Stripe expects a response within 20 seconds. Long processing = timeout = retry.

Fix: Acknowledge quickly, process async. Store the event, return 200, process in a background job.

Different webhook secrets per environment

Using your production secret locally (or vice versa) = signature mismatch.

Fix: Use the Stripe CLI secret for local dev. Use your dashboard secret for production (in Vercel env vars).

Ready to accept payments?

Start with the complete Stripe payments guide.

Payments Guide →