Skip to main content

Verifying Webhook Signatures

Webhooks sent for integrations with a configured secret are signed. This page explains the signing scheme, why it holds up, and how to verify a signature in your own endpoint, with working examples in Node.js and Python.

How it works

When an event happens on an integration with a webhook secret configured, we build a JSON payload, compute a signature over it using that secret, and send both to your webhookUrl in a single POST request.

Nobody without your secret can produce a valid signature for a given payload, and any change to the payload, even a single byte, changes the signature. That is what lets you trust that a request landing on your endpoint actually came from us and was not altered on the way.

Why this is secure

  • HMAC-SHA256 ties the payload to a secret only you and we know. Forging a valid signature without the secret is computationally infeasible.
  • A timestamp is folded into the signed string, not just the payload. Someone who captures a valid request cannot replay it later, because your endpoint rejects anything outside a short freshness window.
  • Comparisons are constant time. Both examples below use crypto.timingSafeEqual and hmac.compare_digest instead of ==, so a timing attack cannot be used to guess the correct signature one byte at a time.
  • Secrets are generated server side with a cryptographically secure random generator, never chosen or guessable, and shown to you exactly once at generation time. After that we only store an encrypted copy.
  • Secrets are per integration. A leaked secret only affects the one integration it belongs to.
  • Delivery only happens over HTTPS. We reject non-HTTPS webhook URLs when you save them, so the payload itself is also protected in transit, on top of the signature.

The signature header

Every signed delivery includes:

HeaderDescription
X-Webhook-Signaturet=<unix timestamp>,v1=<hex HMAC-SHA256 digest>
X-Webhook-IdA unique id for this delivery, useful for logging and deduplication
X-Webhook-TimestampSame value as t above, provided for convenience

Example:

X-Webhook-Signature: t=1737038421,v1=5257a869e7bf...
X-Webhook-Id: 8f14e45f-ceea-4c1f-9a1c-0f8a3f2a9f61
X-Webhook-Timestamp: 1737038421

Use the t value from inside X-Webhook-Signature when recomputing the digest. X-Webhook-Timestamp currently always matches it, but treat the copy inside the signature as the source of truth.

The payload

The request body is JSON with three top level fields:

{
"id": "8f14e45f-ceea-4c1f-9a1c-0f8a3f2a9f61",
"event": "validation.updated",
"data": {}
}

id matches X-Webhook-Id. event tells you what happened. data is the event specific payload.

Verifying a signature

  1. Get the raw request body bytes, exactly as received. Do not use a version that has already been parsed and would be re-serialized differently.
  2. Read X-Webhook-Signature and split it into t and v1.
  3. Reject the request if t is more than a few minutes away from your current time.
  4. Compute HMAC-SHA256(secret, "t.rawBody") and hex encode it.
  5. Compare the result to v1 using a constant time comparison. Reject on mismatch.
  6. Only after all checks pass, parse the body as JSON and process the event.

Your secret is a base64url encoded string, but use it exactly as copied, as the raw key material for the HMAC. Do not base64 decode it first.

Node.js (Express)

const express = require("express");
const crypto = require("crypto");

const app = express();

// Keep the raw bytes around before they get parsed into an object.
app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
},
}),
);

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

function verifySignature(rawBody, header, secret) {
if (!header) {
throw new Error("Missing signature header");
}

const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=")),
);
const { t: timestamp, v1: providedSignature } = parts;

if (!timestamp || !providedSignature) {
throw new Error("Malformed signature header");
}

const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > TOLERANCE_SECONDS) {
throw new Error("Timestamp outside tolerance window");
}

const expectedSignature = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody.toString("utf8")}`)
.digest("hex");

const expected = Buffer.from(expectedSignature, "hex");
const provided = Buffer.from(providedSignature, "hex");

if (
expected.length !== provided.length ||
!crypto.timingSafeEqual(expected, provided)
) {
throw new Error("Signature mismatch");
}
}

app.post("/webhooks/inbound", (req, res) => {
try {
verifySignature(
req.rawBody,
req.header("X-Webhook-Signature"),
WEBHOOK_SECRET,
);
} catch (err) {
console.error("Webhook rejected:", err.message);
return res.status(401).send("Invalid signature");
}

const { event, data } = req.body;
// handle the event here

res.status(200).send("ok");
});

app.listen(3000);

Python (Flask)

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300


def verify_signature(raw_body: bytes, header: str, secret: str) -> None:
if not header:
raise ValueError("Missing signature header")

parts = dict(part.split("=", 1) for part in header.split(","))
timestamp = parts.get("t")
provided_signature = parts.get("v1")

if not timestamp or not provided_signature:
raise ValueError("Malformed signature header")

if abs(time.time() - float(timestamp)) > TOLERANCE_SECONDS:
raise ValueError("Timestamp outside tolerance window")

signed_payload = f"{timestamp}.".encode() + raw_body
expected_signature = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()

if not hmac.compare_digest(expected_signature, provided_signature):
raise ValueError("Signature mismatch")


@app.route("/webhooks/inbound", methods=["POST"])
def handle_webhook():
try:
verify_signature(
request.get_data(),
request.headers.get("X-Webhook-Signature"),
WEBHOOK_SECRET,
)
except ValueError as err:
app.logger.warning("Webhook rejected: %s", err)
abort(401)

payload = request.get_json()
event = payload["event"]
data = payload["data"]
# handle the event here

return "", 200

request.get_data() returns the raw body regardless of whether get_json() has already been called, so Flask does not have the re-serialization problem that some other frameworks do.

Common mistakes

  • Verifying against the parsed and re-serialized body instead of the raw bytes. Most frameworks parse JSON before your handler runs. Re-serializing the parsed object rarely produces the exact same bytes we signed, so the signature check fails even though nothing is actually wrong. Capture the raw body before parsing, as shown above.
  • Base64 decoding the secret. Use it as the literal string from the dashboard.
  • Skipping the timestamp check. Without it, a captured request can be replayed indefinitely.
  • Comparing signatures with == or ===. Use hmac.compare_digest or crypto.timingSafeEqual.
  • Committing the secret to source control. Store it in your secrets manager or environment variables, the same as any other credential.

Generating and rotating your secret

From your integration's settings page, use the "Generate" or "Regenerate" button under Webhook signing secret. The plaintext value is shown once, immediately after generation, and cannot be retrieved again. Copy it into your own secrets store right away.

Regenerating creates a brand new secret and immediately invalidates the previous one. Any endpoint still checking against the old value starts rejecting valid webhooks until it is updated, so update your stored copy at the same time you regenerate.