Synthetic Industry

Inspectable example · updated 2026-10-11

Synthetic webhook test vector: why a re-formatted body makes a genuine event fail verification

Five versions of one made-up event, each shown in full with its real SHA-256 digest, show that re-formatting the body changes the hash and that a one-character change is rejected.

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

A made-up event and a made-up secret

This is a synthetic worked example, not a customer case and not a test of any provider. The secret below is invented for this page and protects nothing. The event is an invented order notification. The five bodies are printed exactly as they are hashed: each is the indented line under its label, with no space or newline added at the end. Verification means: take the exact bytes you received, compute the keyed hash with your secret, and compare the result with the signature the sender provided.

  • Secret (synthetic): synthetic-test-secret-not-real
  • The sender's signature header carries the digest of body A, the bytes as sent.
  • Bodies B to E are what your endpoint would hash if it altered the body first, or what an attacker might send. In D the six characters \u00eb are written out in place of the letter ë.

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

A (original bytes, as sent)
  {"event":"order.created","id":"evt_0001","total":1250,"customer":"Zoë"}
B (space after each colon and comma)
  {"event": "order.created", "id": "evt_0001", "total": 1250, "customer": "Zoë"}
C (keys in another order)
  {"id":"evt_0001","event":"order.created","total":1250,"customer":"Zoë"}
D (ë written as \u00eb)
  {"event":"order.created","id":"evt_0001","total":1250,"customer":"Zo\u00eb"}
E (total changed from 1250 to 1260)
  {"event":"order.created","id":"evt_0001","total":1260,"customer":"Zoë"}

The digest of each body

Each digest is HMAC SHA-256 of exactly the body printed above, with the secret above, written in hex. The last column is the verdict when that body is checked against the sender's signature for A. The snippet at the end of the page recomputes every digest and verdict from the same five bodies.

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

case | SHA-256 digest of that body (hex)                                  | checked against the sender's signature for A
A    | caa1a4bfbed4ed837f3ef71816b8765fb375ceba129c43346169639c6657c897 | accept
B    | 195e20b7deccb58d318bfa4dbe79e9e018a73cfebc37ebd03f13eb97ec79bcff | reject
C    | 1bd0e5ca28cc3aa81c94787f38966166515a668d192b274f3bc1473815d29071 | reject
D    | e385a45c1b64a48e0c7843581edd970bcdc23116a66d1bdd1044c3eb65361ed3 | reject
E    | 87d7d9c26bd69b501e334b69c9567d0de63bf700a83381364a9aa4f01189e07f | reject

Reading the five cases

Case A matches itself, so a correct endpoint accepts it. Case B has exactly the same fields and values, but the text was parsed and written out again with a space after each colon and comma, as some serialisers do by default, so the bytes differ and so does the digest: re-formatting like this is a common reason a genuine event is rejected. Case C keeps the same fields with the keys in another order, and case D writes the same letter in another JSON notation; both mean the same thing to a person and give different digests. Case E changes one figure and must be rejected. Only A matches.

  • B, C and D show why verification must use the untouched received text, before any parsing.
  • E shows what verification is for: a changed body must fail.
  • An endpoint that accepts B, C or D after normalising the body would also accept an attacker who knows how it normalises; the sender's bytes are the contract.

A published value to test your own function

GitHub documents a test for its webhook scheme: the secret "It's a Secret to Everybody" and the payload "Hello, World!" produce the signature below. Running the same computation here gives the same value, which confirms the method used for the table. If your verification function returns this value for those inputs, the hashing is right and a live failure is about the bytes or the secret.

  • Expected value: 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

Run it yourself

Save the snippet as a file and run it with Node. It holds the same five bodies, takes the signature header as a separate value (as a real request supplies it), hashes the body it receives and compares the two in constant time. Expected output: A accept; B, C, D and E reject; a body made by adding one trailing space to A rejects; then GitHub's documented value above.

  • The verdict depends only on the received body and the supplied signature: change either and the answer changes.

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

const { createHmac, timingSafeEqual } = require('node:crypto');
const secret = 'synthetic-test-secret-not-real';
const bodies = {
  A: '{"event":"order.created","id":"evt_0001","total":1250,"customer":"Zoë"}',
  B: '{"event": "order.created", "id": "evt_0001", "total": 1250, "customer": "Zoë"}',
  C: '{"id":"evt_0001","event":"order.created","total":1250,"customer":"Zoë"}',
  D: '{"event":"order.created","id":"evt_0001","total":1250,"customer":"Zo\\u00eb"}',
  E: '{"event":"order.created","id":"evt_0001","total":1260,"customer":"Zoë"}',
};
// The signature header the sender sent with body A (taken from the table above).
const signatureHeader = 'sha256=caa1a4bfbed4ed837f3ef71816b8765fb375ceba129c43346169639c6657c897';
// Verify: hash the body you received, then compare with the signature you were sent, in constant time.
function verify(receivedBody, header) {
  const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(receivedBody).digest('hex'));
  const received = Buffer.from(header);
  return received.length === expected.length && timingSafeEqual(received, expected);
}
for (const [name, body] of Object.entries(bodies)) console.log(name, verify(body, signatureHeader) ? 'accept' : 'reject');
console.log('A plus one trailing space', verify(bodies.A + ' ', signatureHeader) ? 'accept' : 'reject');
// GitHub's documented test value for its own scheme:
console.log(createHmac('sha256', "It's a Secret to Everybody").update('Hello, World!').digest('hex'));

What this example does not show

It does not test any provider's service, any framework's body handling or any real endpoint, and it does not mean any integration has been delivered. Providers differ in header names, encodings and whether a timestamp is signed as well; read your provider's documentation. The example also leaves out repeat deliveries, which are a separate problem from signature checks: see the retries guide and our webhook receiver job.

  • Real endpoints also need a timestamp or identifier policy where the provider offers one.
  • The matching paid job is our webhook signature repair (posted test price from £295, untested). Send the provider, the event type and the status your endpoint returns, not your secret or real events.

Sources and limits

  • GitHub: Validating webhook deliveries Checked 2026-10-11.
    • HMAC SHA-256 over the payload, hex-encoded; proxies must not alter the payload; compare in constant time; documented test secret, payload and signature, which this page also reproduces locally.
  • Node.js crypto documentation Checked 2026-10-11.
    • crypto.createHmac(algorithm, key) with update and digest('hex') computes a keyed hash; the module documentation (v26) shows an HMAC SHA-256 example.