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:readscope. See Authentication and API keys. - curl, Node.js 18 or later with
@voicelayer/api, or Python 3.9 or later withvoicelayer.
Three ways to get the same events
| Transport | Use it when |
|---|---|
| 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 -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.*),conversationIdorchannel. - 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"{
"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
| Error | Status | How to fix |
|---|---|---|
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 | 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 | 400 | Use the event types the reference lists (for example message.*, conversation.created). |
invalid_conversation_id | 400 | Pass the id of a conversation, as the API returns it. |
events_unavailable | 503 | Retry shortly from your last cursor; nothing is lost, events are read from a durable log. |
insufficient_scope | 403 | Use a key with the scope named in details.required, or one whose restriction covers this channel or Binding. |