The signature scheme
Every delivery also carries
X-Webhook-ID (the event id, useful for deduplication) and X-Webhook-Event (the event type). The User-Agent is AntonPayments-Webhook/1.0.
X-Webhook-Signature may contain more than one signature, comma-separated — for example v1=abc…,v1=def…. This happens during a secret-rotation grace window, when a delivery is signed with both your new and previous secret so you can roll the new secret out without dropping events (see Rotating secrets). Always parse every v1= entry and accept the delivery if your computed signature matches any one of them. Never assume there is exactly one signature.Verification steps
1
Read the raw request body
Capture the body as bytes before any JSON parsing or framework normalisation. Parsed-and-re-serialised JSON will not byte-match the string Anton signed.
2
Read the timestamp and signature headers
Pull
X-Webhook-Timestamp and X-Webhook-Signature from the request. Split the signature header on commas and strip the v1= prefix from each entry — you may receive one signature normally, or two during a rotation grace window.3
Reject old timestamps
If the timestamp is more than 5 minutes (300 seconds) away from your server’s current time, reject the request. This prevents replays of captured deliveries.
4
Compute the expected signature
Concatenate
{timestamp}.{body}, compute HMAC-SHA256 over it using your subscription’s signing secret, and hex-encode the result.5
Compare in constant time against each entry
Compare your expected signature to each
v1= entry using a constant-time comparison (hmac.compare_digest, crypto.timingSafeEqual, hash_equals, hmac.Equal) and accept the delivery if it matches any of them. Never use == — it leaks timing information.6
Respond 2xx within 30 seconds
Return any 2xx status to acknowledge receipt. Non-2xx responses are retried with exponential backoff up to 5 total attempts.
Verification code
All examples read the raw body, enforce a 5-minute freshness window, and use a constant-time comparison.Rotating secrets
Every subscription has one signing secret. The secret is returned once — in the response toPOST /v1/webhooks — and never again. Store it in your secrets manager at creation time.
Retrieve the current secret
Retrieve the current secret
Rotate the secret
Rotate the secret
X-Webhook-Signature carries two comma-separated v1= entries. Your endpoint keeps verifying with the old secret while you roll the new one out, and starts matching the new secret the moment you deploy it. After 24 hours the previous secret stops signing and only the new secret is used.The grace window only helps if your verifier checks every
v1= entry (see the code samples above) — a verifier that reads only a single signature will reject deliveries during the window. Roll the new secret into your endpoint’s configuration within 24 hours of rotating; once deployed, the old secret is no longer needed.Common pitfalls
- Parsed JSON instead of raw bytes. Frameworks that parse and re-serialise the body (Express’s default
json()middleware, some ASP.NET pipelines) will break verification — whitespace, key ordering, and number formatting all change. Capture the raw body before parsing. - Wrong header case. HTTP headers are case-insensitive, but some frameworks expose them under specific casing. Read
X-Webhook-Signature/X-Webhook-Timestampvia your framework’s header API, not by exact-match dictionary lookup. - Assuming a single signature.
X-Webhook-Signaturecan carry more than one comma-separatedv1=entry (during a rotation grace window). Split on commas, strip thev1=prefix from each entry, and accept if any matches — a parser that reads only the first/whole value will reject valid deliveries mid-rotation. - Trailing newlines or encoding changes. A proxy or middleware that appends
\nor transcodes the body will invalidate the signature. - Using
==orstrcmp. Byte-by-byte equality checks leak timing information. Always use a constant-time comparison. - Not enforcing the timestamp window. Without a freshness check, an attacker who captures one valid delivery can replay it indefinitely. Reject anything older than 5 minutes.
- Replying slowly. Deliveries time out after 30 seconds. Acknowledge first, process asynchronously.