DEV Community

ArkNet Digital
ArkNet Digital

Posted on

Stripe webhooks in the Next.js App Router: signature checks, idempotency and the bugs AI assistants leave

Ask an AI coding assistant to "add Stripe subscriptions" and you will usually get a checkout button that works and a webhook route that looks fine. The checkout is the easy half. The webhook is where real money and real access get decided, and it is where generated code most often cuts corners.

The common problems are always the same few:

  • The route parses the body as JSON before verifying the signature, so verification fails, and the "fix" is to remove verification.
  • There is no protection against Stripe sending the same event twice.
  • The code assumes events arrive in order.
  • The route sits behind auth middleware, so Stripe's requests get redirected to a login page.
  • Errors are swallowed and a 200 is returned, so Stripe never retries a failed update.

This post walks through a webhook route for the Next.js App Router that handles each of these, plus a way to test it locally before you trust it.

1. The route: read the raw body, then verify

Stripe signs the exact bytes it sends. If anything parses and re-serialises the body first, the signature no longer matches. In the App Router you get the raw body with req.text(). You don't need the old bodyParser: false config from the Pages Router.

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

export const runtime = "nodejs"; // the Stripe Node SDK expects the Node runtime

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

// Server-only client. The service-role / secret key bypasses RLS,
// so it must never be imported into client code.
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function POST(req: Request) {
  const signature = req.headers.get("stripe-signature");
  if (!signature) {
    return new NextResponse("Missing signature", { status: 400 });
  }

  const body = await req.text(); // raw body, unparsed

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new NextResponse("Invalid signature", { status: 400 });
  }

  // ...idempotency and handling below
  return NextResponse.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • STRIPE_WEBHOOK_SECRET is the whsec_... signing secret for this endpoint. It is not your API key. The Stripe CLI gives you a different one from the Dashboard endpoint, and mixing them up is a common reason for "signature verification failed".
  • If verification fails, return 400 and stop. Never fall back to "trust the payload anyway".

2. Keep the webhook out of your auth middleware

If you protect routes in middleware.ts (or proxy.ts in newer Next.js versions), check that the matcher doesn't catch /api/stripe/webhook. Stripe isn't logged in. If your middleware redirects unauthenticated requests, Stripe gets a 3xx or an HTML login page and records a failure.

export const config = {
  matcher: ["/((?!api/stripe/webhook|_next/static|_next/image|favicon.ico).*)"],
};
Enter fullscreen mode Exit fullscreen mode

The signature is the authentication for this route. Nothing else is needed.

3. Handle duplicate events (idempotency)

Stripe may deliver the same event more than once, and retries happen whenever your endpoint times out or returns an error. If your handler "adds 100 credits" or "sends a welcome email", a duplicate does real damage.

The simplest guard is a table keyed on the event ID:

create table if not exists public.stripe_events (
  id text primary key,            -- Stripe event id, e.g. evt_...
  type text not null,
  received_at timestamptz not null default now()
);

alter table public.stripe_events enable row level security;
-- No policies: only the server-side service-role client touches this table.
Enter fullscreen mode Exit fullscreen mode

Then claim the event before doing any work:

const { error: claimError } = await supabase
  .from("stripe_events")
  .insert({ id: event.id, type: event.type });

if (claimError) {
  // 23505 = unique violation: we've already processed this event
  if (claimError.code === "23505") {
    return NextResponse.json({ received: true, duplicate: true });
  }
  return new NextResponse("DB error", { status: 500 });
}

try {
  await handleEvent(event);
} catch (err) {
  // Release the claim so Stripe's retry can process it again
  await supabase.from("stripe_events").delete().eq("id", event.id);
  console.error("Webhook handling failed", event.id, err);
  return new NextResponse("Handler error", { status: 500 });
}
Enter fullscreen mode Exit fullscreen mode

The release step matters. If you claim the event, crash halfway, and still keep the row, the retry will be skipped as a "duplicate" and the customer never gets access.

Where you can, also make the writes themselves idempotent. "Set this user's plan to pro" is safe to run twice. "Increment credits by 100" isn't.

4. Don't trust event order

Stripe doesn't guarantee that events arrive in the order they happened. You can receive customer.subscription.updated before checkout.session.completed, or an old update after a newer one.

A robust pattern: treat the event as a notification, then fetch the current state from Stripe and write that.

async function handleEvent(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const userId = session.client_reference_id; // set when creating the session
      if (!userId || !session.subscription) return;

      const sub = await stripe.subscriptions.retrieve(
        session.subscription as string
      );
      await upsertSubscription(userId, sub);
      return;
    }

    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const incoming = event.data.object as Stripe.Subscription;
      const sub = await stripe.subscriptions.retrieve(incoming.id); // latest state
      const userId = await findUserIdByCustomer(sub.customer as string);
      if (userId) await upsertSubscription(userId, sub);
      return;
    }

    default:
      return; // ignore events you didn't ask for
  }
}

async function upsertSubscription(userId: string, sub: Stripe.Subscription) {
  const { error } = await supabase.from("subscriptions").upsert(
    {
      user_id: userId,
      stripe_customer_id: sub.customer as string,
      stripe_subscription_id: sub.id,
      status: sub.status,
      price_id: sub.items.data[0]?.price.id ?? null,
      updated_at: new Date().toISOString(),
    },
    { onConflict: "user_id" }
  );
  if (error) throw error;
}
Enter fullscreen mode Exit fullscreen mode

Because you always write the latest state from Stripe, a late or repeated event can't roll a user back to an old status.

findUserIdByCustomer is a lookup on your subscriptions table by stripe_customer_id. Write that mapping on checkout. To link a checkout to a user, pass client_reference_id: user.id when you create the Checkout Session on the server, using the user ID from the server-side session. Never use a value the browser sends you.

5. Gate access on your database, not the redirect

Another pattern AI tools produce: the success page (/billing/success?session_id=...) flips the user to "pro". Anyone can visit that URL. Access should come only from the row your webhook wrote:

const { data } = await supabase
  .from("subscriptions")
  .select("status")
  .eq("user_id", user.id)
  .maybeSingle();

const isPro = data?.status === "active" || data?.status === "trialing";
Enter fullscreen mode Exit fullscreen mode

Your subscriptions table should have RLS on, with at most a select policy so users can read their own row. Users should never be able to insert or update it. Only the webhook (service role) writes.

6. Test it locally before you trust it

The Stripe CLI forwards real, signed test events to your machine:

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the whsec_... it prints into STRIPE_WEBHOOK_SECRET in .env.local

# in another terminal
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
Enter fullscreen mode Exit fullscreen mode

Then run the negative tests, which most people skip:

# No signature: expect 400
curl -i -X POST localhost:3000/api/stripe/webhook -d '{}'

# Forged signature: expect 400
curl -i -X POST localhost:3000/api/stripe/webhook \
  -H "stripe-signature: t=1,v1=deadbeef" -d '{"type":"checkout.session.completed"}'
Enter fullscreen mode Exit fullscreen mode

If either of those returns 200, your route is trusting unsigned input. Fix that before launch.

Finally, test a duplicate. Find a delivered event in the Stripe Dashboard and resend it, then confirm nothing changes twice in your database.

A short checklist

  • [ ] Raw body via req.text(), verified with constructEvent
  • [ ] Endpoint-specific whsec_ secret, separate for CLI and production
  • [ ] Webhook path excluded from auth middleware
  • [ ] Event-ID table with a unique key, released on failure
  • [ ] Writes are idempotent; current state fetched from Stripe
  • [ ] User linked via server-set client_reference_id
  • [ ] Access read from your DB row, not the success URL
  • [ ] subscriptions table: RLS on, users can't write it
  • [ ] Unsigned and forged requests return 400
  • [ ] Errors return 5xx so Stripe retries

If you'd rather start with this already wired

We packaged this setup, along with Supabase auth with RLS on every table and a server-side Gemini route, as a Next.js 16 starter: AI SaaS Starter Template (Next.js 16 + Supabase + Stripe + Gemini). It's for people who'd rather spend the first week on their product than on plumbing. Everything above works fine without it.

Need the whole app built for you instead? That's what we do at verdanopulence.digital/services.

Top comments (1)

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

Fetching the latest subscription from Stripe instead of trusting the payload is the right fix for out-of-order events. One edge I'd check in upsertSubscription: with onConflict on user_id there's a single row per user, but the event you retrieve is for whichever subscription id fired. If someone cancels an old subscription and starts a new one (or has a trial plus a paid one for a while), a late customer.subscription.deleted for the old id fetches that old sub, status canceled, and overwrites the row that was just set to active. "Latest state" is then latest for that subscription, not for the user.

Keying the row on stripe_subscription_id, or ignoring events whose subscription id doesn't match the one stored for the user, would close it. Might be a non-issue if your plans only allow one subscription per customer, but I'd want a test with a resend of an old deleted event after a resubscribe.