Skip to main content
Every webhook request includes a CallingBox-Signature header. Your server must verify this header on every request before doing any work — otherwise anyone who knows your URL can forge events.

Anatomy of the signature header

CallingBox-Signature: t=1713268860,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d2aa
  • t=<timestamp> — the unix second when CallingBox signed the payload.
  • v1=<hex> — HMAC-SHA256 of "<timestamp>.<raw_body>" using your endpoint’s signing secret. The header may eventually contain multiple v1= values during secret rotations; your code should accept a match against any of them.

Verify in Python

from callingbox import Callingbox

event = Callingbox.webhooks.construct_event(
    payload=request.body,             # raw bytes, NOT pre-parsed JSON
    sig_header=request.headers["CallingBox-Signature"],
    secret=os.environ["CALLINGBOX_WEBHOOK_SECRET"],
)
# event.type, event.data, event.id are now trustworthy
construct_event raises callingbox.BadRequestError on any mismatch, missing pieces, or timestamp drift greater than 300 seconds.

Verify in TypeScript / Node

import { Webhooks } from "callingbox";

const event = Webhooks.constructEvent(
  rawBody,                              // Buffer or string, NOT parsed JSON
  req.headers["callingbox-signature"] as string,
  process.env.CALLINGBOX_WEBHOOK_SECRET!,
);
// event.type, event.data, event.id are now trustworthy

Manual verification (any language)

If you aren’t using the SDKs, roll your own with any HMAC-SHA256 library:
  1. Split the header on ,; parse the t= timestamp and every v1= value.
  2. Check abs(now - t) <= 300 seconds. Reject otherwise.
  3. Compute HMAC_SHA256(secret, f"{t}.{raw_body}") and lowercase-hex-encode it.
  4. Compare against every v1= value with a constant-time comparison. Accept if any match.
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> None:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts["t"])
    if abs(int(time.time()) - ts) > tolerance:
        raise ValueError("timestamp drift too large")
    signed = f"{ts}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts["v1"]):
        raise ValueError("signature mismatch")

Always use the raw body

Parse JSON after verification, using the exact bytes the body came in with. If your framework has already parsed the body and re-serialized it, the signature will fail even for legitimate events. In Express use express.raw({ type: "application/json" }); in FastAPI read await request.body().

Secret rotation

Rotate a signing secret with:
curl -X POST https://api.callingbox.io/v1/webhook_endpoints/{id}/rotate_secret \
  -H "Authorization: Bearer YOUR_API_KEY"
The response returns the new signing_secret once. Previous signatures stop verifying immediately, so coordinate the rollout:
  1. Rotate and grab the new secret.
  2. Deploy the new secret to your consumers.
  3. Re-deliver any events that failed verification in the brief window (see Retries).

Per-call vs endpoint secrets

  • Account-level endpoints have their own signing_secret, scoped to that endpoint only.
  • Per-call webhook_url deliveries are signed with the organization’s default secret. Grab it from the dashboard under Webhooks → Default signing secret. Rotating it affects all inline per-call webhooks in the org.