Read the body as text, once, before anything parses it, and pass that exact string to constructEvent. Stripe signs bytes, and a parsed object re-serialised is a different byte sequence even when it holds the same data. await req.text() is all the App Router needs. You do not need bodyParser: false — that was the Pages Router, and it is why most of the answers you have found do not apply.
That is the question people actually ask. The rest of this page is the four problems that arrive after it, none of which announce themselves: a comparison that leaks timing, a runtime without Node crypto, duplicate deliveries that verification cannot detect, and a handler slow enough to be recorded as a failure.
Stripe signs bytes. Parsed JSON is not those bytes.
The signature in the Stripe-Signature header is an HMAC over the exact request body as sent. Re-serialising a parsed object does not reproduce it: key order, whitespace and number formatting are all free to change, and any one of them makes the digest differ. So the rule is to read the body as text once, verify against that string, and only then parse it. In the App Router this is straightforward, which is the good news buried under a hundred Pages Router answers: req.text() gives you the raw body with no config needed. If you call req.json() first the stream is consumed and the raw bytes are gone for good.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // RAW, and only once
const sig = req.headers.get('stripe-signature');
if (!sig) return new Response('No signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body, // the string, not an object
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
// Verification failed: this is a 400, never a 500. A 5xx tells Stripe
// to retry something that will never succeed.
return new Response('Invalid signature', { status: 400 });
}
// Parse AFTER verifying. event.data.object is already typed.
switch (event.type) {
case 'checkout.session.completed':
await grantAccess(event.data.object);
break;
}
return Response.json({ received: true });
}Only relevant if you hand-roll it, and then it matters.
A plain string comparison returns as soon as two characters differ, so how long it took leaks how much of the prefix was right. That is enough to reconstruct a valid digest one byte at a time over enough requests. Stripe's own SDK does a constant-time compare for you, which is the first reason to use constructEvent rather than write your own. If you are verifying a provider that gives you no SDK, compare every byte regardless of mismatch and accumulate the difference.
// Constant-time: touches every position whatever happens.
let diff = 0;
for (let i = 0, n = Math.max(expected.length, received.length); i < n; i++) {
diff |= (expected.charCodeAt(i) || 0) ^ (received.charCodeAt(i) || 0);
}
return diff === 0;A Next.js-specific trap, and the error is unhelpful.
Most webhook tutorials reach for node:crypto and timingSafeEqual. Add export const runtime = "edge" to that route and it stops resolving, because the Edge runtime offers WebCrypto instead. WebCrypto works in both runtimes, so verification written against crypto.subtle is the portable choice and is what you want if the route might ever move. Stripe's SDK handles this too, via constructEventAsync, which you should prefer on Edge because the synchronous version wants Node crypto.
// Portable across Node and Edge: WebCrypto, available in both.
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
// With the Stripe SDK on Edge, use the async variant:
const event = await stripe.webhooks.constructEventAsync(body, sig, secret);Guaranteed, by design, and verification does nothing about it.
Stripe retries any delivery it did not get a 2xx for, and it can also deliver the same event twice without a failure. A verified signature proves the message is genuine, not that it is new. So a handler that increments a credit balance, sends a receipt or extends a period must be idempotent, and the cheapest way is to record event.id and refuse one you have already seen. Store it in the same transaction as the effect, or a crash between the two puts you back where you started.
const { rowCount } = await db
.insert(webhookEvents)
.values({ id: event.id })
.onConflictDoNothing() // unique PK on the Stripe event id
.returning();
if (rowCount === 0) return Response.json({ received: true, duplicate: true });
await grantAccess(event.data.object); // runs exactly once per eventStripe gives you about 20 seconds, then calls it a failure.
Provisioning an account, generating a PDF or calling three other APIs inside the webhook will eventually exceed the window. Stripe records a failure, retries, and your slow handler runs twice concurrently. Acknowledge first and do the work after: verify, record the event, return 2xx, and hand the rest to a queue or a background function. On a serverless platform be careful that returning the response does not kill work still in flight, which is exactly what makes this bug intermittent and horrible to reproduce.
Every item above is really one constraint on sequence. Get the order right and the rest follows:
Everything above works with nothing but the Stripe SDK, and that is the honest primary answer. For the record, this is what the template ships, including the part it does not do:
| Piece | What it does |
|---|---|
createWebhookHandler(handlers) | Reads the raw body once, verifies, parses, revalidates the cached entitlement, then calls your per-event handler. |
verifyWebhookSignature(body, sig) | HMAC-SHA256 over WebCrypto, with a constant-time compare. Works on Node and Edge. |
Returns 401 on a bad signature | Not a 500, so a forged or misconfigured delivery is not retried forever. |
Does NOT dedupe retries | Your per-event handler is called every delivery. Idempotency is still yours to write; see problem 4. |
Import the handler from sparkstripe-server.ts, which deliberately has no 'use client'. Do not re-export it through SparkStripe.tsx to tidy the import paths. That turns the handler into a client reference, and the route then compiles clean, passes CI, deploys, and throws Attempted to call POST() from the server but POST is on the client on every delivery with nothing reporting it: a customer who paid stays un-activated, one who cancelled stays paid.
Almost always because something parsed the body before verification. Stripe signs the exact bytes it sent, and a parsed-then-re-serialised object is a different byte sequence even when it is the same data. In the App Router, read the body with `await req.text()` and pass that string to `constructEvent`. If you call `req.json()` first, the stream is consumed and the raw body cannot be recovered.
No. That was the Pages Router fix, and it is why searching this problem is so confusing: most of the answers predate the App Router. A route handler receives a standard Request, so `req.text()` already gives you the unmodified body. There is no config object to set.
400. A 5xx tells Stripe the delivery failed for a transient reason and it should retry, but a bad signature will never become good, so you are buying yourself an hour of retries and a failing-webhook alert. Reserve 5xx for your own code breaking on an event that was genuinely valid.
Yes, with two adjustments. Use `constructEventAsync` rather than `constructEvent`, because the synchronous one wants Node crypto, and do not import `node:crypto` anywhere in the route. If you verify by hand, use `crypto.subtle`, which exists in both runtimes.
Record `event.id` with a unique constraint and ignore an insert that conflicts, in the same transaction as the effect it guards. Signature verification cannot help here: it proves the message is authentic, not that you have never seen it before, and Stripe retries by design.
The signing secret differs per endpoint. `stripe listen` prints its own `whsec_` for the forwarded local endpoint, which is not the one on your dashboard endpoint, so deploying with the CLI secret in the environment fails every real delivery. Check which secret the running environment actually holds before touching the code.
On its SparkPay pricing page, which is the page checkout charges from and the only place the figure is maintained. Quoting it here would mean a second number that goes stale the day a plan changes.