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 is what separates a delivery from us out of everything else that arrives, so verify it before you parse the body and before you do any work.

Prerequisites

  • Your endpoint's signing secret, shown in the delivery portal (GET /api/webhooks/portal) alongside the endpoint. Secrets are per endpoint and per environment. Store them the way you store API keys.
  • Access to the raw request body bytes. Most frameworks parse JSON before your handler runs, and re-serializing the parsed object will not reproduce the signed bytes.

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, or disable it for this route.

The scheme

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

HeaderContents
webhook-idThe event id, the same value as id in the envelope
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 prefixed (whsec_) when displayed. 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

Build signed_payload, HMAC it with the decoded secret, base64 encode the result, and compare it against each v1, entry using a constant-time comparison. 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

Check webhook-id against your processed-ids table, store it, and return 2xx. Then do the work asynchronously. See Webhooks.

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, candidate.split(",", 1)[1])
        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.