THE FULL GUIDE / EP 04 · ASTER × CLAUDE
Anyone can fake a Stripe “payment succeeded”. Verify your webhook
Your webhook is a doorman who signs for any package from anyone in a delivery vest. A URL isn’t a password, and “payment succeeded” is just JSON anyone can type. Here’s how to make the doorman check signatures, why the check “kept failing”, and the three moves that close the hole.

Commented HOOK? Here’s everything we promised: the checklist, the code, and why each step matters.
Jump to a story
- The checklist
- 01What a webhook is, and why anyone can POST to it
- 02How a fake “payment succeeded” gets someone Pro
- 03Is this rare? One scan says no
- 04Verify the signature with constructEvent
- 05The raw-body trap (and the exact code per framework)
- 06“No signatures found matching the expected signature for payload”
- 07Handle each event once, and only fulfil verified, paid events
- 08Test it with the Stripe CLI before you ship
- 09“Make the error go away” is not a security fix
- FAQ
- Source notebook
What a webhook is, and why anyone can POST to it

When someone pays you through Stripe Checkout, Stripe tells your server about it by sending an HTTPS POST with a JSON event to a URL you registered, your webhook endpoint. A typical one looks like https://your-app.com/api/webhook. Your code reads the event, sees checkout.session.completed, and gives the customer what they paid for.
Stripe’s own docs say webhooks are how you should fulfil orders: the customer’s browser might never make it back to your success page, and subscriptions and delayed payment methods change state long after checkout.
The catch: the door is open to the whole street
Stripe requires your endpoint to be a publicly accessible HTTPS URL. That’s the point: Stripe’s servers have to reach it. But so can everyone else. The endpoint has no idea who’s knocking unless it checks.
- A URL isn’t a password. Paths like
/api/webhook,/api/stripe/webhookand/webhooks/stripeare what every tutorial and AI assistant uses, so they’re easy to guess. They also show up in public repos, logs and browser bundles. - The payload is just JSON. Anyone can type
{"type":"checkout.session.completed", …}. Event IDs, session IDs and amounts in the body prove nothing on their own. - Stripe gives you the proof. Every event Stripe sends carries a signature in the
Stripe-Signatureheader, made with a secret only you and Stripe know. That header is the doorman’s ID check.
THE BUILDER’S TAKEAWAYYour webhook is the one door in your app that grants paid access. It has to check ID like it means it.
How a fake “payment succeeded” gets someone Pro

This is how it happens. There’s no famous named heist here, because a forged webhook doesn’t look like an attack in your logs. It looks like a sale. Here is the kind of handler the episode is about, next to the fixed version:
// app/api/webhook/route.js
export async function POST(req) {
const event = await req.json(); // whatever anyone sent
// It kept saying "No signatures found matching
// the expected signature", so…
// const sig = req.headers.get('stripe-signature');
// event = stripe.webhooks.constructEvent(body, sig, secret);
if (event.type === 'checkout.session.completed') {
const email = event.data.object.customer_email;
await db.users.update({ email }, { plan: 'pro' });
}
return Response.json({ received: true });
}// app/api/webhook/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const secret = process.env.STRIPE_WEBHOOK_SECRET!; // whsec_…
export async function POST(req: Request) {
const body = await req.text(); // raw, untouched
const sig = req.headers.get('stripe-signature') ?? '';
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, secret);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// Only now is this really Stripe talking.
await handle(event);
return Response.json({ received: true });
}On the left, the handler trusts whatever arrives. An attacker who knows (or guesses) the URL sends one request with their own email in customer_email, and the database marks them Pro. Then their cousin does it. Then a guy who wrote STRIPE on his shirt. No card, no Stripe, no record in your Stripe Dashboard, just a new “customer” in your app.
Test your own endpoint in ten seconds
Send your own endpoint a forged event with no signature. This is the same kind of request the attacker would send, and it’s the fastest way to know where you stand:
# Send YOUR OWN endpoint a forged event, with no Stripe-Signature header.
curl -i -X POST https://your-app.com/api/webhook \
-H "Content-Type: application/json" \
-d '{"id":"evt_fake_1","object":"event","type":"checkout.session.completed",
"data":{"object":{"id":"cs_fake_1","object":"checkout.session",
"payment_status":"paid","customer_email":"you+test@example.com"}}}'
# Verified handler: HTTP/1.1 400 (and nobody gets Pro)
# Unverified handler: HTTP/1.1 200 (check your database. Did you just upgrade a stranger?)Only test endpoints you own. Use a throwaway test email so you can find and undo any upgrade it causes.
// ❌ Every one of these can be typed by the user.
if (localStorage.getItem('isPaid') === 'true') showProFeatures();
if (new URLSearchParams(location.search).get('paid') === '1') unlockPro();
await fetch('/api/upgrade', { method: 'POST', body: JSON.stringify({ plan: 'pro' }) });
// ✅ The server decides, from its own database, which only a verified webhook writes to.
const { plan } = await fetch('/api/me').then((r) => r.json());THE BUILDER’S TAKEAWAYIf a request can grant Pro, assume someone other than Stripe will send it.
Is this rare? One scan says no

In May 2026, the security vendor securityscanner.dev reported that it probed about 6,000 web apps and found 1,542 that accepted forged Stripe webhook events. According to the vendor, it sent minimal fake checkout.session.completed events, with no Stripe-Signature header, to 17 common webhook paths per host, and counted any 200, 201 or 202 reply as accepted.
- Treat it as a vendor claim. It’s one company’s scan, not an independent study, and a
2xxreply doesn’t prove an app actually granted anything. It does prove the handler didn’t reject an unsigned request, which a verified handler always does. - The pattern they describe is the one in the episode. Signature verification sits on a TODO list, or got commented out while fighting the error, and the handler ships without it. The vendor also says code generators often produce handlers without the check unless you ask.
- It’s spread across hosts. The vendor counted about 720 on custom domains, 198 on Render and 142 on Vercel, among others.
The exact number matters less than the shape: it takes one unauthenticated request to find out, so assume someone will try yours.
THE BUILDER’S TAKEAWAYWebhook paths are predictable and the test is one request. Scanners do it at scale.
Verify the signature with constructEvent

Stripe signs every event it sends. The official libraries check it for you in one call. In Node, that’s stripe.webhooks.constructEvent() with three things:
| Argument | What it is | Where it comes from |
|---|---|---|
payload | The raw request body, a string or Buffer, byte for byte | Your framework (see the next section) |
header | The value of the Stripe-Signature request header | req.headers.get('stripe-signature') |
secret | Your endpoint’s signing secret, starting with whsec_ | Workbench → Webhooks → your endpoint → reveal, or the output of stripe listen |
If the signature matches and the timestamp is recent, it returns the parsed Stripe.Event. If anything is wrong, it throws a StripeSignatureVerificationError. Catch it, return 400, and do nothing else.
What the signature actually is
The header looks like t=1492774577,v1=5257a8…,v0=6ffbb5… (on one line). Stripe computes an HMAC with SHA-256, using your endpoint secret as the key, over the string timestamp + "." + raw body. constructEvent recomputes it and compares. An attacker without your whsec_ can’t produce a matching v1, and changing even one byte of the body or the timestamp breaks it.
- Only
v1counts. Stripe sends an extra fakev0signature on test events; ignore every scheme exceptv1to prevent downgrade attacks. The library already does. - Don’t hand-roll it unless you must. If you do, use a constant-time comparison and check the timestamp yourself. Stripe documents the manual steps, but recommends the official libraries.
whsec_is notsk_. The signing secret verifies incoming events. Your secret API key calls Stripe. Both stay on the server, and neither goes in client code.
THE BUILDER’S TAKEAWAYOne function call is the difference between a doorman and an open door.
The raw-body trap (and the exact code per framework)

This is why the check “kept failing” in the episode. The signature covers the exact bytes Stripe sent. Most frameworks helpfully parse JSON before your code runs. If you then hand constructEvent the parsed object, or JSON.stringify it back into a string, the bytes are different (whitespace, key order, escaping, number formatting) and verification fails, even though the data “looks” the same.
Stripe’s docs: any manipulation to the raw body of the request causes the verification to fail. So the rule is: read the raw body first, verify, then use event.data.object, which the library has already parsed for you.
Next.js App Router
// app/api/webhook/route.ts (Next.js App Router)
import Stripe from 'stripe';
export const runtime = 'nodejs'; // the default; on 'edge', use constructEventAsync
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
// Read the body ONCE, as text. Don't call req.json() before this.
const body = await req.text();
const sig = req.headers.get('stripe-signature') ?? '';
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
console.warn('Stripe webhook rejected:', (err as Error).message);
return new Response('Invalid signature', { status: 400 });
}
// event.data.object is already parsed for you. No JSON.parse needed.
return Response.json({ received: true });
}A request body can only be read once. If anything calls req.json() before req.text(), the raw body is gone. App Router route handlers don’t parse the body for you, so there’s no bodyParser flag to turn off here.
Next.js Pages Router
// pages/api/webhook.ts (Next.js Pages Router)
import type { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';
// Turn off Next's body parser for THIS route, so the raw bytes survive.
export const config = { api: { bodyParser: false } };
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function rawBody(req: NextApiRequest): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
return Buffer.concat(chunks);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).end('Method Not Allowed');
}
const body = await rawBody(req);
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
}
// …handle event…
res.status(200).json({ received: true });
}Without bodyParser: false, req.body is already an object and the signature can’t match.
Express
// server.js (Express)
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// 1. The webhook route comes FIRST, with a raw parser on this route only.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
// req.body is a Buffer here, exactly the bytes Stripe signed.
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// …handle event…
res.json({ received: true });
});
// 2. JSON parsing for every OTHER route goes AFTER the webhook route.
app.use(express.json());
app.post('/api/other', (req, res) => { /* … */ });
app.listen(3000);Order matters. If app.use(express.json()) runs before the webhook route, it parses the body first and verification fails. Stripe’s own Express example uses express.raw({type: 'application/json'}) on the route.
Hono, Cloudflare Workers, Deno, Bun and edge runtimes
// src/index.ts (Hono on Cloudflare Workers, Bun or Deno)
import { Hono } from 'hono';
import Stripe from 'stripe';
type Env = { STRIPE_SECRET_KEY: string; STRIPE_WEBHOOK_SECRET: string };
const app = new Hono<{ Bindings: Env }>();
app.post('/webhook', async (c) => {
const stripe = new Stripe(c.env.STRIPE_SECRET_KEY);
const body = await c.req.text(); // raw body
const sig = c.req.header('stripe-signature') ?? '';
try {
// Web Crypto is async, so use the async variant outside Node.
const event = await stripe.webhooks.constructEventAsync(
body, sig, c.env.STRIPE_WEBHOOK_SECRET,
undefined, Stripe.createSubtleCryptoProvider(),
);
// …handle event…
return c.json({ received: true });
} catch (err) {
return c.text(`Webhook Error: ${(err as Error).message}`, 400);
}
});
export default app;Outside Node’s crypto, verification goes through Web Crypto, which is async. Use constructEventAsync. The same applies to a Next.js route with runtime = 'edge'.
THE BUILDER’S TAKEAWAYVerify the bytes Stripe sent, not your framework’s opinion of them.
“No signatures found matching the expected signature for payload”
This is the message that got the check commented out. Stripe’s troubleshooting page puts it simply: if you see it, at least one of the three arguments you passed to constructEvent() is wrong. Here’s every message stripe-node can throw, and what each one means:
| Error message | Usual cause | Fix |
|---|---|---|
No signatures found matching the expected signature for payload. | The body was changed before verifying (parsed and re-stringified, middleware, a proxy), or the secret is the wrong one | Raw body; the whsec_ for this exact endpoint and mode |
Webhook payload must be provided as a string or a Buffer … Payload was provided as a parsed JavaScript object instead. | You passed req.body after express.json(), or the result of req.json() | Use express.raw() or await req.text() |
No stripe-signature header value was provided. | The request didn’t come from Stripe (a forged request, a curl test), or a proxy dropped the header | For fakes, this is the check working. Otherwise forward the header |
Unable to extract timestamp and signatures from header | You passed the wrong header, or it was mangled | Pass stripe-signature exactly as received |
Timestamp outside the tolerance zone | The signed timestamp is older than the tolerance (5 minutes by default): a replay, a delayed relay, or your server clock is off | Sync the clock (NTP). Don’t raise the tolerance to hide it |
The wrong-secret checklist
Stripe calls the wrong endpoint secret the most common error. Every one of these is a different whsec_:
- Stripe CLI vs Dashboard.
stripe listenprints its own secret (it stays the same between restarts). A Dashboard or API endpoint has a different one. Don’t verify CLI-forwarded events with the Dashboard secret, or the other way around. - Sandbox vs live. If the same URL is registered for test and live, Stripe says the secret is different for each.
- Each endpoint. Several endpoints means several secrets. Use the one for the endpoint that’s calling you.
- Whitespace. A newline or space pasted into the env var breaks the match. Newer stripe-node versions add a note to the error when the secret contains whitespace.
- Rolled secrets. After Roll secret in Workbench, the old one keeps working for up to 24 hours if you chose a delay, then stops. Update your env var in that window.
Timestamp tolerance and replays
A replay attack resends a real, validly signed event that someone captured. Because the timestamp is inside the signed payload, it can’t be changed without breaking the signature, so constructEvent rejects events older than its tolerance: 300 seconds by default in stripe-node. Stripe generates a fresh timestamp and signature for every delivery attempt, so its own retries still pass.
THE BUILDER’S TAKEAWAYThe error isn’t the bug. It’s the doorman telling you the package was opened.
Handle each event once, and only fulfil verified, paid events

A verified event is real, but it can still arrive twice, out of order, or for a payment that hasn’t cleared. Stripe’s docs say webhook endpoints might occasionally receive the same event more than once, that delivery order isn’t guaranteed, and that in live mode failed deliveries are retried for up to three days with exponential back-off (three times over a few hours in a sandbox).
1. Record every event ID under a unique key
-- One row per Stripe event you have handled.
create table stripe_events (
id text primary key, -- evt_…
type text not null,
processed_at timestamptz not null default now()
);
-- And one fulfilment per Checkout Session, even if two events point at it.
alter table purchases add constraint purchases_session_unique unique (checkout_session_id);Stripe also notes that in some cases two separate Event objects are generated for the same thing. That’s why fulfilment gets its own unique key on the object, here the Checkout Session ID, not just the event ID.
2. Fulfil from the API, in one transaction
// lib/stripe-webhook.ts (postgres.js shown; any database with a unique key works)
import Stripe from 'stripe';
import sql from './db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function handle(event: Stripe.Event) {
// Only the events you subscribed to. Ignore the rest.
if (event.type !== 'checkout.session.completed' &&
event.type !== 'checkout.session.async_payment_succeeded') return;
// Re-fetch from Stripe: the API is the source of truth, not the payload.
const session = await stripe.checkout.sessions.retrieve(
event.data.object.id, { expand: ['line_items'] },
);
if (session.payment_status === 'unpaid') return; // delayed method: wait for async_payment_succeeded
// Who paid? The user ID YOU put on the session when you created it, server-side.
const userId = session.client_reference_id;
if (!userId) throw new Error(`No client_reference_id on ${session.id}`);
await sql.begin(async (tx) => {
// Seen this event before? Then do nothing.
const fresh = await tx`
insert into stripe_events (id, type) values (${event.id}, ${event.type})
on conflict (id) do nothing returning id`;
if (fresh.length === 0) return;
// Same transaction: the event is marked done only if the upgrade happens.
await tx`
insert into purchases (checkout_session_id, user_id)
values (${session.id}, ${userId})
on conflict (checkout_session_id) do nothing`;
await tx`update users set plan = 'pro' where id = ${userId}`;
});
}If the upgrade fails, the transaction rolls back, the event isn’t marked as done, your handler returns an error, and Stripe’s retry gets another chance.
Three details carry the security here:
- Re-fetch the object. Stripe’s fulfilment guide retrieves the Checkout Session from the API (with
line_itemsexpanded) and checkspayment_statusbefore fulfilling. It’s also a second, independent proof the session is real. - Check
payment_status.unpaidmeans don’t fulfil yet. Delayed methods like bank debits sendcheckout.session.async_payment_succeededlater. - Identify the user from your own data. Set
client_reference_id(or metadata) when your server creates the session, from your auth session. Don’t look users up by an email someone typed.
// When you create the Checkout Session (server-side), attach YOUR user ID.
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: process.env.PRO_PRICE_ID!, quantity: 1 }],
client_reference_id: user.id, // from your auth session, not from the request body
success_url: 'https://your-app.com/welcome?session_id={CHECKOUT_SESSION_ID}',
});3. Listen only to the events you need
| Event | When it arrives | What to do |
|---|---|---|
checkout.session.completed | Customer finished Checkout | Retrieve the session; fulfil if payment_status isn’t unpaid |
checkout.session.async_payment_succeeded | A delayed payment (e.g. bank debit) cleared later | Fulfil now |
checkout.session.async_payment_failed | That delayed payment failed | Don’t grant access; tell the customer |
invoice.paid | A subscription invoice was paid (first or renewal) | Retrieve the subscription, confirm status is active, extend access |
invoice.payment_failed | A renewal payment failed | Notify the customer; Stripe’s retry settings decide what happens next |
customer.subscription.updated | Plan change, renewal, trial end, status change | Sync plan and status from the object |
customer.subscription.deleted | The subscription ended | Remove Pro |
charge.refunded / charge.dispute.created | Money went back or is contested | Review or restrict access |
4. Return 2xx fast, do the slow work after
Stripe says your endpoint must quickly return a 2xx before any complex logic that could cause a timeout, and recommends processing events with an asynchronous queue so a burst (like the first of the month, when subscriptions renew) can’t overwhelm you. Verify, record the event, enqueue the heavy work (emails, provisioning, analytics), and respond. With a success_url set, Checkout also waits up to 10 seconds for your response to checkout.session.completed before redirecting the customer, so a slow handler shows up as a slow checkout.
THE BUILDER’S TAKEAWAYVerification proves who sent it. Idempotency and re-fetching prove it should change anything.
Test it with the Stripe CLI before you ship
You don’t need a public URL to test signed events. The Stripe CLI forwards real, signed events from your sandbox to localhost:
# 1. Log in once
stripe login
# 2. Forward events to your local server (keep this running)
stripe listen --forward-to localhost:3000/api/webhook
# > Ready! Your webhook signing secret is whsec_… ← put THIS in .env.local
# 3. In another terminal, fire a real, signed test event
stripe trigger checkout.session.completed
# 4. Replay a specific event to a registered endpoint (duplicate test)
stripe events resend evt_123 --webhook-endpoint=we_123stripe trigger creates real sandbox objects to produce the event, so you may see related events (like payment_intent.created) too. Use --events on stripe listen to filter.
- Run your app on
localhost:3000, thenstripe listen --forward-to localhost:3000/api/webhookand copy thewhsec_it prints into your local env. - Run
stripe trigger checkout.session.completed. Thestripe listenterminal should show the event and your handler’s200. - Send the forged curl request from the attack section to localhost. You should get
400, and nothing in your database should change. - Trigger the same event twice, or use
stripe events resend, and confirm the user is upgraded exactly once. - Deploy, register the production endpoint in Workbench with only the events you need, set that endpoint’s own
whsec_in production, and run the forged curl against production once.
Unit-test the check itself
stripe-node can sign a payload for you with a test secret, so you can prove in CI that good signatures pass and tampered or missing ones fail:
// webhook.test.ts (sign a fake payload with a test secret, no network needed)
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_dummy');
const secret = 'whsec_test_secret';
const payload = JSON.stringify({ id: 'evt_test_webhook', object: 'event' }, null, 2);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
const event = stripe.webhooks.constructEvent(payload, header, secret); // passes
expect(event.id).toBe('evt_test_webhook');
// Change ONE byte of the body and it must throw:
expect(() => stripe.webhooks.constructEvent(payload + ' ', header, secret)).toThrow();
// And the forged request from the attack section, with no header at all:
expect(() => stripe.webhooks.constructEvent(payload, '', secret)).toThrow();THE BUILDER’S TAKEAWAYA test that proves a forged event gets a 400 is worth more than a handler that looks right.
“Make the error go away” is not a security fix

In the episode, Claude points out that it wrote the webhook after being asked to “make the error go away”, and it’s very literal. That’s the joke, but the pattern is real: the signature error is loud, the fix looks like plumbing, and deleting one line makes everything work. For everyone.
The three moves are small: verify with constructEvent and the raw body, handle each event ID once, and only grant Pro for verified, paid events you re-fetched. Our other guides cover the same kind of gap elsewhere: Supabase Row Level Security, keeping API keys out of the browser and protecting a paid AI endpoint.
THE BUILDER’S TAKEAWAYTutorials skip it because it’s boring. Attackers love boring.
QUESTIONS PEOPLE ASK
FAQ
What does “No signatures found matching the expected signature for payload” mean?
stripe-node computed the signature for the body and secret you passed and it didn’t match the Stripe-Signature header. Almost always the body was changed before verifying (parsed to JSON and re-stringified, a body-parser middleware, a proxy) or you used the wrong whsec_ secret (CLI vs Dashboard, sandbox vs live, another endpoint’s). Pass the raw body and the secret for that exact endpoint. Don’t remove the check.
Why does my Stripe webhook work locally but fail in production?
Locally you’re probably verifying with the secret printed by stripe listen. A production endpoint registered in Workbench has its own whsec_, and sandbox and live secrets differ too. Also check that production doesn’t add a global JSON parser, proxy or edge runtime that the local setup didn’t have.
How do I get the raw body for Stripe in the Next.js App Router?
Call await req.text() in the route handler and pass that string to stripe.webhooks.constructEvent(body, req.headers.get('stripe-signature'), secret). Don’t call req.json() first, because a body can only be read once. On the edge runtime, use constructEventAsync. In the Pages Router, export config = { api: { bodyParser: false } } and read the raw buffer.
Why does Express break Stripe signature verification?
Because express.json() parses the body before your handler sees it. Register the webhook route first with express.raw({ type: 'application/json' }), and add app.use(express.json()) after it for your other routes.
Isn’t a secret webhook URL enough?
No. A URL isn’t a password: common paths are easy to guess, and URLs leak through repos, logs and screenshots. Stripe requires the endpoint to be publicly reachable and tells you to verify the Stripe-Signature header on every event before acting on it.
Does Stripe send the same webhook event more than once?
It can. Stripe says endpoints might occasionally receive the same event more than once, retries failed deliveries for up to three days in live mode, and doesn’t guarantee order. Store each event.id under a unique key and skip repeats, and make fulfilment unique per object (for example the Checkout Session ID), because Stripe notes that sometimes two separate events are generated.
What is the default timestamp tolerance in constructEvent?
300 seconds (5 minutes) in stripe-node. Events signed longer ago than that throw “Timestamp outside the tolerance zone”, which protects against replayed events. Stripe re-signs every retry with a fresh timestamp. Keep your server clock in sync and don’t set the tolerance to 0, which Stripe says disables the recency check.
Can I grant Pro from the Checkout success page instead of a webhook?
Not on its own. A success URL or ?paid=1 can be opened by anyone, and Stripe says you can’t rely on the landing page because customers don’t always reach it. You can run the same fulfil function from the landing page with the session_id, as long as it re-fetches the session from Stripe, checks payment_status, and is idempotent. Webhooks stay required.
THE BIGGER PICTURE
The takeaway for builders.
An unverified Stripe webhook doesn’t look broken. Payments work, upgrades work, and anyone who sends the right JSON gets the same upgrade for free. Make the doorman check signatures: verify with `constructEvent` on the raw body, handle each event once, and only grant access for paid sessions you fetched from Stripe yourself. Then send yourself a forged event and watch it bounce. A free Aster repository audit can take a second look at the rest of your code.
The source notebook 11 links
Go a little deeper. These are the sources linked in this story.
- 01Stripe Docs: receive Stripe events in your webhook endpointdocs.stripe.com
- 02Stripe Docs: verify events are sent from Stripedocs.stripe.com
- 03Stripe Docs: fulfill orders (Checkout)docs.stripe.com
- 04securityscanner.dev (vendor claim): “We probed 6,000 web apps for Stripe webhook signature checks. 1,542 don’t bother.” (May 2026)securityscanner.dev
- 05stripe-node README: webhook signinggithub.com
- 06Stripe Docs: resolve webhook signature verification errorsdocs.stripe.com
- 07stripe-node: webhook-signing examples (Express, Next.js App and Pages Router, Deno)github.com
- 08Stripe CLI reference: stripe listendocs.stripe.com
- 09Stripe Docs: webhook best practices, handle duplicate eventsdocs.stripe.com
- 10Stripe Docs: using webhooks with subscriptionsdocs.stripe.com
- 11Stripe CLI reference: stripe triggerdocs.stripe.com
PUT THE CONTEXT TO WORK
A second look.
Before you ship.
Connect your GitHub repository and see what needs attention. Your first audit is free.
Start my free audit

