Receive with webhooks
Get a signed HTTPS request the moment someone writes to you, a message is delivered, or a call ends.
What you'll build
An HTTPS endpoint that receives VoiceLayer events, checks each one really comes from VoiceLayer, and answers fast. Then you subscribe it to the events you care about.
Prerequisites
- A secret API key with the
webhooks:managescope. See Authentication and API keys. You can also manage webhooks in the dashboard under Developers → Webhooks. - A public
https://URL that reaches your endpoint. While developing, a tunnel to your machine works. - Node.js 18 or later with
@voicelayer/api, or Python 3.9 or later withvoicelayer, or any language that can compute an HMAC-SHA256.
Step 1: Write the endpoint
Every delivery is a POST with a JSON body and these headers:
content-type: application/json
x-voicelayer-event: message.received
x-voicelayer-delivery: <delivery id>
x-voicelayer-timestamp: 1792238400
x-voicelayer-signature: t=1792238400,v1=<hex HMAC-SHA256>Verify the signature against the raw body, before you parse it, so you know the request came from VoiceLayer and was not replayed. Answer with any 2xx within 10 seconds, and do slow work afterwards.
import { createServer } from 'node:http';
import { verifyWebhook, WebhookVerificationError } from '@voicelayer/api';
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
try {
// throws when the signature or the timestamp is wrong
const event = verifyWebhook({
payload: Buffer.concat(chunks),
headers: req.headers,
secret: process.env.VOICELAYER_WEBHOOK_SECRET!
});
console.log(event.type, event.data);
res.writeHead(200).end();
} catch (err) {
res.writeHead(err instanceof WebhookVerificationError ? 400 : 500).end();
}
});
}).listen(3000);verifyWebhook and verify_webhook refuse a timestamp more than five minutes from now, so an old delivery cannot be replayed.
Step 2: Subscribe the endpoint
POST /v1/webhooks/subscriptions registers your URL. eventTypes takes exact types or a prefix with .*; leave it empty to receive every type.
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/webhooks",
"eventTypes": [
"message.*",
"conversation.*"
]
}'{
"subscription": {
"id": "0192f5a7-3c4d-7e5f-8a6b-7c8d9e0f1a23",
"projectId": "0192f5a1-5e6f-7a7b-8c8d-9e0f1a2b3c45",
"url": "https://example.com/voicelayer/webhooks",
"eventTypes": [
"message.*",
"conversation.*"
],
"description": "Production receiver",
"enabled": true,
"createdByKeyId": "key_7f2a91",
"restrictTags": [],
"createdAt": "2026-10-20T15:04:05.000Z",
"updatedAt": "2026-10-20T15:04:05.000Z",
"secret": "<shown only in this response>"
}
}The response holds the signing secret, shown only this once. Store it where your endpoint reads VOICELAYER_WEBHOOK_SECRET.
Step 3: Handle the events
Each delivery's body is an envelope: id, type, createdAt, livemode and the event in data.
{
"id": "0192f5b1-0a1b-7c2d-8e3f-4a5b6c7d8e90",
"type": "message.received",
"createdAt": "2026-10-20T15:04:05.000Z",
"livemode": true,
"data": { "messageId": "…", "conversationId": "…", "channel": "sms", "direction": "inbound", "bindingId": null, "status": "received", "text": "Do you have a table for two tonight?", "author": null, "error": null, "metadata": {}, "occurredAt": "…" }
}Messages and conversations:
| Type | When |
|---|---|
message.received | Someone wrote to your workspace |
message.sent | A message left through the carrier |
message.delivered | The carrier reports it delivered |
message.failed | It could not be delivered; data.error says why |
conversation.created | A new conversation opened |
conversation.controller_changed | It moved between the agent, your code and a person (see handoff) |
Calls, for voice agents:
| Type | When |
|---|---|
call.started | A call began. The caller's number can still be null at this point. |
call.ended | A call finished, with its outcome, the captured fields and the transcript |
recording.ready | A call's recording is available (callId, recordingUrl, status) |
call.ended carries durationMs, endReason, direction (inbound, outbound or web), outcome (completed, dropped, failed, no_answer or other), the fields your agent captured with their values, structuredOutputs, and the final transcript. A transcript larger than 256 KB arrives empty with transcript.truncated: true; fetch the whole call from transcript.fetchPath with your secret key. Payloads only gain fields over time, so a handler written today keeps working.
Step 4: Deduplicate
A delivery can arrive more than once. The event id is the same on every retry and on every transport (webhooks, the event stream and GET /v1/events), so process each id once.
Retries and failed deliveries
A delivery that does not get a 2xx within 10 seconds is retried after 1 minute, 5 minutes, 30 minutes and 2 hours. After the fifth attempt it is marked failed. Deliveries are kept for 30 days.
List recent failures, with each attempt's status code and latency (never your response body):
curl "https://api.vlayers.ai/v1/webhooks/deliveries?status=failed&limit=20" \
-H "Authorization: Bearer $VOICELAYER_API_KEY"After you fix your endpoint, send a delivery again. It keeps its event id:
curl -X POST "https://api.vlayers.ai/v1/webhooks/deliveries/$DELIVERY_ID/redeliver" \
-H "Authorization: Bearer $VOICELAYER_API_KEY"Manage subscriptions
GET /v1/webhooks/subscriptionslists them (never their secrets).PATCH /v1/webhooks/subscriptions/{id}changes the URL, the event types, or pauses deliveries withenabled: false.DELETE /v1/webhooks/subscriptions/{id}removes one.
Verify without an SDK
x-voicelayer-signature is t=<unix seconds>,v1=<hex>. The hex is the HMAC-SHA256 of "<t>.<raw body>", keyed with the subscription secret. During a secret rotation the header can carry several v1= values; one match is enough.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyVoiceLayerSignature(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2) as [string, string]));
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Test it
- Run your endpoint and expose it at a public
https://URL. - Subscribe it with a test key (Step 2).
- Simulate a message with
POST /v1/test/inbound(see Test mode). Your endpoint receivesmessage.receivedwith"livemode": false.
Common errors
Webhook routes answer { "error", "message" }. The ones you meet most:
| Error | Status | How to fix |
|---|---|---|
unauthorized | 401 | Send Authorization: Bearer <key> with a secret key from the dashboard (Developers → API keys). Test keys start with vl_test_. |
insufficient_scope | 403 | Use a key with the scope named in details.required, or one whose restriction covers this channel or Binding. |
invalid_request | 400 | Read message and details.issues (the validation issues, by path), fix the request and send it again. |
not_found | 404 | Check the id, and that the key belongs to the workspace (and covers the channel) that owns it. |
Next steps
- Stream events: no public endpoint needed.
- Conversations and handoff: reply to what you receive.
- Test mode.