Skip to content

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=….

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).

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.

verify.ts
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));
});
}
verify.py
import hashlib
import hmac
import math
import re
import 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:

  1. Dedupe on event.id. Persist processed ids; a replay of an id you have seen is a no-op.
  2. Treat every handler as idempotent, and re-fetch the resource by id when you need current state, because a call.ended may arrive before the call.started it follows.

Respond fast: accept, persist, return 2xx, process async. Ten seconds includes your cold starts.