SI Back Office

Inspectable example · updated 2026-10-11

Synthetic webhook signature test vectors: six deliveries, one accepted

Invented secret, invented body and real HMAC-SHA256 digests show exactly which small changes make a signature check fail, with a verifier you can run.

An example, not a customer case study. Scope and evidence limitations are described below.

What this example is

Everything here is synthetic. The two secrets are invented for this page and belong to no sender, the event is made up, and no real service, receiver or customer is involved. The digests are real HMAC-SHA256 values. Every input is printed below, and the script on this page regenerates every value in the table when run with Node.js; it was run on Node.js v24 on 11 October 2026 and printed exactly what is shown. As a cross-check, the same function accepts the header GitHub publishes for its own test input. The example is a specification for testing a receiver, not evidence that any receiver passes it.

The fixtures and the six cases

The sender signs the exact 87 bytes of body A with the first secret. The receiver recomputes the digest over the bytes it received and compares it with the header. Cases B to F each change one thing, and only the unchanged delivery should be accepted. The bodies for B, C and D and the second secret used for E are printed here so each row can be reproduced.

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

Secret (invented)            : demo-secret-not-a-real-key
Second secret (invented)     : another-demo-secret   (used only for case E)

Body A, exactly as sent (87 bytes):
{"event":"task.created","delivery":"dlv_demo_001","title":"Call back Ada","priority":2}
Header the sender attaches, sha256= plus HMAC-SHA256 of body A with the first secret:
sha256=967c95481a9684fc158c35e7474bb0cb79845611bfea3f3d6ec03bb7372c0320

Body B (87 bytes): A with "priority":2 changed to "priority":3
Body C (96 bytes): A re-saved with a space after each colon and comma and inside the braces:
{ "event": "task.created", "delivery": "dlv_demo_001", "title": "Call back Ada", "priority": 2 }
Body D (88 bytes): A followed by one newline character
Header E: sha256= plus HMAC-SHA256 of body A with the second secret

case | what arrives                           | receiver's digest (first 16) | header presented  | result
A    | body A, right secret (87 bytes)        | 967c95481a9684fc             | 967c95481a9684fc  | accept
B    | body B, priority changed (87)          | 44a0a4f3241d3529             | 967c95481a9684fc  | reject
C    | body C, same data with spaces (96)     | 158efa5845904a61             | 967c95481a9684fc  | reject
D    | body D, trailing newline added (88)    | 4afcfad89f0ace96             | 967c95481a9684fc  | reject
E    | body A, header made with second secret | 967c95481a9684fc             | 4f5b159f5fe97500  | reject
F    | body A, header missing                 | not computed                 | none              | reject

Why C and D matter most

Cases B, E and F are what people expect a signature to catch: an altered value, a wrong secret and a missing header. Cases C and D are the ones that break working receivers. In C the data is identical, but a framework that parsed the JSON and wrote it out again has changed the bytes, so the recomputed digest differs from the one the sender made. In D a single trailing newline does the same. The lesson is to capture the raw request body before any parser touches it, and hash that.

  • Case C: parsed and re-serialised JSON no longer matches the signed bytes.
  • Case D: one extra byte is enough to change the digest completely.
  • A receiver that accepts C or D is checking something other than the signature, and one that rejects A has probably modified the body.

A verifier you can run

This Node.js script applies the rules from the guide: it checks the prefix, compares in constant time, and checks the lengths first because the comparison function throws when its inputs differ in length. Save it as a .cjs file and run it with node. It rebuilds the six cases from the inputs above and prints one line per case: the name, the body length in bytes, the first 16 characters of the receiver's digest, the first 16 of the header presented, and the verdict. Only A should be accepted. The next two lines show the same body signed in two other shapes, described after the output, and the last line checks the verifier against the test input GitHub publishes.

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

const crypto = require('node:crypto');
const hmac = (secret, data, enc = 'hex') => crypto.createHmac('sha256', secret).update(data).digest(enc);

function verify(secret, rawBody, header) {
  if (!header || !header.startsWith('sha256=')) return false;
  const expected = Buffer.from('sha256=' + hmac(secret, rawBody));
  const received = Buffer.from(header);
  return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}

const secret = 'demo-secret-not-a-real-key';
const other = 'another-demo-secret';
const A = '{"event":"task.created","delivery":"dlv_demo_001","title":"Call back Ada","priority":2}';
const header = 'sha256=' + hmac(secret, A);
const cases = {
  A: [A, header],
  B: [A.replace('"priority":2', '"priority":3'), header],
  C: ['{ "event": "task.created", "delivery": "dlv_demo_001", "title": "Call back Ada", "priority": 2 }', header],
  D: [A + '\n', header],
  E: [A, 'sha256=' + hmac(other, A)],
  F: [A, undefined],
};
for (const [name, [body, hdr]] of Object.entries(cases)) {
  const mine = hdr ? hmac(secret, body).slice(0, 16) : 'not computed';
  const theirs = hdr ? hdr.slice(7, 23) : 'none';
  console.log(name, Buffer.byteLength(body), mine, theirs, verify(secret, body, hdr) ? 'accept' : 'reject');
}
console.log('base64 of A', hmac(secret, A, 'base64'));
console.log('timestamp.body', hmac(secret, '1760000000.' + A));
const ghHeader = 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17';
console.log('GitHub published input', verify("It's a Secret to Everybody", 'Hello, World!', ghHeader) ? 'accept' : 'reject');

// Printed on Node.js v24, 11 October 2026:
// A 87 967c95481a9684fc 967c95481a9684fc accept
// B 87 44a0a4f3241d3529 967c95481a9684fc reject
// C 96 158efa5845904a61 967c95481a9684fc reject
// D 88 4afcfad89f0ace96 967c95481a9684fc reject
// E 87 967c95481a9684fc 4f5b159f5fe97500 reject
// F 87 not computed none reject
// base64 of A lnyVSBqWhPwVjDXnR0uwy3mEVhG/6j89bsA7tzcsAyA=
// timestamp.body 2fc2a022ded71da36e1def26da4ab2421efde6d67ed71596fcf16b3a5253a35d
// GitHub published input accept

Other senders sign other things

This verifier handles only the GitHub-style header, sha256= followed by a hex digest of the body. The base64 and timestamp.body output lines show why that is not universal. The base64 line is the same digest as case A written in base64, which is the encoding Shopify documents for its header. The timestamp.body line signs the characters 1760000000, a full stop and then the body, which is the shape Stripe documents; it is a different digest from case A although the secret and the body are the same. Slack signs v0, a timestamp and the body joined with colons. A receiver for any of them has to rebuild the exact signed bytes and use the exact encoding the sender documents. Where a timestamp is signed, it should also reject deliveries outside a tolerance, which the guide on signatures explains.

What was and was not exercised

Exercised: the digests above, the verdicts for the six cases and for GitHub's published input, and the base64 and timestamp lines, by running the script in Node.js v24 on 11 October 2026. Not exercised: any real sender, any web framework's raw-body handling, a proxy in front of a server, key rotation, replays or concurrent deliveries. Your own receiver, and your own sender's documented scheme, may differ in header name, prefix, encoding and signed bytes.

If you want a receiver built and tested against cases like these, the paid outcome covers one signed event type, with tests for valid, altered, wrong-secret, missing, repeated, simultaneous and replayed deliveries and for a stop between storing and acting. Send the sender's documentation link, never a secret.

Sources and limits

  • GitHub: validating webhook deliveries Checked 2026-10-11.
    • GitHub's published test uses the secret It's a Secret to Everybody and the payload Hello, World!, giving a header beginning sha256=757107ea and ending 3e17, which the verifier below reproduces.
  • Node.js: crypto module Checked 2026-10-11.
    • createHmac(algorithm, key) with update() and digest('hex') computes an HMAC digest, as in the page's own example; digest('base64') gives the base64 form, and timingSafeEqual throws if its inputs differ in byte length.
  • Shopify: HTTPS webhook subscriptions Checked 2026-10-11.
    • Shopify's X-Shopify-Hmac-SHA256 header is a base64-encoded HMAC signature made with the app's client secret and the raw request body.
  • Stripe: receive events in your webhook endpoint Checked 2026-10-11.
    • Stripe signs the timestamp, a full stop and the request body with HMAC-SHA256, and its libraries have a default tolerance of 5 minutes between the timestamp and the current time.