Your prototype works. That is not the same as safe to sell.
A webhook endpoint is a public URL. Anyone on the internet can POST to it. If your handler reads the JSON body and grants access, credits, or a subscription based on what it says, then anyone who guesses the URL can hand themselves a paid account by sending a fake "payment succeeded" event.
The launch check is blunt:
A request with a missing or wrong signature must be rejected before it changes any state.
A signature is not a password in the body
The fix is not a secret field inside the JSON. Payment providers sign the request: they compute a keyed hash (HMAC) of the exact bytes they sent, using a secret only you and they know, and put the result in a header. Your job is to recompute that hash over the raw body you received and compare.
Two details decide whether this actually works, and both are easy to get wrong.
Verify the raw body, not the parsed object
The signature is over the exact bytes the provider sent. If your framework parses the JSON first and you re-serialize it to check the signature, a single reordered key or whitespace difference makes the recomputed hash wrong — so you either reject valid events or, worse, "fix" it by trusting the parsed object instead. In the ShipTested example the handler verifies the unmodified raw string first, and only then parses it:
const expected = hmacSha256Hex(rawBody, secret);
if (!timingSafeEqual(expected, receivedSignature)) {
return { status: 401 }; // reject before any parse or state change
}
const event = JSON.parse(rawBody); // only trusted after the signature holds
In Next.js this means reading the raw request body in the route handler (not req.json() on a framework that has already consumed and reserialized it). Lemon Squeezy sends the digest in the X-Signature header as an HMAC-SHA256 hex string; Stripe sends it in Stripe-Signature and signs with your endpoint secret. The provider differs; the invariant does not.
Compare in constant time
a === b on two strings can return early on the first differing character. In principle that timing difference leaks how much of the signature you guessed right, one character at a time. Use a constant-time comparison (crypto.timingSafeEqual in Node) so every comparison takes the same time regardless of where the mismatch is. It costs nothing and closes the door.
Reproduce it with a test
The public example proves the negative case directly. Its launch suite includes:
✓ invalid signature is rejected before state changes
The test sends a well-formed order payload with a bogus signature and asserts two things: the handler returns a rejection, and the entitlement store is still empty. A handler that parses first and checks later would fail the second assertion — the damage is already done by the time it looks at the signature.
Run it against a clean clone:
npm test
Then send yourself a hostile request in staging: take a real test-mode event body, change one byte, keep the old signature, and POST it. A correct handler rejects it and writes nothing.
A valid signature is still not enough
Verifying the signature proves the message is authentic — that it came from the provider and was not modified. It does not prove the message is one you should act on. After the signature holds, a safe handler still checks the event type, the paid status, the store and product or variant, the test versus live mode, and that the order maps to a real internal user — and it makes repeated deliveries grant access only once. Signature verification is the first gate, not the whole wall. (Idempotency is its own launch check; a single event can be replayed many times and each replay carries a perfectly valid signature.)
What this proves — and does not
The example is a dependency-free teaching harness. It proves that a specific handler rejects an unsigned or altered body before mutating state, and that raw-body verification plus a constant-time compare is the shape of the fix. It is not a drop-in for your framework, it does not configure your provider's secret rotation, and it is not a security audit. Wire the raw-body reader your framework actually needs, keep the secret in server-only configuration, and rotate it if it ever leaks.
Your prototype accepting a webhook is the start. Rejecting a forged one, before it touches your database, is evidence you can stand behind when you sell.
Inspect the signed-webhook example
Run the broader launch-readiness checklist
Sources: Lemon Squeezy signing requests and Stripe webhook signatures.
Test this on your own app before you sell it.