28 Oct 2026 · Auth & Stripe
Stripe Webhooks in Next.js 15 App Router, Raw Body and Idempotency
Why stripe.webhooks.constructEvent fails in the App Router, how to read the raw body, and how to store event ids so retries do not double-credit.
Stripe signs the exact bytes it sends. If Next.js parses the body to JSON before you verify, constructEvent throws and Checkout looks like it succeeded while your database never moved.
The MIT starter is the place to read a working Route Handler next to this post: Agentic-Packs/agenticpacks-oss. AgenticPacks OSS is getting a single-plan Stripe checkout, webhooks, and a credit counter. Pro is 3-tier subscriptions, the customer portal, proration, and team pools. See pricing.
Why App Router breaks the signature
Pages Router had export const config = { api: { bodyParser: false } }. App Router Route Handlers always give you a Web Request. If you await request.json(), the bytes are gone. Stripe compares HMAC against the original string, including whitespace.
Symptoms:
Webhook Error: No signatures found matching the expected signature- HTTP 400 from your route, 200 from Stripe after retries fail
- Checkout completed, user still on the free plan
middleware.ts that consumes the body will do the same damage. Keep /api/webhooks/stripe out of any middleware that reads the request.
Read text, verify, then parse
import Stripe from "stripe";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const raw = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "missing signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return NextResponse.json({ error: "invalid signature" }, { status: 400 });
}
// persist event.id first, then apply side effects
return NextResponse.json({ received: true });
}
runtime = "nodejs" matters. Edge has a different crypto path and a body size limit you do not want for webhooks.
Do not JSON.parse until after constructEvent succeeds. The verified event object is what you switch on.
ACK fast, work later
Stripe retries on slow 5xx. If you run LangGraph or send email inside the webhook, you will double-charge credits when the first attempt times out and the second succeeds.
- Verify signature.
- Insert
event.idinto a uniqueStripeEventtable. On unique violation, return 200 and stop. - Apply the entitlement (credits, plan, seat).
- Return 200.
Agent work happens after that, from your own job or from the user hitting the app. The self-hosting cost post is blunt about this: the webhook must be fast.
Idempotency is a table, not a hope
model StripeEvent {
id String @id // Stripe event.id
type String
createdAt DateTime @default(now())
}
try {
await prisma.stripeEvent.create({
data: { id: event.id, type: event.type },
});
} catch (error) {
if (isUniqueViolation(error)) {
return NextResponse.json({ received: true });
}
throw error;
}
checkout.session.completed and invoice.paid can both fire for the same subscription. Key side effects on customer + period, not only on event.id, or you will grant two credit packs.
Env and Docker
STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are runtime secrets. They are not NEXT_PUBLIC_* and they are not Docker ARG. That split is in the Docker env guide. Rotate the webhook secret in the Stripe dashboard and in Compose. Rebuild is not required.
Locally, stripe listen --forward-to localhost:3000/api/webhooks/stripe prints a whsec_... that is not the live secret. Do not commit it.
Tired of a 400 on every checkout.session.completed?
AgenticPacks OSS will ship the single-plan checkout, raw-body Route Handler, and credit counter: Agentic-Packs/agenticpacks-oss.
For 3-tier Stripe, proration, and team credit pools already wired, see 3-tier billing and team pools in Pro.
What to handle first
| Event | What you write |
|---|---|
checkout.session.completed | Attach stripeCustomerId, set plan, grant credits |
invoice.paid | Renew period credits |
customer.subscription.updated | Plan change and proration |
customer.subscription.deleted | Drop to free, keep data |
Return 200 only after the row is committed. If Prisma is down, return 500 and let Stripe retry.
Where to Go From Here
You do not have to keep pasting constructEvent into a blank App Router.
Option A: Free open-source baseline (MIT)
- GitHub: github.com/Agentic-Packs/agenticpacks-oss
- What is included: Next.js 15, Compose, PostgreSQL 16, Auth.js v5, single-plan ($19/mo) Stripe checkout with webhooks and a credit counter. No Caddy.
- Cost: free, MIT.
Option B: Ship with AgenticPacks Pro ($149 one-time)
If billing is 3-tier, seats, and token ledgers:
- Automated Hetzner provisioning: Terraform, Caddy SSL, Compose. Secrets stay runtime.
- Production multi-agent core: LangGraph, pgvector RAG, Redis.
- Usage & Unit Economics: Stripe subscriptions, portal upgrades, proration, team pools, token-to-dollar ledger.
