Webhooks are how the services you depend on tell you something happened. A payment succeeded. A subscription lapsed. A file finished processing. You give the provider a URL, they POST to it, your application updates.
The awkward property is that the URL has to be publicly reachable, because the provider is on the internet. Which means anyone else on the internet can POST to it too. If your handler trusts what it receives, it will act on whatever it is told.
What an unverified endpoint allows
Take a normal payments integration. Stripe POSTs a
payment_intent.succeeded event and your code marks the order paid
and grants access.
Without verification, anyone who knows or guesses your endpoint can send the same shape of JSON. Your application has no way to distinguish it. It marks the order paid, because that is what it was told.
The same pattern applies wherever a webhook drives a decision:
subscription.created grants a plan, invoice.paid
extends a term, user.verified confirms an identity. Each is a
free upgrade for anyone willing to send a POST.
Endpoint URLs are not secret either. They appear in provider dashboards, in
error monitoring, in logs, in screenshots shared in support threads, and often
in patterns predictable enough to guess: /webhooks/stripe is a
reasonable first attempt on most sites.
How the signature works
When you create a webhook endpoint, the provider gives you a signing secret. It is not the same as your API key, and it exists only for this.
On each request the provider computes an HMAC over the request body, using that secret, and sends the result in a header along with a timestamp. You recompute the same value with the same secret and compare.
If they match, two things are true: the sender holds the signing secret, and the body has not been altered. If they do not, you reject the request without looking at what it says.
Implementing it
Every major provider ships a helper that does the comparison correctly, and you should use it rather than writing your own. In Node with Stripe:
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }), // raw body, not parsed
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // the exact bytes received
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send('invalid signature');
}
// only now is it safe to act on event
handle(event);
res.json({ received: true });
});
Three details matter more than they look.
The raw body. Signature verification is over exact bytes. If a body-parsing middleware runs first and you pass it the re-serialised object, verification fails on genuine requests because the bytes differ. This is the most common reason a correct implementation appears broken, and the fix is to exempt the webhook route from body parsing.
Reject before doing anything. The verification must happen before any handling. Logging the payload first is usually fine; writing to the database first is not.
Constant-time comparison. The provider's helper does this.
Comparing strings with == can leak information through how long
the comparison takes, which is exactly the sort of detail worth delegating to
a library.
Replay, and the timestamp
A valid signed request stays valid. Anyone who captures one, from a log, a proxy, an error report, can send it again and it will verify, because nothing about it has changed.
Providers include a timestamp in the signature header for this reason, and their helpers usually reject anything outside a tolerance window by default, commonly five minutes. Do not widen that window to make a flaky test pass.
Idempotency handles the rest. Each event carries an identifier; recording which you have processed and ignoring repeats protects you from both replays and the provider's own legitimate retries. Providers do retry, deliberately, and a handler that is not idempotent will double-count perfectly honest deliveries.
Beyond the big providers
Most established services sign their webhooks. Smaller ones sometimes do not, and then you are choosing between weaker options:
- A shared secret in the URL or a custom header. Weak, because it travels in logs and proxies, and better than nothing.
- Source address allowlisting, if the provider publishes stable addresses. Workable, brittle when they change them, and no help if the provider is behind a shared cloud range.
- Treating the webhook as a hint rather than a fact. This is the strongest option available when there is no signature: the webhook tells you something may have changed, and your code calls the provider's API to find out what is actually true before acting.
That last pattern is worth reaching for whenever the consequence is significant. A forged "payment succeeded" achieves nothing if your handler responds by asking the payment provider whether that payment exists.
Handling the delivery guarantees
Two properties of webhook delivery catch people out, and both interact with verification.
Order is not guaranteed. Events can arrive out of sequence,
particularly under retry. A subscription.updated can land before
the subscription.created that preceded it. Handlers that assume
order produce states that should be impossible. Where it matters, use the
timestamp or version on the event and ignore anything older than what you have
already applied.
Delivery is at-least-once, not exactly-once. Providers retry when they do not get a prompt success, including when your handler succeeded but was slow to answer. This is the main practical reason to be idempotent, well before anyone malicious is involved.
Which leads to the other common mistake: doing the work before responding. If your handler verifies the signature, then spends fifteen seconds generating a PDF, the provider may time out and retry, and you now have two PDFs. Verify, record the event, return 200, and do the work afterwards from a queue. Fast acknowledgement is part of correct webhook handling, not an optimisation.
Checking what you have
Go through your endpoints. For each one, three questions:
- Does it verify a signature? Look for the verification call, and check that it runs before any side effect rather than after.
- What happens if verification fails? It should return 400 and do nothing else. An endpoint that logs the failure and carries on has verification in name only.
- Is it idempotent? Send the same event twice in staging and confirm the second one changes nothing.
Then test the negative case, which is the one nobody tests: send a request with a deliberately wrong signature and confirm you get a 400 and no state change. A verification path that has only ever been exercised by valid requests has not really been tested.
Both Stripe and most other providers offer a CLI that forwards real events to a local endpoint, which makes this straightforward to exercise during development rather than on a deployed environment. Sending one genuine event and one deliberately corrupted event through the same handler takes a couple of minutes and settles the question properly.
Why this belongs in a readiness guide
It is a small, specific, entirely fixable flaw that turns a public URL into a way to change your data. Verification is a handful of lines using a library you already have installed, and the failure mode without it is somebody granting themselves whatever your webhook grants.