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.

Redeliveries are signed again

A redelivery sends the original payload a second time, and it is signed when it is sent. Its X-Webhook-Signature and X-Webhook-Timestamp therefore differ from the original delivery, even though the body is identical. This is what you want: the timestamp is current, so a redelivery passes the tolerance check like any other delivery. Two things follow.
  • A repeated body is expected. A redelivery sends a body you have already seen, with a current timestamp and its own valid signature. Verify it exactly as you would a first delivery — the tolerance check is about when the delivery was signed, not about whether the body is new. Nothing is loosened by accepting it: a delivery signed long ago still carries that old timestamp and still falls outside your tolerance.
  • Do not use the signature to detect duplicates. Each delivery of the same body is signed separately, so their signatures will not match. Use execution_id from the body to recognise work you have already done.
A redelivery also carries X-Webhook-Redelivery: true, which an ordinary delivery never sends. Headers are not part of the signed string, so use it for logging rather than as proof — the signature covers the body and the timestamp only.

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 25, 2026