Formwork
Menu
Get started free Log in

Verifying webhook signatures

Check that a webhook request really came from Formwork, with code for PHP, Node.js and Python and a way to test the calculation by hand.

Anyone who knows your webhook address can send it a request. The signature is how your server tells a genuine Formwork delivery from a forgery.

How the signature is made

For each attempt, Formwork takes the Unix time in seconds, a full stop, and the exact bytes of the request body:

timestamp + "." + raw body

It computes an HMAC-SHA256 of that string using the endpoint's secret (the whsec_... value) as the key, and sends the hex digest in a header, after the text sha256=:

X-Formwork-Timestamp: 1790739600
X-Formwork-Signature: sha256=<64 hexadecimal characters>

The timestamp is the moment that attempt was signed, so a retry carries a newer timestamp and a different signature from the first try.

What to check

  1. Read the raw body, before any JSON parsing. Parsing and re-encoding changes the bytes and breaks the signature.
  2. Recompute the signature with your secret.
  3. Compare it with the header in constant time, not with ==.
  4. Reject timestamps more than a few minutes old, which stops someone replaying a captured request.
  5. Only then parse the JSON.

Answer 401 when a check fails. Formwork counts any non-2xx answer as a failed attempt and retries later.

PHP

$timestamp = $_SERVER['HTTP_X_FORMWORK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_FORMWORK_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');

$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}

Node.js

const crypto = require('crypto');

// rawBody must be the unparsed request body as a string or Buffer
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));

timingSafeEqual throws when the two buffers have different lengths, so compare lengths first or wrap the call in a try block, and treat a mismatch as a failure. Add the same five-minute check on the timestamp.

In Express, use a raw body parser for this route, for example express.raw({ type: 'application/json' }), so the body is not parsed before you verify it.

Python

expected = "sha256=" + hmac.new(secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, signature)

Here raw_body is the request body as a string. Check the timestamp age as well.

Try the calculation by hand

You can reproduce a signature in a terminal, which helps when a check keeps failing. Save a payload exactly as your server received it, then:

TIMESTAMP=1790739600
SECRET=whsec_your_secret_here
printf '%s' "$TIMESTAMP.$(cat body.json)" | openssl dgst -sha256 -hmac "$SECRET"

The hex digest it prints should match the part of the header after sha256=. If it does not, the body you saved is not byte-for-byte what was signed. Common causes are a framework that re-encoded the JSON, a trailing newline added by an editor, and a secret that was replaced with New secret in the meantime.

Rotating the secret

New secret in the webhook settings creates a fresh secret and stops the old one working immediately. The new secret is shown once, so copy it into your server's configuration straight away. Until you do, your server will reject the deliveries signed with the new secret. Deliveries that failed in the meantime can be replayed from the delivery log. See Webhooks.

Also worth doing

  • Deduplicate on X-Formwork-Delivery or the response.id in the body, because deliveries can be repeated.
  • Serve your endpoint over HTTPS. Formwork refuses plain http:// addresses in production.
  • Keep your endpoint's response time short. Formwork waits 10 seconds and then counts the attempt as failed.

Updated Sep 30, 2026