Webhooks
Signature Verification
Verify that a webhook came from clodo before processing its payload.
Every webhook delivery includes a Clodo-Signature header. Verify it against the raw request body using your webhook signing secret.
#Header format
Clodo-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>t— unix seconds when we signed the request.v1— lowercase hex HMAC-SHA256 over<t>.<body_bytes>, keyed with your secret.
#Algorithm
- Read the raw bytes of the request body. Do not parse and re-serialize — key order would change and the HMAC would not match.
- Parse
tandv1from theClodo-Signatureheader. - Reject if
abs(now - t) > 300(5-minute replay window). - Compute
expected = HMAC_SHA256(secret_utf8, f"{t}.".encode("ascii") + body_bytes).hexdigest(). - Constant-time compare
expectedwithv1.
#Python verifier
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify(secret: str, body: bytes, signature_header: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
if "t" not in parts or "v1" not in parts:
return False
try:
t = int(parts["t"])
except ValueError:
return False
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode("ascii") + body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Flask example:
@app.post("/webhooks/clodo")
def receive():
body = request.get_data() # raw bytes
if not verify(SECRET, body, request.headers.get("Clodo-Signature", "")):
return "", 401
payload = json.loads(body)
# ... handle payload ...
return "", 200#Node verifier
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verify(secret, body, signatureHeader) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2)),
);
if (!parts.t || !parts.v1) return false;
const t = parseInt(parts.t, 10);
if (!Number.isFinite(t)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
const signed = Buffer.concat([Buffer.from(`${t}.`, "ascii"), body]);
const expected = crypto
.createHmac("sha256", secret)
.update(signed)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Express example (use express.raw so req.body stays as bytes):
app.post(
"/webhooks/clodo",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verify(SECRET, req.body, req.get("Clodo-Signature") || "");
if (!ok) return res.status(401).end();
const payload = JSON.parse(req.body.toString("utf8"));
// ... handle payload ...
res.status(200).end();
},
);#Common mistakes
- Parsing the JSON before verifying. Most frameworks consume the body as a stream; once consumed it is gone. Use raw-body middleware (
express.raw,request.get_data(), etc.) and verify against the bytes. - Comparing strings with
==. Usehmac.compare_digest(Python) orcrypto.timingSafeEqual(Node). - Trusting
twithout bounds-checking. The 5-minute window is the replay defense.
#See also
- Webhook Signing Secrets for minting and rotating the secret.
- Webhook Events for the catalog of events and their payload shapes.
- Webhook Retry Policy for retry schedule.