SI Back Office

Troubleshooting guide · updated 2026-10-11

Verify a webhook signature on the exact bytes you received, not the data you parsed

Why a correct secret can still fail, how to compare signatures safely, which identifier to trust against replays, and how to test a receiver with known inputs.

What a signature proves, and what it does not

A webhook URL is public. Anyone who learns it can send a request to it, so a receiver needs a way to tell the sender's real deliveries from anything else. Many senders solve this with a shared secret and an HMAC signature: the sender computes a digest of the request body with the secret and sends it in a header, and the receiver recomputes the digest and compares. A match shows that whoever made the request held the secret and that the body has not changed since the digest was made.

It does not show that the event is new, or that your system should act on it. A signed delivery can arrive twice, or be recorded by an attacker and sent again later with its signature still valid. So a valid signature is the first check. Then comes a check for repeats, and which identifier that check uses decides whether it stops a replay (see the section on replays), and then your own rules about what the event is allowed to cause.

Verify the bytes you received

The digest is computed over specific bytes, so the receiver must hash exactly those bytes. Shopify's documentation says its signature is computed from the raw request body with the app's client secret. GitHub's examples hash the request body as read, and GitHub tells you to make sure nothing between GitHub and your server, such as a proxy or a load balancer, alters the payload or the headers before you verify it. If your framework parses the JSON first and you then serialise it again, the bytes change: keys can reorder, spaces appear or disappear, and a trailing newline may be added or lost. The digest will no longer match, although the data is the same.

This is the most common reason a correct secret still fails. Capture the raw body before any parser touches it, hash that, and only then parse. GitHub also says to treat the payload as UTF-8, because payloads can contain non-English characters.

  • Read the raw body first; parse it only after the signature is accepted.
  • Do not hash a re-serialised copy of the JSON.
  • Check that a proxy or gateway in front of your server does not rewrite the body or the headers.
  • Compare against the header's exact format, which differs by sender (see the next section).

The header format depends on the sender

Two receivers can both do the HMAC correctly and still fail because they compare different encodings. GitHub puts a hex digest after the prefix sha256=. Shopify documents its X-Shopify-Hmac-SHA256 value as base64-encoded, so you compare against the decoded header value or encode your own digest as base64. Stripe puts a timestamp and one or more signatures in one header (t=...,v1=...) and signs the timestamp, a full stop and the body, not the body alone. Slack signs v0, the timestamp and the body joined with colons, and prefixes the hex digest with v0=. Read your sender's page for the header name, the encoding and exactly which bytes are signed, and test against its published example before anything else.

If the matrix is wider than the box, scroll horizontally to read every column. Keyboard: focus the matrix and use Left/Right.

GitHub  sha256=<hex of HMAC-SHA256(secret, body)>
Shopify <base64 of HMAC-SHA256(client secret, raw body)>
Stripe  t=<timestamp>,v1=<hex of HMAC-SHA256(secret, timestamp + "." + body)>
Slack   v0=<hex of HMAC-SHA256(secret, "v0:" + timestamp + ":" + body)>

Compare safely

Comparing two strings with a plain equality operator can leak, through timing, how many leading characters matched. GitHub's page says never to use a plain == and to use a constant-time function instead, naming secure_compare and Node's crypto.timingSafeEqual; its Python example uses hmac.compare_digest. Node's documentation says timingSafeEqual throws an error if its two inputs have different byte lengths, so check the lengths first and reject if they differ. Verify before doing any other work: GitHub notes that this saves server time on fake deliveries as well as guarding against tampering.

  • Reject with an error status and do no work when the signature is missing, malformed or wrong.
  • Do not log the secret or the full computed digest.
  • GitHub's older SHA-1 header is kept for legacy use; use the SHA-256 one where the sender offers both.

Replays: which identifier to trust

GitHub describes a replay attack as a bad actor intercepting a webhook delivery and re-sending it. The copy carries a genuine signature, so verification alone cannot refuse it. The usual defence is to remember which deliveries you have handled and ignore a second one, but the identifier you remember matters.

GitHub's identifier is the X-GitHub-Delivery header, and Shopify's is X-Shopify-Webhook-Id. Both are headers. GitHub's published test vector derives the signature from the secret and the payload alone, and Shopify says its signature is made from the raw request body, so neither signature covers those headers. Someone holding one captured request can send the same body with a new header value, and the signature still checks. Remembering the header identifier therefore handles the sender's own retries, which is useful, but it does not stop that kind of replay.

Prefer an identifier that is inside the signed bytes, such as an event ID in the JSON body, as Stripe's event objects carry. If the only identifier is a header, key your memory on a hash of the raw body instead, after checking that two different genuine events cannot have byte-identical bodies. Then decide how long to keep what you remember. Where the sender signs a timestamp, as Stripe and Slack do, reject deliveries whose timestamp is outside a tolerance (Stripe's libraries default to 5 minutes; Slack's example uses five), and keep the remembered keys for at least that tolerance plus the sender's retry period. Where nothing signed carries a time, a captured request stays valid for as long as the secret does, so the retention period is a choice to write down: a request older than it can act again.

Remembering is not enough on its own. If you record a delivery as handled and then act on it, and the process stops between the two, the retry is ignored and the event is lost. Store the delivery in one durable step, acknowledge, and run the action from that stored record, in a way that leaves one effect for one key.

  • Use an event ID inside the signed body if there is one; otherwise a hash of the raw body.
  • An unsigned header identifier handles retries, not replays.
  • Reject stale signed timestamps where the sender signs one.
  • Write down how long handled keys are kept.

Look after the secret

GitHub recommends a random string with high entropy, kept securely on the server, and warns never to hard-code a token into an application or push it to a repository. In practice that means the secret lives in your hosting provider's configuration or secrets store, a separate secret per environment, and a written way to rotate it. Never paste it into a chat, a ticket or an email. When you ask someone to build or review a receiver, give them a test secret, not the live one.

Test with known inputs first

Test before any real delivery arrives. GitHub publishes a test vector: the secret It's a Secret to Everybody and the payload Hello, World! should produce a header that begins sha256=757107ea and ends 3e17. Run that through your code, then add cases of your own: an altered body, a different secret, a missing header and a re-serialised body. Every one except the exact original should be rejected without any work being done. The example page prints the secrets, the bodies and the digests for six synthetic cases, with a short script that reproduces them, so you can check a receiver you have written or been given.

What this guide does not cover

This guide is about authenticity and replays. It does not show how to answer fast or recover missed events; those are covered separately. It is not a security assessment. The paid outcome builds one receiver for one signed event type, with a repeat key taken from the signed bytes, a durable record before the action, and tests for valid, altered, wrong-secret, missing, repeated, simultaneous and header-changed deliveries and for a stop between storing and acting, in a repository you control. Send the sender's documentation link and the event and action, never the secret. If you already have a receiver that only rejects genuine deliveries, a smaller fixed-price repair of one endpoint fits better.

Sources and limits

  • GitHub: validating webhook deliveries Checked 2026-10-11.
    • GitHub signs deliveries with an HMAC hex digest in the X-Hub-Signature-256 header, whose value starts with sha256=.
    • The page says never to use a plain == comparison and to use a constant-time function, naming secure_compare and crypto.timingSafeEqual in its prose; its Python example uses hmac.compare_digest.
    • It advises verifying the signature before any further processing, keeping the secret out of code and repositories, and treating the payload as UTF-8.
    • The older X-Hub-Signature header uses SHA-1 and is included only for legacy purposes.
    • Its published test uses the secret It's a Secret to Everybody and the payload Hello, World!, giving a header that begins sha256=757107ea and ends 3e17; the signature is derived from the secret and the payload alone.
  • GitHub: best practices for using webhooks Checked 2026-10-11.
    • The page defines a replay attack as a bad actor intercepting a webhook delivery and re-sending it, and says to use the X-GitHub-Delivery header to ensure each delivery is unique per event.
    • If a redelivery is requested, the X-GitHub-Delivery header is the same as in the original delivery.
  • Shopify: HTTPS webhook subscriptions Checked 2026-10-11.
    • Each delivery carries an X-Shopify-Hmac-SHA256 header that is a base64-encoded HMAC signature, made with the app's client secret and the raw request body; a delivery whose signature does not match should be rejected.
    • Shopify advises idempotent processing, and, if processing is not idempotent, detecting and skipping duplicates with the X-Shopify-Webhook-Id header.
  • Stripe: receive events in your webhook endpoint Checked 2026-10-11.
    • The Stripe-Signature header carries a timestamp (t=) and a v1 signature; the signed payload is the timestamp, a full stop and the request body, and the HMAC is SHA-256.
    • Because the timestamp is part of the signed payload, an attacker cannot change it without invalidating the signature; Stripe's libraries have a default tolerance of 5 minutes between the timestamp and the current time, and a tolerance of 0 disables the recency check.
    • Stripe advises guarding against duplicated event receipts by logging the event IDs already processed.
  • Slack: verifying requests from Slack Checked 2026-10-11.
    • Slack signs a base string made of v0, the X-Slack-Request-Timestamp value and the request body joined with colons, and sends the hex digest prefixed with v0= in the X-Slack-Signature header.
    • The page's example rejects a request whose timestamp differs from local time by more than five minutes.
  • Node.js: crypto module Checked 2026-10-11.
    • crypto.timingSafeEqual(a, b) takes Buffers, TypedArrays or DataViews of the same byte length and throws an error if their byte lengths differ.