How-to guide

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:manage scope. 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 with voicelayer, 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.*"
    ]
  }'
Response · 201
{
  "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:

TypeWhen
message.receivedSomeone wrote to your workspace
message.sentA message left through the carrier
message.deliveredThe carrier reports it delivered
message.failedIt could not be delivered; data.error says why
conversation.createdA new conversation opened
conversation.controller_changedIt moved between the agent, your code and a person (see handoff)

Calls, for voice agents:

TypeWhen
call.startedA call began. The caller's number can still be null at this point.
call.endedA call finished, with its outcome, the captured fields and the transcript
recording.readyA 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.

Events carry your customers' real numbers, words and captured values. Treat your endpoint and its logs as holding personal data.

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/subscriptions lists them (never their secrets).
  • PATCH /v1/webhooks/subscriptions/{id} changes the URL, the event types, or pauses deliveries with enabled: 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

  1. Run your endpoint and expose it at a public https:// URL.
  2. Subscribe it with a test key (Step 2).
  3. Simulate a message with POST /v1/test/inbound (see Test mode). Your endpoint receives message.received with "livemode": false.

Common errors

Webhook routes answer { "error", "message" }. The ones you meet most:

ErrorStatusHow to fix
unauthorized401Send Authorization: Bearer <key> with a secret key from the dashboard (Developers → API keys). Test keys start with vl_test_.
insufficient_scope403Use a key with the scope named in details.required, or one whose restriction covers this channel or Binding.
invalid_request400Read message and details.issues (the validation issues, by path), fix the request and send it again.
not_found404Check the id, and that the key belongs to the workspace (and covers the channel) that owns it.

Next steps

On this page