Back to Knowledge
Updated Sep 2026
PlaybookPayments

Webhooks

Webhooks

Webhook

An automatic message sent from one app to another when something happens. Like 'Hey, a user signed up!' in real-time.

"Like a doorbell. Instead of constantly checking, you get notified when someone arrives."

are 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. The examples use Stripe, but the rules (verify, dedupe, respond fast) apply to every webhook provider.

What Are Webhooks?

Instead of constantly asking Stripe "did anything happen?" (polling), Stripe pushes events to you as an HTTPS POST with a JSON body. Your app exposes an endpoint

Endpoint

A specific URL where your API receives requests. Like /api/users or /api/products. Each endpoint handles a specific action.

"Like different phone extensions at a company. Dial the right one to reach the right department."

like /api/webhooks/stripe to receive them.

The Flow

Stripe→ POST →/api/webhooks/stripe→ verify →update DB→ 200 OK

Why Not Just Check After Checkout?

Subscriptions renew automatically. Payments fail. Users cancel in the Customer Portal. Webhooks catch events that happen without the user on your site.

Key Stripe Events

EventWhen it fires
checkout.session.completedUser finished Checkout (save customer + subscription IDs here)
customer.subscription.createdNew subscription started
customer.subscription.updatedPlan changed, renewed, status changed, cancel scheduled
customer.subscription.deletedSubscription ended: revoke access
invoice.paidA (recurring) payment succeeded
invoice.payment_failedA recurring payment failed: nudge the user

Subscribe your endpoint only to the events you handle. Stripe recommends against listening to everything.

Building a Webhook Handler

A Next.js route handler that verifies the signature, skips duplicates, and updates your database. (lib/stripe.ts comes from the Payments guide; the table columns from Subscriptions.)

app/api/webhooks/stripe/route.ts

import type Stripe from "stripe";
import { eq } from "drizzle-orm";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { users, webhookEvents } from "@/lib/db/schema";

export async function POST(req: Request) {
  // 1. Raw body + signature header. Don't use req.json() here!
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  // 2. Verify it really came from Stripe
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  // 3. Idempotency: record the event ID first; skip if we've seen it
  const inserted = await db
    .insert(webhookEvents)
    .values({ stripeEventId: event.id })
    .onConflictDoNothing()
    .returning();
  if (inserted.length === 0) return new Response("Duplicate", { status: 200 });

  // 4. Handle the events you care about
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object;
      if (session.client_reference_id) {
        await db
          .update(users)
          .set({
            stripeCustomerId: session.customer as string,
            stripeSubscriptionId: session.subscription as string,
            subscriptionStatus: "active",
          })
          .where(eq(users.id, session.client_reference_id));
      }
      break;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const sub = event.data.object;
      await db
        .update(users)
        .set({ subscriptionStatus: sub.status })
        .where(eq(users.stripeCustomerId, sub.customer as string));
      break;
    }
    default:
      // Not interested: still say 200 so Stripe doesn't retry
      break;
  }

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

lib/db/schema.ts (dedupe table)

export const webhookEvents = pgTable("webhook_events", {
  stripeEventId: text("stripe_event_id").primaryKey(),
  receivedAt: timestamp("received_at", { withTimezone: true }).defaultNow(),
});

If processing throws after the insert, delete the row (or wrap both in a transaction) so Stripe's retry can try again.

Signature Verification (Critical!)

Your webhook URL is public. Anyone could POST to it and fake an event. Signature verification proves the request came from Stripe and wasn't modified.

Without Verification

Attackers send a fake "payment completed" event and get free access.

With Verification

constructEvent() checks the HMAC-SHA256 signature in the Stripe-Signature header and rejects stale timestamps (5-minute default tolerance) to block replays.

Get Your Webhook Secret

In the Stripe Dashboard, open Workbench → Webhooks, create an event destination pointing at https://yourdomain.com/api/webhooks/stripe, pick your events, then reveal the signing secret (whsec_...). Save it as STRIPE_WEBHOOK_SECRET. Roll it periodically, or immediately if it leaks.

Testing with the Stripe CLI

Stripe can't reach localhost. The Stripe CLI forwards events from your sandbox to your dev server.

Terminal

# Install (Homebrew, Scoop, and other options: docs.stripe.com/cli/install)
npm install -g @stripe/cli

# Log in to your Stripe account
stripe login

# Forward events to your local route (prints a whsec_... secret)
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# In a second terminal: fire a test event
stripe trigger checkout.session.completed

Local Webhook Secret

stripe listen prints its own signing secret starting with whsec_. Put that one in .env.local and restart next dev. It's different from your Dashboard endpoint's secret.

Retries, Duplicates & Ordering

Retries

If you don't return 2xx, Stripe retries with exponential backoff for up to three days in live mode (a few times over a few hours in a sandbox). You can also resend from the Dashboard.

Duplicates

The same event can arrive more than once. Log event.id and skip ones you've processed, like the handler above.

Ordering

Order isn't guaranteed, and created timestamps can tie. Never use them to sequence events.

Why This Matters

Without idempotency, a retry could grant access twice, send duplicate emails, or double-count revenue.

Common Pitfalls

Skipping signature verification

“It works without it!” Yes, and so does leaving your front door unlocked. Anyone can POST a fake “payment succeeded” event.

Fix: Always call stripe.webhooks.constructEvent() on the raw body. It's a few lines of code.

Parsing the body before verifying

Calling req.json() (or any middleware that re-serializes JSON) changes the bytes, so the signature never matches.

Fix: Use await req.text() and pass that exact string to constructEvent.

Returning errors for events you don't handle

Stripe treats non-2xx responses as failures and keeps retrying.

Fix: Return 200 for event types you ignore, and only subscribe the endpoint to events you actually use.

Slow webhook handlers

Stripe's docs say to return a 2xx quickly, before any complex logic that could time out. Timeouts count as failures and trigger retries.

Fix: Verify, record the event, respond. Push heavy work (emails, reports) to a queue or durable workflow.

Assuming events arrive in order

Stripe doesn't guarantee ordering. invoice.paid can arrive before customer.subscription.created.

Fix: Don't depend on sequence. When in doubt, fetch the latest object from the API (e.g. stripe.subscriptions.retrieve(id)) and save that.

Mixing up signing secrets

The stripe listen secret, each sandbox endpoint, and the live endpoint all have different whsec_ values.

Fix: CLI secret in .env.local; the live endpoint's secret only in Vercel's Production env vars

Environment Variable

A secret value stored outside your code, like API keys or passwords. Keeps sensitive info out of your codebase.

"Like a sticky note with the WiFi password — you know it, but you don't write it on the wall."

.

Blocking the webhook with auth

If your proxy.ts protects /api/*, Stripe gets redirected to a login page and every delivery fails (Stripe treats redirects as failures).

Fix: Exclude the webhook path from your auth matcher. The signature is the authentication. See Protected Routes.

Ready to accept payments?

Put the webhook to work with Checkout and recurring plans.