Most webhook handlers are tested only on the happy path: a real event arrives, verification passes, the handler runs. The path that matters — a request with a bad signature — is rarely tested at all, because producing one is inconvenient.
This produces both.
Prove the rejection works
Turn on "tamper with the body after signing" and the tool changes the payload after computing the signature — exactly what an attacker modifying a captured request would do.
Replay it with the supplied cURL command. Your endpoint must reject it.
If it accepts, you have one of the classic bugs:
- You are verifying a re-serialised body rather than the raw bytes.
- You are parsing the JSON before verifying, and acting on the parsed object.
- Verification runs but its result is not checked.
- Verification throws and the error is swallowed by a try/catch that returns 200.
The last one is more common than it should be, because webhook handlers are often written to always return 200 so the provider stops retrying.
Verification code that is correct
The generated code captures the pattern that works for the provider you selected: raw body, timestamp check for schemes that include one, constant-time comparison. It is the reference implementation, not a sketch.
For Stripe it parses the compound t=…,v1=… header, checks the timestamp against a 300-second window, and reconstructs timestamp.body before hashing. Skipping the timestamp check is the difference between replay protection and none.
Idempotency
Providers retry on timeout, on non-2xx, and sometimes for no visible reason. Your handler will receive duplicates. Key on the event ID and make processing idempotent — otherwise a retry storm becomes duplicate charges, duplicate emails or duplicate fulfilments.
Return 2xx quickly and process asynchronously. A handler that does thirty seconds of work before responding guarantees timeouts, which guarantees retries.
Local testing
The cURL command targets localhost:3000 by default so you can replay against a development server without a tunnel. For events genuinely originating at the provider, use stripe listen --forward-to or GitHub's redelivery button — but for testing your verification logic specifically, a locally generated signed request is faster and lets you control the failure case.
Frequently asked questions
Are these real Stripe events?
The structure is realistic and the signature is genuinely computed with the secret you supply, so your verification code will accept it exactly as it would a real event. The IDs are random and no Stripe API was involved.
My endpoint accepts the tampered request. What is wrong?
You are almost certainly verifying against a re-serialised body. Find where your framework parses the JSON and capture the raw bytes before that happens — the webhook signature verifier has framework-specific snippets.
Should I verify in middleware or in the handler?
Middleware, so it cannot be forgotten on a new endpoint. Make sure the middleware runs before body parsing, which usually means registering it on the webhook path specifically.