Verify webhook signatures
Check that a webhook message came from GenPage, was not changed on the way and is not an old message sent again, using your signing secret.
On this page
When a webhook has a Signing Secret, GenPage signs every message it sends. Your endpoint can then check that the message came from GenPage, was not changed on the way and is not an old message sent again. This article is for whoever builds the endpoint. You set the secret when you create or edit the webhook under Developers → Webhooks, see Webhooks.
The headers on each message
| Header | What it holds |
|---|---|
webhook-id | A unique id for the message. A repeat of the same message keeps its id, so you can use it to skip duplicates. |
webhook-timestamp | When the message was sent, in seconds since 1 January 1970 (Unix time). |
webhook-signature | v1, followed by the signature in base64. Sent only when the webhook has a secret. |
X-Webhook-Signature | The older signature of the body alone, in lowercase hex. Sent only when the webhook has a secret. |
The first three follow the Standard Webhooks specification, so its free libraries can do the whole check for you.
Check a message
- Take the request body exactly as it arrived, as raw bytes, before anything parses it as JSON.
- Join three values with a full stop: the
webhook-id, thewebhook-timestampand the raw body. - Compute an HMAC-SHA256 of that text. The key is your Signing Secret exactly as you typed it, as UTF-8 text. Do not decode it.
- Encode the result in base64 and put
v1,in front of it. Compare it withwebhook-signatureusing a constant-time comparison. The header can hold several signatures separated by spaces; accept the message if any of them matches. - Reject the message if
webhook-timestampis more than five minutes away from your server's clock. This stops someone from replaying a message they captured. - To skip duplicates, remember the
webhook-idvalues you have processed.
Use a Standard Webhooks library
The libraries check the signature and the time in one call. Your secret is plain text rather than a key that starts with whsec_, so pass it in as raw bytes:
// Node: npm install standardwebhooks
import { Webhook } from "standardwebhooks";
const wh = new Webhook(new TextEncoder().encode(process.env.GENPAGE_SECRET), { format: "raw" });
const payload = wh.verify(rawBody, request.headers); // throws if the check fails
# Python: pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
payload = Webhook(GENPAGE_SECRET.encode()).verify(raw_body, request.headers) # raises if the check fails
Test your code
Run your check against these values. The body is one line with no line break at the end. The timestamp is in the past, so skip the five-minute rule for this test.
Signing secret: my-signing-secret
webhook-id: msg_01K5TEST000000000000000000_7
webhook-timestamp: 1790142744
Body: {"event":"form_submission","timestamp":"2026-09-23T05:52:24+00:00","form_id":"contact","submission_id":4821,"data":{"name":"Zo\u00eb M\u00fcller","website":"https:\/\/acme.io\/path"},"page_url":"https:\/\/acme.genpage.ai\/demo?x=1","ip_address":"203.0.113.7"}
webhook-signature: v1,KlX4cBqzvZTus3rQZ1SvZDwiOl9RYmcXurt7AiC4GYQ=
X-Webhook-Signature: 79a9467b29d6d56263df5654a6ceab20a5bbdfff72a731300bb3dc303fc401f6
The older X-Webhook-Signature header
Endpoints built before September 2026 may check X-Webhook-Signature. It is the HMAC-SHA256 of the raw body alone, keyed with your secret, written as 64 lowercase hex characters with no prefix. It keeps working. It does not cover the time, though, so a captured message would still pass. Move to webhook-signature when you can.
Was this article helpful?