Skip to content

All posts

How to verify webhook signatures

6 min readThe Eventeer team

A webhook endpoint is a public URL that accepts POST requests. Anyone who finds it can send it a body that claims to be “payment succeeded”. The standard defense is a signature: the sender computes a keyed hash of the request with a secret you both know, and you recompute it and compare. This post explains how that works, where people go wrong, and what the common providers actually send.

If you only want to check one signature by hand, there is a free webhook signature verifier that runs in your browser.

The idea in four lines

  1. You and the sender share a secret. Nobody else has it.
  2. The sender computes HMAC-SHA256(secret, message) and puts the result in a header.
  3. You compute the same thing from the request you received.
  4. If the two values match, the sender knew the secret and the message was not changed.

An HMAC does not hide the body. It proves who sent it and that it arrived unmodified. The details that matter are what “message” means, how you compare, and how you stop a valid request from being replayed.

Rule 1: sign the raw body

The message is the exact bytes the sender signed. If your framework parses the JSON and you re-serialize it, key order, whitespace and number formatting can change, and the signature will not match. Capture the raw body before any parser touches it.

In Express that means express.raw({ type: "application/json" }) on the webhook route instead of express.json(). In Rails, request.raw_post. In Phoenix, a custom body reader that keeps the original bytes. In Flask, request.get_data().

This is the most common failure: the code is correct, the secret is correct, and verification fails because middleware already parsed the body.

Rule 2: compare in constant time

An ordinary string comparison returns as soon as it finds a differing byte. In theory an attacker can measure that and guess a signature byte by byte. Use the comparison function your platform provides for this: crypto.timingSafeEqual in Node, hmac.compare_digest in Python, Plug.Crypto.secure_compare in Elixir, hash_equals in PHP. Both inputs must have equal length, so check the length first (or decode both to bytes of fixed size).

Rule 3: check the timestamp

A signature proves a request was genuine once. It does not stop someone who captured it from sending it again an hour later. Providers fix this by including a timestamp in the signed message. You reject requests whose timestamp is too far from your clock. Five minutes in either direction is the usual tolerance. For a stricter guarantee, also remember recent signatures or event ids and reject repeats.

Not every provider signs a timestamp. GitHub does not, so replay protection there means deduplicating on the delivery id.

What each provider sends

The formats differ in small ways that break copy-pasted code. These are the ones people ask about most; check each provider’s docs for the current details before shipping.

Provider Header Signed message Encoding
Stripe Stripe-Signature: t=…,v1=… t + "." + raw body hex
GitHub X-Hub-Signature-256: sha256=… raw body hex
Shopify X-Shopify-Hmac-Sha256 raw body base64
Standard Webhooks webhook-signature: v1,… with webhook-id and webhook-timestamp id + "." + timestamp + "." + raw body base64
Eventeer X-Eventeer-Signature: t=…,v1=… t + "." + raw body hex

Notes on the table:

  • Stripe may include several v1 values in the header, for example while a secret is being rotated. Accept the request if any of them matches. The signing secret starts with whsec_ and is used as given.
  • GitHub has no timestamp. Verify the HMAC and deduplicate on the X-GitHub-Delivery id. Older X-Hub-Signature (SHA-1) headers exist but you should use the SHA-256 one.
  • Shopify signs only the body, with your app’s secret, and base64-encodes the result. A hex comparison against it will never match.
  • Standard Webhooks is a specification several services follow. The signature header can hold several space-separated v1,<base64> entries. The secret is conventionally prefixed whsec_, and the part after the prefix is base64 that you decode to get the key bytes.

A verifier in Node

Here is a complete check for a Stripe-style header, which is also the format Eventeer uses:

import crypto from "node:crypto";

export function verify(secret, rawBody, header, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=", 2)),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // too old or too new

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  // The header can carry several v1 values (secret rotation): any match passes.
  return header
    .split(",")
    .map((p) => p.trim())
    .filter((p) => p.startsWith("v1="))
    .some((p) => {
      const got = Buffer.from(p.slice(3), "hex");
      const want = Buffer.from(expected, "hex");
      return got.length === want.length && crypto.timingSafeEqual(got, want);
    });
}

rawBody is the string or buffer of the unparsed request. Call it before you do anything else with the payload, and return 401 when it is false.

A Python version is the same shape:

import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance=300) -> bool:
    items = [p.strip().split("=", 1) for p in header.split(",")]
    t = next((v for k, v in items if k == "t"), None)
    if t is None or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(v, expected) for k, v in items if k == "v1")

How Eventeer signs its own deliveries

When Eventeer sends an event to a destination, the request carries X-Eventeer-Signature in the format t=<unix time>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with the destination’s signing secret. Reject any request whose t is more than five minutes from now. The headers X-Eventeer-Event-Id (use it to drop duplicates) and User-Agent: Eventeer-Webhooks/1 come with it. The Node function above verifies these requests as written.

The signing secret is returned once when you create the destination:

curl -X POST https://app.eventeer.io/api/v1/destinations \
  -H "Authorization: Bearer $EVENTEER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"billing hook","url":"https://example.com/hooks/eventeer"}'

The response contains signing_secret. To replace it, call POST /api/v1/destinations/:key/rotate-secret; the old secret stops working at once, so update your endpoint immediately after.

The scheme also works in the other direction. An hmac webhook listener verifies X-Eventeer-Signature on incoming requests, so a sender you control can sign with the same recipe. When the request has an Idempotency-Key, the sender should add a v2 value that also covers the key, which stops a captured request from being replayed with a different key. A signature that was accepted once is remembered until it can no longer pass the timestamp check, so a second use of the same signed request is answered with 401. Sign each request with a fresh timestamp or body, and retry with an Idempotency-Key if you need to resend.

If a provider signs its webhooks and you need to check them before they reach your code, an hmac listener is the place. For storing and forwarding signed payloads without losing them, see replaying failed webhooks.

A short checklist

  • Read the raw body before parsing.
  • Compute the HMAC with the exact message format for that provider.
  • Compare with a constant-time function, after matching lengths.
  • Enforce a timestamp tolerance where the provider signs one.
  • Deduplicate on an event or delivery id.
  • Store secrets in a secrets manager, rotate them, and accept two during the switch.
  • Return 401 on failure and log the reason, but not the secret or the full signature.

The webhooks use case page shows how listeners and destinations fit together, and the API docs list every endpoint named here.

  • 5 min read

    Fan out one webhook to many endpoints

    Providers let you register one webhook URL. Fan out each event to several endpoints and browsers, with retries, a delivery log and signed requests.

  • 5 min read

    Alert when a webhook source goes quiet

    A failing webhook often looks like silence, not errors. Set up a quiet-for-N-minutes alert, metrics on payload fields and a dashboard so you hear first.

Send your first event

Create an organization, add a listener and see every event stored and replayable. Choose a plan when you are ready for real traffic.