Skip to main content
Every webhook request includes a signature in the Webhook-Signature header. You should verify this signature to confirm the request is authentic and hasn’t been tampered with before acting on it. The signature is computed using HMAC-SHA256 over the delivery ID, timestamp, and raw request body.

Signature format

The Webhook-Signature header contains one or more signatures in the format:
v1,{hex_digest}
During secret rotation, multiple signatures may be present, separated by spaces:
v1,a1b2c3d4e5f6... v1,f6e5d4c3b2a1...
A request is valid if any of the signatures matches your computed value.

Verification steps

1

Extract the headers

Read the Webhook-Signature, Webhook-Id, and Webhook-Timestamp headers from the incoming request.
2

Prepare the signed payload

Concatenate the timestamp, the delivery ID, and the raw request body, separated by periods (.):
{webhook_timestamp}.{webhook_id}.{raw_body}
3

Compute the expected signature

Compute an HMAC-SHA256 using your webhook signing secret as the key and the prepared string as the message.
4

Compare signatures

Split the Webhook-Signature header on spaces. The request is valid if any of the provided signatures matches your computed value. Always use a constant-time comparison function to prevent timing attacks.
Use the raw request body when computing the HMAC. Parsing the JSON first and re-serializing it can silently alter the payload — for example, some languages round floating-point numbers or reorder keys — which will cause the signature check to fail.

Examples

import hmac
import hashlib

def verify_webhook(payload: bytes, headers: dict, secret: str) -> bool:
    timestamp = headers["Webhook-Timestamp"]
    delivery_id = headers["Webhook-Id"]
    signatures = headers["Webhook-Signature"].split(" ")

    signed_payload = f"{timestamp}.{delivery_id}.".encode() + payload
    computed = hmac.new(
        secret.encode(),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()
    expected = f"v1,{computed}"

    return any(hmac.compare_digest(expected, sig) for sig in signatures)

Secret rotation

When we rotate a webhook signing secret, both the old and new secrets remain active during a transition period. During this window, we sign each delivery with all active secrets and include their signatures in the Webhook-Signature header, separated by spaces. For example:
Webhook-Signature: v1,a1b2c3d4e5f60718293a4b5c6d7e8f90... v1,f6e5d4c3b2a10987fedc6b5a4938271...
Your verification code should accept the delivery if any of the signatures is valid. This gives you a grace period to update your stored secret without missing any deliveries. When we rotate a secret, here’s what to expect:
  1. We’ll notify you that a rotation is starting.
  2. Both the old and new secrets become active — deliveries are signed with both.
  3. You have 48 hours to update your verification code to use the new secret.
  4. After 48 hours, the old secret is retired and only the new secret will be used to sign deliveries.
Make sure to update your stored secret within the 48-hour window. Once the old secret is retired, deliveries will only be signed with the new secret and your verification will fail if you’re still using the old one.

Rejecting stale webhooks

To protect against replay attacks, we recommend checking the Webhook-Timestamp header and rejecting any webhook where the timestamp is more than 5 minutes from your server’s current time.
import time

def is_timestamp_valid(headers: dict, tolerance_seconds: int = 300) -> bool:
    timestamp = int(headers["Webhook-Timestamp"])
    return abs(time.time() - timestamp) < tolerance_seconds
Always validate the timestamp before processing the webhook. Replayed requests with old timestamps should be discarded even if the signature is valid.