Webhooks
At v1 each tenant configures exactly one webhook endpoint:
PUT /v1/webhook-endpoint with url, enabled_events[], and an
api_version_pin that freezes payload shapes so your receiver never has to
redeploy in lockstep with the platform. Deliveries are inspectable at
GET /v1/webhook-deliveries?event_id=….
Envelope and catalog
Section titled “Envelope and catalog”Every delivery is one event:
{ "id": "evt_01JA4R…", "type": "call.ended", "created_at": "2026-08-03T18:04:11Z", "api_version": "2026-07-01", "tenant_id": "ten_01J9XV…", "data": { "call_id": "call_01JA2…", "disposition": "completed_goal", "duration_ms": 143000 }}The schema catalog includes call lifecycle and analysis, campaign lifecycle, appointments, shift and survey results, recorded conversation actions, opt-outs, scrub/export completion, caller messages, tickets and lead capture. See Receive results for routing support, workforce and qualification receipts into one company integration.
appointment.booked is emitted when a configured live
phone call successfully creates an event through the tenant’s active direct
Google Calendar connection; it means the event write succeeded, not that an
attendee mailbox received the invite. optout.recorded is emitted when a
durable suppression is recorded. Tool-result events require that tool to be
available and successfully save its result; enabling an event does not grant a
tool or make an action succeed. usage.threshold remains schema-reserved with
no threshold-crossing emitter, so it does not currently send notifications.
Some resources are polled rather than pushed: import terminal states, agent publish
verdicts, invoice finalization, and endpoint auto-disable (delivered by email,
since a dead endpoint cannot receive its own obituary).
Verify the signature
Section titled “Verify the signature”Every delivery carries:
Vocapable-Signature: t=<unix>,v1=<hex hmac-sha256(secret, "{t}.{body}")>Verify against the raw request bytes before parsing anything, and reject
timestamps skewed more than 5 minutes. POST /v1/webhook-endpoint/rotate-secret
keeps the old and new secrets valid for 24 hours, so verify against every
secret you currently hold, and rotation never drops a delivery.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyVocapableSignature( header: string, rawBody: Uint8Array, secrets: string[], nowSeconds: number = Math.floor(Date.now() / 1000),): boolean { let timestamp: string | undefined; const signatures: Buffer[] = []; for (const part of header.split(",")) { const separator = part.indexOf("="); if (separator < 1) return false; const key = part.slice(0, separator).trim(); const value = part.slice(separator + 1).trim(); if (key === "t") { if (timestamp !== undefined || !/^(0|[1-9][0-9]*)$/.test(value)) return false; timestamp = value; } else if (key === "v1") { if (!/^[0-9a-fA-F]{64}$/.test(value)) return false; signatures.push(Buffer.from(value, "hex")); } } const t = Number(timestamp); if (!Number.isSafeInteger(t) || !Number.isFinite(nowSeconds) || signatures.length === 0) return false; if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;
return secrets.some((secret) => { const expected = createHmac("sha256", secret) .update(`${timestamp}.`) .update(rawBody) .digest(); return signatures.some((received) => timingSafeEqual(expected, received)); });}import hashlibimport hmacimport mathimport reimport time
TOLERANCE_SECONDS = 5 * 60
def verify_vocapable_signature( header: str, raw_body: bytes, secrets: list[str], now_seconds: float | None = None) -> bool: timestamp = None signatures = [] try: for part in header.split(","): key, value = (item.strip() for item in part.split("=", 1)) if key == "t": if timestamp is not None or not re.fullmatch(r"0|[1-9][0-9]*", value): return False timestamp = int(value) elif key == "v1": if not re.fullmatch(r"[0-9a-fA-F]{64}", value): return False signatures.append(bytes.fromhex(value)) except ValueError: return False now = time.time() if now_seconds is None else now_seconds if timestamp is None or timestamp > 2**53 - 1 or not signatures or not math.isfinite(now): return False if abs(now - timestamp) > TOLERANCE_SECONDS: return False
payload = f"{timestamp}.".encode() + raw_body return any( hmac.compare_digest(hmac.new(secret.encode(), payload, hashlib.sha256).digest(), received) for secret in secrets for received in signatures )These examples retain every v1 value so either signature can match during
rotation. Pass the original bytes (Buffer in Node), before JSON parsing or
reserialization. Answer 2xx after verification and durable acceptance; reject
an unverifiable delivery without processing it.
Delivery semantics: at-least-once, unordered
Section titled “Delivery semantics: at-least-once, unordered”A delivery counts on any 2xx within 10 seconds; otherwise exponential backoff with jitter (1m, 5m, 30m, 2h, 6h, then 6-hourly) for up to 24 hours, after which the event is marked exhausted. Three consecutive exhausted events auto-disable the endpoint, and you are notified by email.
Delivery is at-least-once and unordered at v1, so there is no
sequence field and no ordering guarantee. Two rules make a receiver
correct:
- Dedupe on
event.id. Persist processed ids; a replay of an id you have seen is a no-op. - Treat every handler as idempotent, and re-fetch the resource by id
when you need current state, because a
call.endedmay arrive before thecall.startedit follows.
Respond fast: accept, persist, return 2xx, process async. Ten seconds
includes your cold starts.