Skip to content

Webhooks Explained: Delivery, Retries and Signature Verification

How webhooks work, why you must verify signatures, a correct HMAC check in Node.js and Python, and how to handle retries safely.

By the apimark editorial team · Updated 2026-10-31 · 8 min read

A webhook is an HTTP request a service sends to your server when something happens: a payment succeeds, a pull request opens, an order ships. Instead of polling an API every minute, you get notified. Webhooks are simple, but three things trip teams up: proving the request is genuine, surviving retries, and responding fast enough.

1. Verify the signature

Your webhook endpoint is a public URL. Anyone who finds it can send fake events ("payment succeeded!") unless you check that each request really came from the provider. Most providers sign the payload with an HMAC using a secret only you and they know, and put the result in a header.

The details differ by provider (header name, whether a timestamp is included, hex or Base64 encoding), so always follow their docs. The general pattern is:

  1. Read the raw request body as bytes. Don't parse and re-serialise JSON first; even whitespace changes break the signature.
  2. Build the signed string exactly as documented (often timestamp + "." + body).
  3. Compute HMAC-SHA256 with your webhook secret.
  4. Compare with the header using a constant-time comparison.
  5. Reject timestamps older than a few minutes to block replay attacks.

Node.js

import crypto from 'node:crypto';

function verify(rawBody, header, secret, timestamp) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(header);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, timestamp: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    msg = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)
Many providers ship an official helper (for example in their SDK) that implements their exact scheme. Prefer it over hand-written code when one exists.

2. Expect duplicates and out-of-order events

Providers retry when your endpoint times out or returns a non-2xx status, sometimes for days. Networks also deliver the same event twice. Design for at-least-once delivery:

  • Store each event ID and skip ones you've already processed (idempotency).
  • Don't assume order; re-fetch the latest object state from the API when it matters.
  • Make handlers safe to run twice: "set status to paid", not "add 1 to paid count".

3. Respond fast, process later

Return 2xx within a few seconds, then do slow work (emails, PDFs, third-party calls) in a background queue. Long handlers cause timeouts, which cause retries, which cause duplicates.

4. Operate it

  • Log the event ID, type and processing result for every delivery.
  • Alert when failures spike, and keep a way to replay failed events.
  • Rotate webhook secrets periodically. Most providers allow two active secrets during rotation.
  • Use the provider's test or CLI tools to send sample events in development.

Useful tools: Hash Generator to sanity-check digests, and JSON Formatter to inspect payloads.