HomeProjectsBlogContact

Webhooks Explained: How to Receive Them Reliably and Securely

Payment confirmations, Git pushes and form submissions often arrive as webhooks. How they work, how to verify signatures, handle retries and duplicates, and test them on your laptop.

Webhooks Explained: How to Receive Them Reliably and Securely

Webhooks are how other services tell your app that something happened: a payment succeeded, a subscription was cancelled, someone pushed to a repository, a form was submitted. They're simple in concept and surprisingly easy to get wrong in ways that lose data or open security holes.

What a webhook is

Instead of your app repeatedly asking "anything new?", the other service sends an HTTP POST to a URL you provide whenever an event happens:

POST /webhooks/payments
Content-Type: application/json
Webhook-Signature: t=1767225600,v1=5257a869e7ec...

{
  "id": "evt_8f3a",
  "type": "payment.succeeded",
  "data": { "orderId": "991", "amount": 4999 }
}

Your endpoint receives it, processes it and responds with a 2xx status to say "got it."

Rule 1: verify the signature

Your webhook URL is public. Anyone who discovers it can send fake events, like a fake "payment succeeded." So every reputable provider signs its webhooks, usually with an HMAC of the raw request body using a secret only you and the provider know.

import crypto from 'node:crypto'

function isValidSignature(rawBody, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(signatureHeader)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

Two important details:

  • Use the raw body. If your framework parses JSON first and you re-stringify it, the bytes change and the signature won't match. Most frameworks have a way to access the raw body for specific routes.
  • Use the provider's official SDK when available. It handles their exact signature format, timestamps and comparisons correctly.

Many providers include a timestamp in the signature. Reject events that are too old, to prevent attackers replaying a captured request later.

Rule 2: respond fast, process later

Providers wait only a short time for your response. If your handler sends emails, calls other APIs and updates several tables before responding, it may time out, and the provider will retry, causing duplicate processing.

The reliable pattern:

  1. Verify the signature.
  2. Store the event (or enqueue a job).
  3. Return 200 immediately.
  4. Process the event in a background job. (Background jobs and queues.)

Rule 3: expect duplicates and handle them

Webhook delivery is usually at least once. Network hiccups and retries mean you'll sometimes receive the same event twice. Make processing idempotent:

const exists = await db.webhookEvents.findUnique({ where: { id: event.id } })
if (exists) return res.status(200).end()   // already handled

await db.webhookEvents.create({ data: { id: event.id, type: event.type, payload: event } })
await queue.add('process_webhook', { eventId: event.id })
res.status(200).end()

A unique constraint on the event ID makes this safe even under concurrent deliveries.

Rule 4: don't assume order

Events can arrive out of order: subscription.updated before subscription.created, or two updates swapped. Options:

  • Fetch the current state from the provider's API when processing, instead of trusting the payload alone.
  • Compare timestamps or version numbers before applying an update.

Rule 5: return the right status codes

  • 2xx: received successfully. The provider won't retry.
  • 4xx/5xx: the provider will typically retry with backoff. Useful when your system is temporarily down, so don't return 200 if you didn't actually store the event.

Don't return errors for events you simply don't care about. Acknowledge them with 200 and ignore them, or the provider will keep retrying and may eventually disable your endpoint.

Testing webhooks locally

Providers can't reach localhost. Options:

  • The provider's CLI, which many payment and developer platforms offer to forward events to your local server and trigger test events.
  • A tunnel like Cloudflare Tunnel or ngrok, which gives your local server a temporary public URL. (Free developer tools.)
  • Replaying saved payloads in automated tests, so your handler is covered by your test suite.

Monitoring

  • Log every received event with its ID and type.
  • Alert on signature failures (could be misconfiguration or an attack) and processing failures.
  • Most providers have a dashboard showing delivery attempts and responses, which is invaluable for debugging.

(Logging and monitoring basics.)

Webhooks and payments

Payment webhooks deserve special care: they're often the only reliable signal that money moved, because users can close the browser before your success page loads. Treat the webhook as the source of truth for order fulfilment. (Adding payments to your app.)

FAQ

What is a webhook?

An HTTP request that a service sends to your app's URL when an event happens, such as a successful payment, so your app can react without constantly polling for changes.

How do I secure a webhook endpoint?

Verify the provider's signature using the raw request body and a shared secret, check the timestamp to prevent replays, use HTTPS and never trust the payload without verification.

Why am I receiving the same webhook twice?

Webhook delivery is usually at least once, so retries after timeouts or network errors can deliver duplicates. Store event IDs and skip events you've already processed.

Keep reading

More from the workbench

All articles →