Webhook receivers are public, unauthenticated by default, and often wired directly into consequential logic. Marking orders paid, provisioning access, sending things.
Verify the signature
Providers sign the request body with a shared secret. Verify it against the raw bytes, before any parsing.
Two things people get wrong. Signing the re-serialised JSON rather than the raw body, key order and whitespace change, the signature fails, and someone "fixes" it by skipping verification. And using === to compare, which leaks information through timing; use the constant-time comparison your language provides.
Many frameworks consume the body before your handler sees it. You'll usually need to configure raw body capture on that route specifically.
Reject old requests
A valid signed request captured once can be replayed indefinitely unless you bound it.
Most providers include the timestamp in the signed payload, which is what stops an attacker adjusting it. Five minutes is a common tolerance.
For stronger protection, store the event ID and reject duplicates. Which you want anyway for idempotency.
Don't trust the payload
Even a correctly signed webhook tells you an event happened, not what the current state is. Events arrive out of order, get retried, and can be stale.
For anything consequential, payment succeeded, subscription active. Take the ID from the payload and fetch the object from the provider's API. That's the authoritative answer.
Be idempotent
Providers retry, sometimes aggressively, and deliver duplicates. Store processed event IDs with a unique constraint and return 200 for anything you've already handled.
Without this you get double-fulfilled orders and duplicate emails, and it's a race condition as much as a webhook problem, two retries arriving simultaneously both check "have I seen this" and both proceed.
Respond fast, work later
Return 200 quickly and process asynchronously. Providers time out and retry, which turns slow processing into duplicate delivery.
Don't leak through the URL
A secret in the path (/webhooks/a8f3...) is authentication by obscurity, and it ends up in logs, proxies and referrers. Use it as a supplementary layer if you like, never as the only one.
Rate limit the endpoint. Restrict by source IP where the provider publishes ranges, though ranges change and this shouldn't be your only control.
Reviewing for it
req.body used before signature verification, === on a signature comparison, and missing timestamp checks are all reviewable in a few minutes. The list of webhook endpoints in most codebases is short.