Stripe webhooks in Next.js — tutorial

Follow 5 simple steps: create the route, verify the request, get the secret, handle the payment event, and test locally. You'll have a working webhook and your first success in minutes.

What you'll build

When someone pays with Stripe, your app has to react — unlock their account, send a welcome email, or save the purchase. A webhook is Stripe calling a URL on your server when something happens (e.g. payment completed). Follow the steps below and you'll have a working webhook that receives real payment events and can unlock access or send emails.


Step 1: Create the webhook route

In your Next.js project, create the file app/api/webhook/stripe/route.ts (create the api/webhook/stripe folders if they don't exist). You need the Stripe package: run npm install stripe if you haven't already, and make sure STRIPE_SECRET_KEY is in your .env.local. The route must read the body as raw text, not as JSON — Stripe needs the exact raw body to verify the request. Paste the code below into route.ts:

app/api/webhook/stripe/route.ts
// app/api/webhook/stripe/route.ts
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2023-08-16",
});

export async function POST(req: NextRequest) {
  const body = await req.text();
  const headersList = await headers();
  const signature = headersList.get("stripe-signature");

  if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
    return NextResponse.json({ error: "Missing config" }, { status: 500 });
  }

  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 NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;
    // Get email, find or create user, set has_access = true
  }

  return NextResponse.json({ received: true });
}

Step 2: Why we verify the request

Anyone could send a fake request to your URL and pretend a payment happened. If you don't verify, you could give away access or send emails for free. Stripe signs every webhook with a secret. The constructEvent call in Step 1 checks that the body and the Stripe-Signature header match — only real Stripe events pass. Don't skip this.


Step 3: Get your webhook secret

Open the Stripe Dashboard → Webhooks (or go to dashboard.stripe.comDevelopersWebhooks). Click Add endpoint. In the form:

  • Endpoint URL: enter your URL, e.g. https://yourdomain.com/api/webhook/stripe (for local testing you'll use the CLI in Step 5).
  • Events to send: click Select events, choose checkout.session.completed, then confirm.
  • Click Add endpoint. On the next screen you'll see Signing secret — click Reveal and copy the value (it starts with whsec_).

Paste that secret into your .env.local as STRIPE_WEBHOOK_SECRET=whsec_.... Keep STRIPE_SECRET_KEY there too (from your Stripe dashboard API keys). Example:

.env.local
# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Step 4: Use the payment event (your first success)

When a payment completes, you get a Checkout Session in event.data.object (customer email, what they bought, etc.). Do this next:

  • Find the user in your database by email (or create one for guest checkout).
  • Set has_access = true (or similar) so they can use your app.
  • Optionally send a welcome or thank-you email.

The webhook runs on your server (not in the browser), so use a server-side DB connection. If Stripe retries the same event, make sure you don't create duplicate access or send the email twice.


Step 5: Test locally and see it work

Stripe can't send webhooks to localhost. Install the Stripe CLI (see stripe.com/docs/stripe-cli for your OS). Log in with the same Stripe account you use for your app. Then run:

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

The CLI will print a temporary webhook signing secret (e.g. whsec_...). Copy it, put it in .env.local as STRIPE_WEBHOOK_SECRET (replace the Dashboard secret for now). Restart your Next.js dev server so it picks up the new value. In another terminal, trigger a test event:

Terminal
stripe trigger checkout.session.completed

You should see the event in the first terminal and your route log or run your handler. First success.


Done — quick recap

You added a POST route, read the body as raw text, verified it with constructEvent, and handled checkout.session.completed to grant access (and optionally send email). Keep the webhook secret safe and always verify.


Webhook + access flow already built

In the Delfy boilerplate this flow is ready: webhook route, signature verification, session handling, and post-purchase email. You get a Magic Link redirect so users land in the dashboard right after paying.

See pricing
Stripe webhooks in Next.js — tutorial | Delfy.dev Blog