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>)
  • t is the Unix timestamp (in seconds) at which we signed the request.
  • v1 is the HMAC-SHA256 of the string <t>.<body>, encoded as lowercase hex.
  • secret is the shared secret we exchange with you at onboarding.

Verification rules

  1. 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.
  2. Compute the MAC over the raw body bytes exactly as received. Do not parse and re-serialize the JSON — key order and whitespace matter.
  3. Compare MACs with a constant-time comparison to avoid timing attacks.
  4. Enforce a replay window. Reject requests whose t differs from your clock by more than 5 minutes.
  5. 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. The eventId and the body stay the same across attempts: deduplicate on eventId, 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 the verify callback of express.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):

InputValue
Secrettest-secret
Timestamp t1704067200
Body{"eventType":"plan.launched"}
Expected v10a960844998fe74bff71706818e35b884820d2de2c69eadcb8c80e31f8e859a0

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.


Did this page help you?