Platform ToolsVerifying Signatures

Verifying signatures

Authenticate every delivery against the raw body before you parse it, and reject replays.

Your webhook URL is a public HTTPS endpoint. Anyone who learns it can POST to it. The signature proves a request came from us, so verify it before you parse the body and before you do any work.

Prerequisites

  • Your endpoint's signing secret, the secret returned once by POST /api/webhooks when you registered the endpoint. Secrets are per endpoint and per environment. Store them the way you store API keys.
  • Access to the raw request body bytes, before any JSON middleware parses them.

Verify against the raw bytes. Whitespace, key order, and unicode escaping are all part of what was signed, and any of them can differ after a parse-and-reserialize round trip. Capture the body before the JSON middleware runs, or disable the middleware for this route.

The scheme

Deliveries are signed with a Standard Webhooks compatible HMAC scheme. Three headers ride each delivery:

HeaderContents
webhook-idThe id the delivery was published under. It equals the envelope id, except on a manual replay
webhook-timestampUnix seconds at signing time
webhook-signatureOne or more space-separated signatures, each v1,<base64>

The signed content is the three parts joined by periods:

signed_payload = "{webhook-id}.{webhook-timestamp}.{raw_body}"
expected       = base64(HMAC_SHA256(secret_bytes, signed_payload))

The secret is displayed with a whsec_ prefix. Strip the prefix and base64-decode the remainder to get the key bytes.

webhook-signature can carry several signatures, space separated. That is how secret rotation works without dropped deliveries: accept the delivery if any of them matches. Compare with a constant-time comparison, never == on strings.

Verifying

Reject stale timestamps

Require webhook-timestamp to be within a tolerance window of your own clock. Five minutes is typical. Without this check, a captured delivery can be replayed forever, because the signature over it stays valid.

Recompute and compare

Compute expected as above and compare it against each v1, entry in constant time. Any match accepts the delivery.

Only then parse

Parse the JSON after verification passes, not before. A payload that failed verification should never reach your business logic, your logs at info level, or your queue.

Deduplicate and acknowledge

Deduplicate on the envelope id, not on webhook-id: a replay is published under a new delivery id so that it is not dropped, while the envelope id stays the same. Store the payload, return 2xx, then do the work asynchronously, as described in Processing.

Reference implementation

import base64, hashlib, hmac, time

TOLERANCE_SECONDS = 300

def verify(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
    """Return True when the delivery is authentic and fresh."""
    event_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signatures = headers["webhook-signature"].split(" ")

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{event_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    return any(
        hmac.compare_digest(expected.encode(), candidate[3:].encode())
        for candidate in signatures
        if candidate.startswith("v1,")
    )

What to do on failure

Return a 4xx and log it with the webhook-id. Do not process the payload, and do not return 2xx to make the retries stop: a genuine delivery that fails verification means your secret is stale or your body handling is wrong, and suppressing the retry hides both.

A burst of verification failures you did not cause is worth an alert.