API docs

Webhooks guide

Events

EventWhenPayload
call.ringingOutbound call starts ringing (best-effort — not guaranteed on every call)light
call.answeredThe call is answeredlight
call.completedThe call ended normally and analysis is readyfull
call.failed / call.no_answer / call.busyThe call ended without a conversationfull
webhook.testYou 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.