Events and logs
Webhooks
Get a signed HTTP request for every call event instead of polling. Failed deliveries are retried with increasing delays, up to 8 attempts.
Register an endpoint
POST
/v1/webhooks Body parameters
| Field | Type | Description |
|---|---|---|
urlrequired | string | A public HTTPS URL that receives the POSTs. Private and loopback addresses are rejected. |
eventsoptional | string[] | The event types to send. Leave it empty to get every event. |
curl -X POST http://localhost:8000/v1/webhooks \
-H "Authorization: Bearer $OFFHOOK_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://you.example.com/hook", "events": [] }'{
"id": "2f7b9d4a-8c1e-4b3f-a6d2-9e5c1f7a3b80",
"url": "https://you.example.com/hook",
"events": [],
"enabled": true,
"secret": "whsec_…",
"created_at": "2026-10-15T10:00:00Z"
} Event types
| Event | Sent when |
|---|---|
| call.queued | The call was accepted and is waiting to be dialed. |
| call.dialing | The carrier is dialing the number. |
| call.in_progress | The call connected and the conversation is live. |
| call.completed | The call ended normally. Includes the outcome summary, duration and number of turns. |
| call.failed | The call ended in an error. Includes an error_code. |
| call.received | An inbound call arrived on one of your numbers. |
| carrier.hangup | The carrier reported why the call ended (busy, no answer and so on). |
Payload
Each delivery is a POST with a JSON body like this, and these headers:
{
"id": "…",
"event": "call.completed",
"created": 1792065645,
"data": {
"call_id": "8f2c4d1e-6b7a-4c2e-9f1d-3a5b7c9e1f20",
"status": "completed",
"summary": "Confirmed for Tuesday at 3 PM",
"duration_seconds": 38,
"turns": 6,
"error": null
}
}X-Voice-Event: call.completed
X-Voice-Delivery: <delivery id>
X-Voice-Signature: t=1792065645,v1=4f1c… Your endpoint should reply with any 2xx status. Anything else counts as a failure and is retried.
Check the signature
X-Voice-Signature has the form t=<unix time>,v1=<signature>.
The signature is an HMAC-SHA256 of {t}.{raw body}, keyed
with your endpoint's secret. Compute it yourself, compare the two in constant time,
and reject old timestamps so a captured request can't be replayed.
import hmac, hashlib, time
def verify(secret, body, header, tolerance=300):
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.{body}".encode(),
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)import crypto from "node:crypto";
function verify(secret, body, header, tolerance = 300) {
const p = Object.fromEntries(header.split(",").map(s => s.split("=")));
const t = Number(p.t);
if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${t}.${body}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1));
} List and delete
GET
/v1/webhooks DELETE
/v1/webhooks/{id}