API docs
Webhooks guide
Events
| Event | When | Payload |
|---|---|---|
call.ringing | Outbound call starts ringing (best-effort — not guaranteed on every call) | light |
call.answered | The call is answered | light |
call.completed | The call ended normally and analysis is ready | full |
call.failed / call.no_answer / call.busy | The call ended without a conversation | full |
webhook.test | You clicked/POSTed Send test | {"test": true, …} |
Light payload data: call_id, direction, from, to, agent_id, status, external_ref.
Full payload data: everything in the call object — including timings, outcome, summary, credits_charged, external_ref. call.completed is sent once analysis lands (typically seconds after hangup; never later than ~90s).
Envelope & headers
{"id": "<event uuid>", "type": "call.completed", "created_at": "<ISO 8601>", "api_version": "2026-06-04", "data": { … }}
Content-Type: application/json
User-Agent: VoiceCallingAI-Webhooks/1.0
X-VCA-Event-Id: <event uuid>
X-VCA-Event-Type: call.completed
X-VCA-Signature: t=<unix seconds>,v1=<hex>
Verify signatures
v1 = HMAC_SHA256(signing_secret, "{t}." + raw_body) over the exact raw bytes received. Always verify, use a constant-time comparison, and reject stale timestamps.
Python:
import hmac, hashlib, time
def verify_vca_signature(sig_header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) <= tolerance
return hmac.compare_digest(expected, parts["v1"]) and fresh
Node (Express):
const crypto = require("crypto");
const express = require("express");
const app = express();
app.post("/hooks/voicecallingai", express.raw({ type: "application/json" }), (req, res) => {
const parts = Object.fromEntries(req.get("X-VCA-Signature").split(",").map(p => p.split("=")));
const expected = crypto.createHmac("sha256", process.env.VCA_SIGNING_SECRET)
.update(`${parts.t}.`).update(req.body).digest("hex");
const expectedBuf = Buffer.from(expected);
const givenBuf = Buffer.from(parts.v1 || "");
const valid = givenBuf.length === expectedBuf.length
&& crypto.timingSafeEqual(expectedBuf, givenBuf)
&& Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body);
console.log(event.type, event.data.outcome);
res.status(200).end(); // respond fast; process async
});
Use the raw request body for verification — JSON re-serialization breaks the signature.
Retries & auto-disable
A delivery succeeds on any 2xx within 10 seconds. Otherwise it retries with exponential backoff — waits of **60s, 120s, 240s, and 480s** after successive failures, **5 attempts in total** — after which it's `exhausted`. **10 consecutive exhausted deliveries auto-disable the subscription** (`status: "failed"`; a banner appears in the dashboard). Fix your endpoint, then set it back to Active — auto-disable resets on the next success.Consumer best practices
Respond 200 immediately and process asynchronously · verify the signature first, always · deduplicate on the envelope id (retries re-send the same event id) · don’t rely on ordering (use data.timings/created_at) · treat call.ringing as best-effort · store your whsec_ like a password and rotate via the API or dashboard.