# Stream events

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

Source: https://docs.vlayers.ai/developers/receive/event-stream

Kind: How-to guide

## 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](https://docs.vlayers.ai/get-started/authentication).
- curl, Node.js 18 or later with `@voicelayer/api`, or Python 3.9 or later with `voicelayer`.

## Three ways to get the same events

| Transport | Use it when |
|---|---|
| [Webhooks](https://docs.vlayers.ai/developers/receive/webhooks) | You 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:

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

TypeScript:

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

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

// Reconnects after a drop and resumes from the last cursor, so nothing is missed.
for await (const { cursor, event } of streamEvents(client, { types: ['message.*'] })) {
  console.log(event.type, event.data);
  // save `cursor` once the event is handled; pass it back as `after` to resume
}
```

Python:

```python
import os
import voicelayer
from voicelayer.events import stream_events

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])

# Reconnects after a drop and resumes from the last cursor, so nothing is missed.
for item in stream_events(config, types=["message.*"]):
    print(item.event["type"], item.event["data"])
    # save item.cursor once the event is handled; pass it back as after= to resume
```

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

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

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

```bash
curl "https://api.vlayers.ai/v1/events?types=message.*&limit=100" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY"
```

TypeScript:

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

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

const { data } = await listEvents({
  client,
  query: {
    types: 'message.*',
    limit: 100
  }
});
console.log(data.events);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.EventsApi(client)
    result = api.list_events(
        types="message.*",
        limit=100,
    )
    print(result.events)
```

Response `200`:

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

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

TypeScript:

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

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

const { data } = await listEvents({
  client,
  query: {
    after: process.env.CURSOR!,
    waitSec: 20
  }
});
console.log(data.events);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.EventsApi(client)
    result = api.list_events(
        after=os.environ["CURSOR"],
        wait_sec=20,
    )
    print(result.events)
```

## 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](https://docs.vlayers.ai/developers/test-mode)). A `message.received` event arrives with `"livemode": false`.

## Common errors

| Error | Status | How to fix |
|---|---|---|
| [`invalid_cursor`](https://docs.vlayers.ai/errors/invalid_cursor) | 400 | Pass back a `cursor` (pull) or an event `id:` line (stream) exactly as received, or leave it out to start afresh. |
| [`cursor_expired`](https://docs.vlayers.ai/errors/cursor_expired) | 410 | Page through `GET /v1/events` from the same cursor (the pull reads the whole retention), then reconnect the stream with its last cursor. |
| [`invalid_event_types`](https://docs.vlayers.ai/errors/invalid_event_types) | 400 | Use the event types the reference lists (for example `message.*`, `conversation.created`). |
| [`invalid_conversation_id`](https://docs.vlayers.ai/errors/invalid_conversation_id) | 400 | Pass the `id` of a conversation, as the API returns it. |
| [`events_unavailable`](https://docs.vlayers.ai/errors/events_unavailable) | 503 | Retry shortly from your last cursor; nothing is lost, events are read from a durable log. |
| [`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. |

## Next steps

- [Receive with webhooks](https://docs.vlayers.ai/developers/receive/webhooks).
- [Pagination](https://docs.vlayers.ai/developers/pagination).
- [Conversations and handoff](https://docs.vlayers.ai/developers/handoff).
