How-to guide

Stream events

Read message and conversation events over one long-lived connection, and resume exactly where you left off.

What you'll build

A process that receives events as they happen, without a public endpoint, and picks up exactly where it stopped after a restart or a dropped connection.

Prerequisites

  • A secret API key with the events:read scope. See Authentication and API keys.
  • curl, Node.js 18 or later with @voicelayer/api, or Python 3.9 or later with voicelayer.

Three ways to get the same events

TransportUse it when
WebhooksYou have a public HTTPS endpoint
Stream (GET /v1/events/stream)A long-running process can keep a connection open
Pull (GET /v1/events)A cron job or a serverless function checks now and then

All three share one event id and one cursor, so you can switch between them without missing or repeating an event.

Step 1: Open the stream

GET /v1/events/stream is a Server-Sent Events stream. Without a cursor it starts live, from now. The SDK helpers reconnect after a drop and resume from the last cursor for you.

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

On the wire, each event is a frame whose id: is its cursor:

id: <cursor>
event: message.received
data: {"id":"…","type":"message.received","createdAt":"…","livemode":false,"data":{"messageId":"…","conversationId":"…","channel":"sms","direction":"inbound","bindingId":null,"status":"received","text":"…","author":null,"error":null,"metadata":{},"occurredAt":"…"}}
  • Filter with types (comma-separated, or a prefix with .*), conversationId or channel.
  • A heartbeat comment arrives every 15 seconds. A connection silent for longer is dead; reconnect.

Step 2: Save your place

Store the cursor of the last event you finished handling, after handling it. When you reconnect, send it as the Last-Event-ID header (or ?after=). The stream replays everything after it, then goes live again. Browsers' EventSource and most SSE libraries send Last-Event-ID for you.

curl -N https://api.vlayers.ai/v1/events/stream \
  -H "Authorization: Bearer $VOICELAYER_API_KEY" \
  -H "Last-Event-ID: $CURSOR"

The stream replays up to 7 days. An older cursor is refused with 410 cursor_expired: page through the pull from that cursor (Step 3), then reconnect the stream.

Step 3: Or pull in pages

GET /v1/events returns events after after, oldest first, with the cursor to pass next time and hasMore. It reads the whole retention, not only 7 days.

curl "https://api.vlayers.ai/v1/events?types=message.*&limit=100" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY"
Response · 200
{
  "events": [
    {
      "id": "0192f5b1-0a1b-7c2d-8e3f-4a5b6c7d8e90",
      "type": "message.received",
      "createdAt": "2026-10-20T15:04:05.000Z",
      "livemode": false,
      "data": {
        "messageId": "0192f5b0-8d1f-7b62-8c3e-5f9a2b7d4e32",
        "conversationId": "0192f5b0-7c3e-7a51-9b2d-4e8f1a6c3d21",
        "channel": "sms",
        "direction": "inbound",
        "bindingId": null,
        "status": "received",
        "text": "Do you have a table for two tonight?",
        "author": null,
        "error": null,
        "metadata": {},
        "occurredAt": "2026-10-20T15:04:05.000Z"
      }
    }
  ],
  "cursor": "MDE5MmY1YjEtMGExYi03YzJk",
  "hasMore": false
}

Without after, the pull starts at the oldest event; from=now returns nothing older than the request. Add waitSec (up to 25) to long-poll: when nothing is there yet, the request waits for the next event instead of answering an empty page.

curl "https://api.vlayers.ai/v1/events?after=$CURSOR&waitSec=20" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY"

Step 4: Deduplicate

Delivery is at least once on every transport. Process each event id once.

Test it

Open the stream with a test key, then simulate a message in another terminal with POST /v1/test/inbound (see Test mode). A message.received event arrives with "livemode": false.

Common errors

ErrorStatusHow to fix
invalid_cursor400Pass back a cursor (pull) or an event id: line (stream) exactly as received, or leave it out to start afresh.
cursor_expired410Page through GET /v1/events from the same cursor (the pull reads the whole retention), then reconnect the stream with its last cursor.
invalid_event_types400Use the event types the reference lists (for example message.*, conversation.created).
invalid_conversation_id400Pass the id of a conversation, as the API returns it.
events_unavailable503Retry shortly from your last cursor; nothing is lost, events are read from a durable log.
insufficient_scope403Use a key with the scope named in details.required, or one whose restriction covers this channel or Binding.

Next steps

On this page