Webhooks are how payment providers, mail services and platforms tell your application something happened. The endpoint is public by necessity, which means anyone can call it. If it acts on unverified input, anyone can tell your application that a payment succeeded.

Verify on the raw body

Signatures are computed over the exact bytes sent. If your framework parses JSON before you see it, re-serialising produces different bytes and verification fails — or worse, someone disables verification to make it work. Capture the raw body first, verify, then parse.

Reject replays

A captured valid request can be sent again. Include the timestamp in the signed payload, reject anything outside a few minutes, and record processed event identifiers so a duplicate is recognised. Providers legitimately retry on failure, so idempotency is required regardless.

Idempotency in practice

Store the provider's event identifier with a uniqueness constraint and ignore repeats. Make the handler safe to run twice: a receipt should not be emailed four times because the provider retried, and a balance should not be credited twice. Design for at-least-once delivery, because that is what you have.

Do not trust the payload for money

For anything financial, treat the webhook as a notification that something changed, then confirm the authoritative state by calling the provider's API for that object. A signature proves the message came from the provider; it does not protect you from acting on a stale or partial event.

Operational points

  • Respond quickly with 2xx and do the work asynchronously — slow handlers cause retries and duplicates.
  • Log every received event with its identifier and outcome; debugging without that log is guesswork.
  • Keep the signing secret in your platform's secret store, and rotate it when anyone with access leaves.
  • Return 401 on a failed signature, and alert if failures spike — that is someone probing.