Skip to main content

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 a webhook_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:
That string is passed through HMAC-SHA256 using your 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.
Compute the HMAC over the raw bytes of the request body, exactly as received.Parsing the JSON and re-serializing it changes the bytes — key order, spacing and indentation all differ — and the signature will not match. This is the most common reason verification fails.

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 carry X-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.
Last modified on September 21, 2026