# Receive with webhooks

> Get a signed HTTPS request the moment someone writes to you, a message is delivered, or a call ends.

Source: https://docs.vlayers.ai/developers/receive/webhooks

Kind: How-to guide

## 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](https://docs.vlayers.ai/get-started/authentication). 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:

```text
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.

TypeScript:
```ts
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);
```

Python:
```python
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from voicelayer.webhooks import WebhookVerificationError, verify_webhook

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers["Content-Length"]))
        try:
            # raises when the signature or the timestamp is wrong
            event = verify_webhook(raw, self.headers.get("x-voicelayer-signature"), os.environ["VOICELAYER_WEBHOOK_SECRET"])
        except WebhookVerificationError:
            self.send_response(400)
            self.end_headers()
            return
        print(event["type"], event["data"])
        self.send_response(200)
        self.end_headers()

HTTPServer(("", 3000), Handler).serve_forever()
```

`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:

```bash
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.*"
    ]
  }'
```

TypeScript:

```ts
import { createVoiceLayerClient, createWebhookSubscription } from '@voicelayer/api';

const client = createVoiceLayerClient({ apiKey: process.env.VOICELAYER_API_KEY!, throwOnError: true });

const { data } = await createWebhookSubscription({
  client,
  body: {
    url: 'https://example.com/voicelayer/webhooks',
    eventTypes: ['message.*', 'conversation.*']
  }
});
console.log(data.subscription.secret);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.WebhooksApi(client)
    result = api.create_webhook_subscription(
        create_webhook_subscription_request=voicelayer.CreateWebhookSubscriptionRequest.from_dict({
            "url": "https://example.com/voicelayer/webhooks",
            "eventTypes": ["message.*", "conversation.*"],
        }),
    )
    print(result.subscription.secret)
```

Response `201`:

```json
{
  "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`.

```json
{
  "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](https://docs.vlayers.ai/developers/handoff)) |

Calls, for [voice agents](https://docs.vlayers.ai/build/first-voice-agent):

| 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.

> **Note:** 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](https://docs.vlayers.ai/developers/receive/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:

```bash
curl "https://api.vlayers.ai/v1/webhooks/deliveries?status=failed&limit=20" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY"
```

TypeScript:

```ts
import { createVoiceLayerClient, listWebhookDeliveries } from '@voicelayer/api';

const client = createVoiceLayerClient({ apiKey: process.env.VOICELAYER_API_KEY!, throwOnError: true });

const { data } = await listWebhookDeliveries({
  client,
  query: {
    status: 'failed',
    limit: '20'
  }
});
console.log(data.deliveries);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.WebhooksApi(client)
    result = api.list_webhook_deliveries(
        status="failed",
        limit="20",
    )
    print(result.deliveries)
```

After you fix your endpoint, send a delivery again. It keeps its event `id`:

cURL:

```bash
curl -X POST "https://api.vlayers.ai/v1/webhooks/deliveries/$DELIVERY_ID/redeliver" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY"
```

TypeScript:

```ts
import { createVoiceLayerClient, redeliverWebhook } from '@voicelayer/api';

const client = createVoiceLayerClient({ apiKey: process.env.VOICELAYER_API_KEY!, throwOnError: true });

const { data } = await redeliverWebhook({
  client,
  path: {
    id: process.env.DELIVERY_ID!
  }
});
console.log(data.delivery.status);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.WebhooksApi(client)
    result = api.redeliver_webhook(
        id=os.environ["DELIVERY_ID"],
    )
    print(result.delivery.status)
```

## 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.

TypeScript:
```ts
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));
}
```

Python:
```python
import hashlib, hmac, time

def verify_voicelayer_signature(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not v1 or abs(time.time() - int(t)) > tolerance_sec:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, 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](https://docs.vlayers.ai/developers/test-mode)). Your endpoint receives `message.received` with `"livemode": false`.

## Common errors

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

| Error | Status | How to fix |
|---|---|---|
| [`unauthorized`](https://docs.vlayers.ai/errors/unauthorized) | 401 | Send `Authorization: Bearer <key>` with a secret key from the dashboard (Developers → API keys). Test keys start with `vl_test_`. |
| [`insufficient_scope`](https://docs.vlayers.ai/errors/insufficient_scope) | 403 | Use a key with the scope named in `details.required`, or one whose restriction covers this channel or Binding. |
| [`invalid_request`](https://docs.vlayers.ai/errors/invalid_request) | 400 | Read `message` and `details.issues` (the validation issues, by path), fix the request and send it again. |
| [`not_found`](https://docs.vlayers.ai/errors/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](https://docs.vlayers.ai/developers/receive/event-stream): no public endpoint needed.
- [Conversations and handoff](https://docs.vlayers.ai/developers/handoff): reply to what you receive.
- [Test mode](https://docs.vlayers.ai/developers/test-mode).
