Skip to content

Realtime

Requires a license with the realtime feature. See pricing.

Realtime pushes a notice to connected browsers and apps the moment content changes, so a page updates without polling. A client subscribes to topics over Server-Sent Events (SSE) or a WebSocket, and receives only its own tenant's events. A client that reconnects picks up the events it missed.

PieceWhat it does
TopicA name a client subscribes to, such as content:article. An SSE stream keeps the first 32.
SSEGET /api/v1/realtime/events, a one-way stream. Browsers reconnect on their own.
WebSocket/api/v1/ws/connect, both ways: events in, flow runs and their answers over the same socket.
ReplayEach topic keeps its last 256 events in memory. A client that reconnects with Last-Event-ID gets what it missed.
TopicCarries
content:<schema>Creates, updates and deletes of entries of that content type, such as content:article
schema:changedA content type was created or changed. Deleting one sends nothing here.
presenceUsers joining and leaving the list of who is online
*Every event of the tenant. The default when no topic is given.
any other nameMessages a flow or a broadcast publishes

A content event carries a notice, not the entry. Read the fields through the Content API when you need them:

{ "action": "update", "record_id": "8f14e45f-ea2c-4b1d-9f3a-2c1b0e7d6a55", "schema": "article" }

action is create, update or delete.

When a token or API key carries scopes, an SSE stream checks each topic against them. Every content:<schema> topic needs content:read, schema:changed needs schema:read, another topic needs read on the part before its colon, and * needs *:read. A topic the scopes do not cover is dropped, and a stream left with none answers 403. An API key with scopes also needs realtime:read to open a stream and ws:read to open a WebSocket.

Every connection must come from an allowed origin: its Origin header must be in CORS_ORIGINS or match the instance's own host. Browsers send it on their own. A server-side client sets it, or it is refused with 403 origin not allowed.

You need a token in TOKEN (the quickstart shows how to get one) and a content type named post, such as the one in Create your first content type.

  1. Open a stream on the topic of post. The Origin header names the instance's own host:

    Terminal window
    curl -N "http://localhost:3002/api/v1/realtime/events?topic=content:post" \
    -H "Origin: http://localhost:3002" \
    -H "Authorization: Bearer $TOKEN"

    The stream opens with a :ok comment line and stays open.

  2. In a second terminal, create an entry:

    Terminal window
    curl -X POST http://localhost:3002/api/v1/content/post \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"data": {"title": "Realtime check", "slug": "realtime-check"}}'

    The first terminal prints:

    id: 4
    event: content:post
    data: {"action":"create","record_id":"2e8580e8-7ec8-4209-9971-48971f12d9e3","schema":"post"}
  3. While the stream is open, list who is online:

    Terminal window
    curl "http://localhost:3002/api/v1/realtime/presence?limit=20" \
    -H "Authorization: Bearer $TOKEN"

    You are on the list, with connection_count: 1.

  4. Open Insight > Realtime in the admin console to see the tenant's connection counts.

GET /api/v1/realtime/events, with topic repeated for each topic. The event field of each message is the topic, and its id is what a reconnect sends back as Last-Event-ID. A comment line keeps the stream open every 30 seconds.

In a browser signed in to the instance, the session cookie authenticates the stream, and the browser sends Last-Event-ID itself when it reconnects:

const es = new EventSource(
"https://lyeve.example.com/api/v1/realtime/events?topic=content:article",
{ withCredentials: true },
);
es.addEventListener("content:article", (e) => {
const change = JSON.parse(e.data);
console.log(change.action, change.record_id);
});

Connect to /api/v1/ws/connect, with topic in the query as for SSE. A browser cannot set an Authorization header on a WebSocket, so it sends the credential as a subprotocol:

const ws = new WebSocket(
"wss://lyeve.example.com/api/v1/ws/connect?topic=content:article",
["lyeve.v1", `lyeve.bearer.${token}`],
);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
console.log(msg.type, msg.topic, msg.data);
};

The server answers with the lyeve.v1 subprotocol. The subprotocol carries a session token, not an API key. A client that can set headers sends Authorization: Bearer <token> or X-API-Key: <key>, with an Origin, instead. A ?token= parameter is ignored, because query strings end up in proxy logs and browser history.

Every message is JSON of the shape {"type", "topic", "id", "data", "ts"}, and topic is left out of messages that are not events. The first has the type ack, and its data names the connection (conn_id) and its topics. Events have the type event. Send Last-Event-ID on reconnect to replay what you missed.

The server pings every 30 seconds and closes the socket when no pong arrives within 10 seconds, or when nothing is read for 120 seconds. A client message is at most 64 KB.

A client runs a published flow of its tenant and reads the answer on the same socket:

{"type": "flow.run", "ref": "r1", "slug": "order-totals", "input": {"order": 7}}
{"type": "flow.result", "id": 0, "data": {"ref": "r1", "slug": "order-totals", "run_id": "...", "status": 200, "body": {"total": 42}}, "ts": "..."}

ref comes back, cut at 128 characters, so you can match answers to requests. input must be an object or absent. The flow's HTTP trigger must list realtime in its protocols, and its auth setting decides whether this caller may run it: public and auth flows answer, admin flows do not. The flow's rate limit and cache apply. Starting a flow this way needs the flow-pro capability.

Runs on one socket are served one at a time, in order. A refusal comes back as an error message whose data carries the ref, a code and a message:

CodeMeaning
NOT_FOUNDNo published flow has that slug, its trigger does not list realtime, or its auth setting excludes the caller
PAYMENT_REQUIREDThe license does not carry flow-pro
RATE_LIMITEDThe flow's rate limit refused the call
LOOP_DETECTEDThe call would nest flows too deep
BAD_REQUESTinput is not an object
UNAVAILABLEThe flow could not run
BUSYEight runs are already waiting on this socket

Any other message a client sends is ignored.

Terminal window
curl -X POST http://localhost:3002/api/v1/ws/broadcast \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"topic": "maintenance", "payload": {"message": "Publishing pauses at 18:00 UTC"}}'
{ "connections_reached": 12, "total_connections": 40 }

With a topic, sockets subscribed to it receive the message. Without one, every socket of the tenant does. connections_reached counts the tenant's topic subscriptions, whatever topic you named, so a socket on two topics counts twice. total_connections is the number of sockets open on this instance, in every tenant. The body is at most 64 KB. Broadcast needs the admin role and reaches WebSocket clients only.

A signed-in user is on the presence list while they hold an SSE stream open. Each join and leave is published on the presence topic. Keep a user listed with a heartbeat at least every 60 seconds, because a user without one drops off:

Terminal window
curl -X POST http://localhost:3002/api/v1/realtime/presence/heartbeat \
-H "Authorization: Bearer $TOKEN"

The heartbeat answers {"status": "ok"}. GET /api/v1/realtime/presence lists the users, paginated:

{
"data": [
{
"user_id": "5f0c7b1e-1d2a-4c3b-9e8f-0a1b2c3d4e5f",
"email": "ada@example.com",
"connected_at": "2026-10-01T09:00:00Z",
"last_heartbeat_at": "2026-10-01T09:14:30Z",
"connection_count": 2
}
],
"total_count": 1,
"limit": 20,
"offset": 0
}

The realtime.publish node sends one event to a topic in the run's tenant. Subscribers to that topic and to * receive it like any other event, with an id they can replay from. Its config is topic (required, at most 255 characters, no line breaks) and payload (an object of at most 64 KB as JSON):

{"type": "realtime.publish", "config": {"topic": "orders:paid", "payload": {"order_id": "{{ input.id }}"}}}

The event is not stored, and webhooks and other flows do not see it. It reaches clients on the instance that ran the flow only, even with REALTIME_BACKEND set. A test run publishes nothing. See Flows.

VariableWhat it doesDefault
CORS_ORIGINSOrigins a browser may connect from, comma-separatednone
REALTIME_BACKENDSet redis so an event reaches clients on every instance behind a load balancer. Read from the environment only.unset, so events stay on one instance
REALTIME_REDIS_URLThe Redis for realtimeREDIS_URL
REDIS_URLThe Redis the instances already shareredis://localhost:6379/0

When the Redis cannot be reached, each instance keeps delivering its own events and retries the connection in the background. If you set LYEVE_PLUGINS to choose which features start, include realtime. See Licensing and tiers and Scaling.

MethodPathRolePurpose
GET/api/v1/realtime/eventssigned inOpen an SSE stream
GET/api/v1/ws/connectsigned inOpen a WebSocket
POST/api/v1/ws/broadcastadminSend a message to the tenant's sockets
GET/api/v1/realtime/presencesigned inList online users, paginated
POST/api/v1/realtime/presence/heartbeatsigned inKeep the caller on the list
GET/api/admin/realtime/metricsadminSSE connection counts for the tenant
GET/api/admin/ws/metricsadminWebSocket connection counts for the tenant
GET/api/admin/realtime/metrics/platformsuper_adminSSE connection counts for every tenant

The metrics routes return the connections accepted since the counters started, the active connections, connections by tenant, events dispatched, reconnections, rejected connections and when the counters started. The SSE metrics add heartbeats sent. The WebSocket metrics add idle connections, broadcasts, pings and pongs.

LimitValue
SSE streams per tenant1000
SSE streams per client address100
WebSockets per tenant1000
Topics per connection32
Replayed events per topic256
WebSocket message and broadcast body64 KB
StatusMessageCause
403origin not allowedThe Origin header is missing, or not in CORS_ORIGINS and not the instance's host
403insufficient scope for requested topicsThe token's scopes cover none of the requested SSE topics
403insufficient scopeAn API key with scopes lacks realtime:read or ws:read
400invalid topic parameter: ...A topic is empty, too long or holds a line break
429max connections (1000) reached for tenant "acme"The tenant is at its connection limit
413broadcast payload exceeds maximum sizeA broadcast body is over 64 KB
400payload is requiredA broadcast had no payload
  • Webhooks: signed calls to your servers for the same changes.
  • GraphQL API: the contentChanged subscription.
  • Client libraries: @lyeve-labs/client-realtime wraps both transports.
  • Flows: run flows over the socket and publish to topics.