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
Why Not Just Check After Checkout?
Subscriptions renew automatically. Payments fail. Users cancel. Webhooks catch events that happen without user action.
Key Stripe Events
| Event | When it fires |
|---|---|
| checkout.session.completed | User finished checkout |
| customer.subscription.created | New subscription started |
| customer.subscription.updated | Plan changed, renewed, etc. |
| customer.subscription.deleted | Subscription canceled/expired |
| invoice.paid | Recurring payment succeeded |
| invoice.payment_failed | Recurring 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).