Verifying Kalba.ai webhook signatures
Every webhook POST carries:
Kalba-Signature: t=<unix seconds>,v1=<hex hmac_sha256(secret, t + "." + rawBody)>
Content-Type: application/json
secret is the whsec_... value shown once when you created the endpoint
(POST /v1/webhook-endpoints). Verify with a tolerance of ±300 seconds and a
constant-time comparison. Always verify against the RAW request body bytes —
re-serializing the parsed JSON will change key order/whitespace and break the HMAC.
Respond with any 2xx within 10 seconds. Non-2xx (and redirects) are retried on a
1 m → 5 m → 30 m → 2 h → 12 h backoff, then marked exhausted — missed events are
always replayable via GET /v1/events?after=<last seen evt_ id>.
Node / TypeScript
import { createHmac, timingSafeEqual } from "crypto"
export function verifyKalbaSignature(
secret: string,
signatureHeader: string,
rawBody: string,
toleranceSec = 300
): boolean {
const parts: Record<string, string> = {}
for (const part of signatureHeader.split(",")) {
const [k, v] = part.split("=")
if (k && v) parts[k.trim()] = v.trim()
}
const t = parts["t"]
const v1 = parts["v1"]
if (!t || !v1 || !Number.isFinite(Number(t))) return false
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
const a = Buffer.from(v1, "utf8")
const b = Buffer.from(expected, "utf8")
return a.length === b.length && timingSafeEqual(a, b)
}
// Express example — note express.raw() to keep the raw bytes:
import express from "express"
const app = express()
app.post("/kalba-hook", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyKalbaSignature(
process.env.KALBA_WEBHOOK_SECRET!,
req.header("Kalba-Signature") ?? "",
req.body.toString("utf8")
)
if (!ok) return res.status(401).send("bad signature")
const event = JSON.parse(req.body.toString("utf8"))
// ... handle event.type / event.data (dedupe by event.id) ...
res.status(200).send("ok")
})
Python
import hashlib
import hmac
import time
def verify_kalba_signature(secret: str, signature_header: str, raw_body: bytes,
tolerance_sec: int = 300) -> bool:
parts = {}
for part in signature_header.split(","):
if "=" in part:
k, v = part.split("=", 1)
parts[k.strip()] = v.strip()
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or not t.isdigit():
return False
if abs(time.time() - int(t)) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask example — request.get_data() returns the raw bytes:
# from flask import Flask, request
# app = Flask(__name__)
#
# @app.post("/kalba-hook")
# def kalba_hook():
# if not verify_kalba_signature(KALBA_WEBHOOK_SECRET,
# request.headers.get("Kalba-Signature", ""),
# request.get_data()):
# return "bad signature", 401
# event = request.get_json()
# # ... handle event["type"] / event["data"] (dedupe by event["id"]) ...
# return "ok", 200
Notes
- No ordering guarantee — order/dedupe by
event.id/event.created. - Test-mode endpoints only ever receive
"mode": "test"events; live endpoints only live. - Unknown event
typevalues must be ignored, not errored (additive versioning).