Webhooks
Rather than polling, register an HTTPS endpoint and we will post each event to it as it happens, signed so you can prove it came from us.
Events
| Field | Type | Description |
|---|---|---|
| call.completed | event | A call ended and the transcript and summary are ready. |
| call.transferred | event | The agent handed the caller to a human. |
| opportunity.created | event | A property enquiry was scored against the buy box. |
| opportunity.updated | event | Owner, outcome or note changed. |
| lead.created | event | A new caller was captured. |
| lead.updated | event | Lead status, owner or note changed. |
| booking.created | event | A callback or viewing was placed on the calendar. |
| agent.published | event | A new agent configuration went live. |
Payload
Every delivery has the same shape. data matches the corresponding API object, so the same parsing code works for both.
{
"id": "evt_4c9a1f",
"type": "opportunity.created",
"created_at": "2026-09-09T08:46:03Z",
"workspace_id": "ws_7Kd2",
"data": {
"id": "opp_31a",
"call_id": "call_9f21",
"address": "14 Hollybank Road, Walsall",
"postcode": "WS3 2NX",
"score": 88,
"outcome": "hot"
}
}Verifying the signature
Each request carries X-Call-Agent-Signature and X-Call-Agent-Timestamp. The signature is an HMAC-SHA256 of {timestamp}.{raw body} using your endpoint's signing secret. Compute it over the raw bytes — parsing the JSON first will change the bytes and break the check.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, timestamp: string, secret: string) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) return false; // reject replays older than 5 minutes
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}401 and do not process it. Never log the signing secret.Retries and duplicates
Respond 2xx within 10 seconds. Anything else is retried with exponential backoff for up to 24 hours. That means an event can arrive more than once, so treat event.id as an idempotency key: record the IDs you have processed and ignore repeats. Order is not guaranteed — compare created_at before overwriting newer state.
Acknowledge first, work afterwards. Queue the payload and return 200 immediately rather than doing slow work inside the request.
Registering an endpoint
curl -X POST https://<your-api-host>/api/v1/webhooks \
-H "Authorization: Bearer cak_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://crm.example.com/hooks/call-agent",
"events": ["call.completed", "opportunity.created", "lead.updated"]
}'The response includes the signing secret once. Endpoints must be HTTPS and publicly reachable; self-signed certificates are rejected.
