How-to guide

TypeScript SDK

Send, receive and handle errors from Node.js with the typed @voicelayer/api client.

What you'll build

A Node.js script that sends a message, reads incoming events, verifies webhooks and handles errors, using @voicelayer/api.

Every operation in the API reference is a typed function named after it.

Prerequisites

  • Node.js 18 or later.
  • A secret API key in VOICELAYER_API_KEY. Use a test key (vl_test_…) while you build: see Authentication and API keys.
  • No phone number needed: with a test key, your workspace's free test sender stands in for one.

Step 1: Install

npm install @voicelayer/api

Step 2: Authenticate

Create one client and pass it to every call. With throwOnError: true, a refused request throws instead of returning an error field.

import { createVoiceLayerClient } from '@voicelayer/api';

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

The client sends Authorization: Bearer <key> to https://api.vlayers.ai. The package is an ES module and the examples use top-level await: in a new project, run npm pkg set type=module once, then run a file with npx tsx example.ts.

Step 3: Send a message

Each function takes { client, path, query, headers, body }, as the operation needs.

curl -X POST https://api.vlayers.ai/v1/messages \
  -H "Authorization: Bearer $VOICELAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "sms",
    "to": "+15550123",
    "content": {
      "type": "text",
      "text": "Your table for two is booked for 7pm."
    }
  }'

You get 202 Accepted with the queued message:

Response · 202
{
  "message": {
    "id": "0192f5b0-8d1f-7b62-8c3e-5f9a2b7d4e32",
    "conversationId": "0192f5b0-7c3e-7a51-9b2d-4e8f1a6c3d21",
    "channel": "sms",
    "direction": "outbound",
    "status": "queued",
    "text": "Your table for two is booked for 7pm.",
    "media": [],
    "from": "+14155550100",
    "to": "+15550123",
    "bindingId": null,
    "metadata": {},
    "error": null,
    "createdAt": "2026-10-20T15:04:05.000Z",
    "parts": [
      {
        "id": "0192f5b0-8d1f-7b62-8c3e-5f9a2b7d4e32",
        "status": "queued"
      }
    ]
  }
}

Step 4: Receive messages

streamEvents opens the event stream, reconnects after a drop and resumes from the last cursor, so you never miss or repeat an event. Store cursor after you handle each event.

curl -N "https://api.vlayers.ai/v1/events/stream?types=message.*" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY" \
  -H "Accept: text/event-stream"

To receive webhooks instead, verify each delivery against the raw request body before you parse it. verifyWebhook checks the signature and the timestamp, then returns the parsed event:

import { verifyWebhook, WebhookVerificationError } from '@voicelayer/api';

export async function POST(request: Request) {
  const payload = await request.text(); // the raw body, before any JSON parsing
  try {
    const event = verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.VOICELAYER_WEBHOOK_SECRET!
    });
    console.log(event.type, event.data);
    return new Response(null, { status: 204 });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response(err.reason, { status: 400 });
    throw err;
  }
}

See Receive with webhooks to create the subscription.

Step 5: Handle errors

A refusal throws VoiceLayerApiError. Read code to decide what to do, and quote requestId if you contact support.

import { createVoiceLayerClient, sendMessage, VoiceLayerApiError } from '@voicelayer/api';

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

try {
  await sendMessage({
    client,
    body: { channel: 'sms', to: '+15550123', content: { type: 'text', text: 'Hello' } }
  });
} catch (err) {
  if (err instanceof VoiceLayerApiError) {
    console.error(err.status, err.code, err.message);
    console.error('retryable:', err.retryable, 'request:', err.requestId, 'docs:', err.docs);
  } else {
    throw err; // a network failure: retry with the same idempotency key
  }
}

retryable is true for a rate limit or a temporary failure. Retry those with backoff and the same idempotency-key. Fix the request for anything else. See Errors, retries and idempotency.

To make a send safe to retry, add an idempotency-key. A retry with the same key never sends twice:

curl -X POST https://api.vlayers.ai/v1/messages \
  -H "Authorization: Bearer $VOICELAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "channel": "sms",
    "to": "+15550123",
    "content": {
      "type": "text",
      "text": "Your table for two is booked for 7pm."
    }
  }'

Use another host only if VoiceLayer gives you one: createVoiceLayerClient({ apiKey, baseUrl: 'https://api.example.com' }).

Step 6: Page through lists

List calls return a cursor. To read a conversation's history newest first, ask for before: 'end', then pass each page's prevCursor back as before:

curl "https://api.vlayers.ai/v1/conversations/$CONVERSATION_ID/messages?before=end&limit=20" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY"

nextCursor works the same way forward with after. See Pagination.

Test it

With a test key, simulate a person texting you from a reserved number, then reply in the conversation it opens. Nothing reaches a real carrier and it costs nothing.

curl -X POST https://api.vlayers.ai/v1/test/inbound \
  -H "Authorization: Bearer $VOICELAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "sms",
    "from": "+15550123",
    "text": "Do you have a table for two tonight?"
  }'

Save the printed id as CONVERSATION_ID, then reply:

curl -X POST "https://api.vlayers.ai/v1/conversations/$CONVERSATION_ID/messages" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content": {
      "type": "text",
      "text": "Yes, we have a table at 7pm. Shall I book it?"
    }
  }'

See Test mode.

Advanced: the generated client

The functions above are the whole client today. A shorter one, new VoiceLayer() with voicelayer.messages.send({ channel, to, text }), arrives in the next @voicelayer/api release, and these functions keep working after it.

Common errors

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.
no_sender409Connect a sender for the channel in the dashboard (for email, create a mailbox), or name one in from.
idempotency_conflict409Use a new key for a new message; repeat the original body exactly to replay the original request.
rate_limited429Wait for retry-after, then retry (with the same Idempotency-Key for a send).

Method reference

Every call in the SDK, by resource. Each links to its full reference page with parameters and examples.

ResourceCallMethod
MessagesList messages
GET /v1/messages
listMessages({ client, query?: { conversationId?, channel?, direction?, after?, before?, limit? } })
MessagesSend a message
POST /v1/messages
sendMessage({ client, headers?: { 'idempotency-key' }, body })
ConversationsList conversations
GET /v1/conversations
listConversations({ client, query?: { channel?, bindingId?, controller?, updatedAfter?, cursor?, limit? } })
ConversationsRetrieve a conversation
GET /v1/conversations/{id}
getConversation({ client, path: { id } })
ConversationsHand off a conversation
PATCH /v1/conversations/{id}
updateConversation({ client, path: { id }, headers?: { 'idempotency-key' }, body })
ConversationsList a conversation's messages
GET /v1/conversations/{id}/messages
listConversationMessages({ client, path: { id }, query?: { after?, before?, limit? } })
ConversationsReply in a conversation
POST /v1/conversations/{id}/messages
replyToConversation({ client, path: { id }, headers?: { 'idempotency-key' }, body })
EventsList events
GET /v1/events
listEvents({ client, query?: { after?, types?, conversationId?, channel?, limit?, from?, waitSec? } })
EventsStream events
GET /v1/events/stream
streamEvents(client, { after?, types?, conversationId?, channel? })
WebhooksList webhook subscriptions
GET /v1/webhooks/subscriptions
listWebhookSubscriptions({ client })
WebhooksCreate a webhook subscription
POST /v1/webhooks/subscriptions
createWebhookSubscription({ client, body })
WebhooksRetrieve a webhook subscription
GET /v1/webhooks/subscriptions/{id}
getWebhookSubscription({ client, path: { id } })
WebhooksUpdate a webhook subscription
PATCH /v1/webhooks/subscriptions/{id}
updateWebhookSubscription({ client, path: { id }, body })
WebhooksDelete a webhook subscription
DELETE /v1/webhooks/subscriptions/{id}
deleteWebhookSubscription({ client, path: { id } })
WebhooksList webhook deliveries
GET /v1/webhooks/deliveries
listWebhookDeliveries({ client, query?: { subscriptionId?, type?, status?, since?, until?, limit? } })
WebhooksRetry a webhook delivery
POST /v1/webhooks/deliveries/{id}/redeliver
redeliverWebhook({ client, path: { id } })
ChannelsList channels
GET /v1/channels
listChannels({ client })
ChannelsList WhatsApp templates
GET /v1/channels/whatsapp/templates
listWhatsAppTemplates({ client, query: { bindingId } })
Test modeSimulate an incoming message
POST /v1/test/inbound
createTestInbound({ client, body })
Request logsList API request logs
GET /v1/request-logs
listRequestLogs({ client, query?: { after?, since?, status?, apiKeyId?, limit? } })

Next steps

On this page