Skip to main content
People search, enrichment, and outreach. Get started with the clodo API
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

  1. Read the raw bytes of the request body. Do not parse and re-serialize — key order would change and the HMAC would not match.
  2. Parse t and v1 from the Clodo-Signature header.
  3. Reject if abs(now - t) > 300 (5-minute replay window).
  4. Compute expected = HMAC_SHA256(secret_utf8, f"{t}.".encode("ascii") + body_bytes).hexdigest().
  5. Constant-time compare expected with v1.

#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 ==. Use hmac.compare_digest (Python) or crypto.timingSafeEqual (Node).
  • Trusting t without bounds-checking. The 5-minute window is the replay defense.

#See also

clodo Docs