Verifying webhook signatures
Every webhook delivery is signed with HMAC-SHA256 over the raw request body. Verify the X-Callplus-Signature header before trusting a payload.
Every webhook delivery carries an X-Callplus-Signature header:
X-Callplus-Signature: t=1786356000,v1=<64 lowercase hex characters>
where
v1 = HMAC_SHA256(secret, "<t>" + "." + <raw request body bytes>)
tis the Unix timestamp (in seconds) at which we signed the request.v1is the HMAC-SHA256 of the string<t>.<body>, encoded as lowercase hex.secretis the shared secret we exchange with you at onboarding.
Verification rules
- Use the secret string as the key, verbatim. The secret is a hex-looking string, but it is used as-is (its UTF-8 bytes) as the HMAC key — do not hex-decode it first.
- Compute the MAC over the raw body bytes exactly as received. Do not parse and re-serialize the JSON — key order and whitespace matter.
- Compare MACs with a constant-time comparison to avoid timing attacks.
- Enforce a replay window. Reject requests whose
tdiffers from your clock by more than 5 minutes. - Only then parse the JSON and process the event.
Retries are signed fresh. Every delivery attempt — including a retry minutes or hours after the first — is signed at send time with a new
t, so a legitimate retry always passes your replay-window check. TheeventIdand the body stay the same across attempts: deduplicate oneventId, never on the signature or timestamp.
Python
import hashlib
import hmac
import time
def compute_signature(secret: str, timestamp: int, body: bytes) -> str:
signed_payload = str(timestamp).encode() + b"." + body
return hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
def verify_webhook(signature_header: str, raw_body: bytes, secret: str,
tolerance_seconds: int = 300) -> bool:
try:
parts = dict(item.split("=", 1) for item in signature_header.split(","))
timestamp = int(parts["t"])
received = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance_seconds:
return False # outside the replay window
expected = compute_signature(secret, timestamp, raw_body)
return hmac.compare_digest(expected, received)Node.js
const crypto = require("node:crypto");
function computeSignature(secret, timestamp, body) {
return crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(body)
.digest("hex");
}
function verifyWebhook(signatureHeader, rawBody, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((item) => {
const i = item.indexOf("=");
return i === -1 ? [item, ""] : [item.slice(0, i), item.slice(i + 1)];
})
);
const timestamp = Number(parts.t);
const received = parts.v1;
if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/.test(received ?? "")) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; // replay window
const expected = computeSignature(secret, timestamp, rawBody);
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}Raw body, not the parsed body. In Express, use
express.raw({ type: "application/json" })(or theverifycallback ofexpress.json) so you can access the exact bytes received. Frameworks that hand you only parsed JSON cannot verify signatures reliably.
Test vector
Check your implementation against this vector before going live (it is also how the examples above were validated):
| Input | Value |
|---|---|
| Secret | test-secret |
Timestamp t | 1704067200 |
| Body | {"eventType":"plan.launched"} |
Expected v1 | 0a960844998fe74bff71706818e35b884820d2de2c69eadcb8c80e31f8e859a0 |
Reproduce it from a shell:
printf '%s' '1704067200.{"eventType":"plan.launched"}' | openssl dgst -sha256 -hmac 'test-secret'This vector is for testing only. Your real secret is longer (64 hex characters) and must never appear in code, logs or version control. Store it in a secrets manager.
Updated 15 days ago