Ship / Webhooks
Webhooks
Get an HTTPS request when a call starts, ends, or has a recording. Manage subscriptions in the dashboard under Webhooks, or from your server with a secret API key.
Subscribe
Every webhook route takes a dashboard session or a secret API key, so a server can subscribe itself. A publishable vl_pub_ key (the website widget’s) is refused. The URL must be public HTTPS.
curl -X POST https://api.vlayers.ai/v1/webhooks/subscriptions \
-H "Authorization: Bearer $VOICELAYER_API_KEY" \
-H "content-type: application/json" \
-d '{ "url": "https://example.com/voicelayer", "eventTypes": ["call.ended"] }'
# → 201 { "subscription": { "id": "…", "secret": "whsec_…", … } }
# The signing secret is shown once, here. Store it.GET /v1/webhooks/subscriptionsList subscriptions (never their secrets).POST /v1/webhooks/subscriptionsCreate one: url (https), eventTypes (empty = all), description.GET /v1/webhooks/subscriptions/:idRead one.PATCH /v1/webhooks/subscriptions/:idChange url, eventTypes, description, or enabled.DELETE /v1/webhooks/subscriptions/:idDelete one.GET /v1/webhooks/deliveriesRecent deliveries and their status (?subscriptionId=, ?limit=).POST /v1/webhooks/deliveries/:id/redeliverSend a delivery again.Events
Each delivery is a JSON envelope — id, type, createdAt, and the event in data.
call.started — The call’s room starts.
callId, startedAt, agentId, phoneNumberId, callerE164, toE164, direction. On an inbound call the caller usually joins a moment later, so the numbers can still be null here, and direction is null until it can be told (an inbound phone call and a web session look alike at this point). When set, it matches call.ended.
call.ended — The call finishes.
version (2), callId, durationMs, endedAt, endReason, agentId, phoneNumberId, callerE164, toE164, direction (inbound · outbound · web), outcome (completed · dropped · failed · no_answer · other), attributes (the values the agent synced onto the call), fields (every field the agent declares — its process, or a flow’s slots — with the captured value and whether it was filled), structuredOutputs (the filled fields as one object; absent when the agent declares none), transcript (the final turns — speaker, text, startMs, endMs — with turnCount, truncated and fetchPath), finalized, valuesOmitted. A number is null when the call never recorded it — a web session has a caller identity instead of a number, and a lost participant event can leave either empty.
recording.ready — A recording file is available.
callId, recordingUrl, status.
POST https://example.com/voicelayer
x-voicelayer-event: call.ended
x-voicelayer-delivery: <delivery id>
x-voicelayer-timestamp: 1767225600
x-voicelayer-signature: t=1767225600,v1=<hex HMAC-SHA256>
{
"id": "<event id>",
"type": "call.ended",
"createdAt": "2026-01-01T00:00:00.000Z",
"data": {
"version": 2,
"callId": "…",
"durationMs": 61000,
"endedAt": "2026-01-01T00:01:01.000Z",
"endReason": "caller_hangup",
"agentId": "…",
"phoneNumberId": "…",
"callerE164": "+15555550100",
"toE164": "+15555550199",
"direction": "inbound",
"outcome": "completed",
"attributes": { "policyNumber": "P-123" },
"fields": [
{ "name": "name", "type": "string", "required": true, "filled": true, "value": "Ada Lovelace" },
{ "name": "callback", "type": "phone", "required": false, "filled": false, "value": null }
],
"structuredOutputs": { "name": "Ada Lovelace" },
"transcript": {
"turns": [
{ "speaker": "agent", "text": "Thanks for calling — who am I speaking with?", "startMs": 0, "endMs": 2100 },
{ "speaker": "caller", "text": "Ada Lovelace.", "startMs": 3000, "endMs": 3900 }
],
"turnCount": 2,
"truncated": false,
"fetchPath": "/v1/calls/<callId>/result"
},
"finalized": true,
"valuesOmitted": false
}
}What call.ended carries
Versions. data.version is 2. Payloads only ever gain fields — none is renamed, retyped or removed — so a handler written for an earlier version keeps working.
The transcript. Final turns only (never an interim speech-to-text guess), in call order. It rides in the event whole when its turns are at most 256 KB as JSON (UTF-8). Past that, transcript.turns is empty and transcript.truncated is true — never a partial transcript — and the whole one is at GET {fetchPath} (/v1/calls/:callId/result, with a secret API key), with the cost and the audit. turnCount always says how many final turns the call has.
The whole event is at most 512 KB. Past that, valuesOmitted is true and things are left out in this order until it fits: the field values (each field still says whether it was filled) and structuredOutputs; then the inline transcript; then attributes. Fetch them from /v1/calls/:callId/result (the attributes from GET /v1/calls/:callId).
When it is sent. The event waits for the agent’s worker to confirm its last transcript turn and captured field have landed — usually a second or two after the hangup — so it carries the whole call (finalized: true). If the worker never confirms (it crashed, or runs an SDK before 0.6.2), the event goes after at most 15 seconds with what had arrived, saying finalized: false; GET /v1/calls/:callId/result then has anything later.
Real values. The numbers, the captured fields and the transcript are your callers’ actual words and values — your own data, sent to the endpoint you configured (public HTTPS only, signed). Treat the endpoint and its logs as holding personal data. What the call audit masks (tool arguments and results, host instructions) is never in a webhook.
Verify the signature
x-voicelayer-signature is t=<unix seconds>,v1=<hex>, where the hex is an HMAC-SHA256 of <t>.<raw body> keyed with the subscription’s secret. Reject a request whose signature doesn’t match, or whose timestamp is old.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (before JSON.parse).
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(parts.v1 ?? '');
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Retries
Answer with any 2xx within 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes and 2 hours; after the fifth try it is marked failed — you can send it again from the dashboard or with the redeliver route. A delivery can arrive more than once, so de-duplicate on the envelope’s id.