Why verify
Your webhook endpoint is a public URL, so anyone who learns it can POST to it. Verifying the signature on each delivery proves two things: the request came from each::labs, and the body was not changed on the way. Signing is opt-in. Supply awebhook_secret when you create a prediction or trigger a workflow, and every delivery for that execution is signed. Deliveries without a secret are still sent, just unsigned.
Signature headers
A signed delivery carries two headers in addition to the payload:How the signature is computed
The signed string joins the timestamp and the body with a single. character:
webhook_secret as the key, and the result is hex-encoded in lowercase.
The timestamp is part of what gets signed, so a captured delivery cannot be replayed under a different one.
Verification steps
1
Read the two headers
Take
X-Webhook-Signature and X-Webhook-Timestamp from the request. Reject the delivery if either is missing, or if the signature does not begin with sha256=.2
Check the timestamp
Reject the delivery if the timestamp is not a number, or if it differs from your current clock by more than your tolerance. Five minutes is a reasonable default.
3
Rebuild the signed string
Join the timestamp, a
., and the raw body bytes.4
Compute the expected signature
HMAC-SHA256 the signed string with your
webhook_secret, then hex-encode it.5
Compare in constant time
Compare your value against the header using a constant-time comparison, never
==. A plain comparison leaks how much of the signature matched.Examples
Retries and the timestamp window
A delivery is attempted up to 3 times in total — the initial attempt plus 2 retries, roughly 10 seconds apart. All attempts of one delivery carry the same signature and the same timestamp; they are computed once, before the first attempt. So a retry can reach you a little after its timestamp was generated. A five-minute tolerance covers the whole retry window comfortably. Do not set a tolerance of only a few seconds. The tolerance is yours to choose and enforce — each::labs does not reject anything on your behalf.The X-Webhook-Secret header
Signed deliveries also carryX-Webhook-Secret, holding your secret as plain text. It predates signing and is still sent so that receivers built against it keep working.
Verify the signature, not this header. Comparing
X-Webhook-Secret only proves the sender knew the secret, and the secret travels in every delivery — so it does not establish that the body is unmodified. The signature does.Troubleshooting
The signature never matches. Almost always the body. Confirm you are hashing the exact bytes received, not a parsed-and-re-serialized copy. In Express,express.json() replaces the raw body with a parsed object; in Flask, use request.get_data() rather than request.json. Copying a payload out of a web-based request inspector usually re-indents it, which changes the bytes too.
It matches sometimes, not always. Check for non-ASCII characters in the payload and make sure you hash bytes rather than a decoded string.
Timestamps look far off. Compare your server clock against UTC. X-Webhook-Timestamp is Unix seconds, not milliseconds.
No signature headers at all. The execution was triggered without a webhook_secret, so the delivery is unsigned. Add the field to your trigger or prediction request.
Related
- Webhook Payload Reference — payload shapes and delivery behavior
- Webhooks Overview — setting up a webhook
- Workflow Webhooks — webhooks for workflow executions