Skip to content

Realtime (WebSocket)

Wire-level reference for the /realtime WebSocket endpoint.

Single endpoint. Topic-based fan-out. See Realtime concepts for the high-level overview.

ws://<host>/realtime

Authenticate with an auth message after connecting (see Auth). Tokens in the URL are not accepted (they leak into logs and Referer).

Origin: a browser connection must come from this server’s own origin or one listed in security.allowed_origins, otherwise the server replies { "type": "error", "reason": "origin_not_allowed" } and closes. Clients that send no Origin header (servers, scripts, the SDK under Node / Bun) are accepted; they still need a token to see protected data. The SSE fallback applies the same rule (403 for a disallowed origin).

First message after connect:

{ "type": "connected" }

Per-event messages:

{ "type": "create", "collection": "<name>", "record": { ...full RecordWithMeta } }
{ "type": "update", "collection": "<name>", "record": { ...full RecordWithMeta } }
{ "type": "delete", "collection": "<name>", "id": "<record_id>" }

record matches the REST API’s record shape exactly.

{ "type": "subscribe", "topics": ["<topic>", ...] }
{ "type": "unsubscribe", "topics": ["<topic>", ...] }
{ "type": "auth", "token": "<jwt>" }

Topics:

FormReceives
<collection>every event in that collection
<collection>/<id>events for one specific record
<collection>.create / .update / .deleteonly that event type for the collection
*every event in every collection
*.create / .update / .deletethat event type in every collection

collections accepted as an alias for topics (backwards compat).

A single broadcast to posts/abc reaches subscribers of:

  • posts
  • posts/abc
  • *

A client subscribed to multiple matching topics gets the event once (per-ws dedup).

Attach a user/admin JWT with an auth message, right after connected or at any point later:

const ws = new WebSocket("ws://host/realtime");
ws.onopen = () => ws.send(JSON.stringify({ type: "auth", token: jwt }));

Auth context is stored per-connection and survives reconnect cycles via the client’s reconnect logic.

Every event is filtered per subscriber. A <collection>/<id> topic needs the collection’s view_rule; collection-wide topics (<collection>, *, and the .create / .update / .delete forms) need both its view_rule and its list_rule. A singleton’s collection topics follow its view_rule alone (0.26.2+). Each rule is applied like this:

RuleBehavior
null (public)Every subscriber receives
"" (admin only)Only admin connections receive
Expression (e.g. owner = @request.auth.id)Evaluated per-subscriber against the record. Non-matching subscribers are skipped silently.

Admins always receive (bypass).

Delete events evaluate against the just-deleted record snapshot so rules that reference per-record fields (like owner = @request.auth.id) still work.

Filtering is silent — non-matching clients see no event, no error. A client without permission cannot infer existence of records they’re not allowed to read.

client → ws connect
server → { type: "connected" }
client → { type: "auth", token: "<jwt>" } ← optional, any time
client → { type: "subscribe", topics: [...] }
... events stream ...
client → { type: "unsubscribe", topics: [...] } ← optional
client closes connection (or server closes on idle)

Close handler removes the connection from every subscription set automatically.

For clients that can’t open WebSockets (locked-down proxies, runtimes without a WS API), an HTTP-only fallback exposes the same fan-out via Server-Sent Events.

GET /api/v1/realtime
Accept: text/event-stream

The server responds with Content-Type: text/event-stream and pushes:

event: connect
data: {"type":"connected","clientId":"<uuid>"}
event: message
data: {"type":"create","collection":"posts","record":{...}}
event: message
data: {"type":"delete","collection":"posts","id":"abc"}
: ping

The first frame carries a server-minted clientId. Use it to manage subscriptions over a side-channel HTTP request. : ping lines are SSE comments — clients ignore them; they keep idle connections alive through proxies (sent every 30 seconds).

POST /api/v1/realtime
{ "clientId": "<from connect frame>",
"topics": ["posts", "comments/abc"],
"token": "<optional jwt>" }
→ { "data": { "clientId": "...", "topics": [...] } }

subscriptions and collections are accepted as aliases for topics. Calling this replaces the client’s full topic list (PUT semantics). token attaches/refreshes the per-connection auth used for view_rule filtering — same JWTs the WS path uses.

404 if the clientId was never opened or has been torn down. Once a token has been attached, further calls for that clientId (and the teardown below) need a token for the same user — in the body or an Authorization: Bearer header — or they answer 403.

DELETE /api/v1/realtime/<clientId>
→ { "data": null }

Idempotent — unknown clientIds also return 200. A client that has authenticated needs that user’s token (Authorization: Bearer).

  • Same wildcard / per-record / per-collection topic forms.
  • Same view_rule / list_rule filtering on every event.
  • Same admin-bypass.
  • Heartbeat every 30s.
  • A client that stops reading, or can’t keep up, is disconnected once about 1 MB of events is waiting for it (WebSocket since 0.24.1). Reconnect and refetch, as after any disconnect.
  • At most 100 topics per connection; extra topics are left out of the subscribed reply.
  • At most 10,000 open realtime connections per server process; past that the WebSocket upgrade is refused with 503 and Retry-After: 5 (before 0.26.5: an { "type": "error", "reason": "too_many_connections" } frame and a close; one that slips in during a race still gets that), and SSE gets 503. Back off before reconnecting, as the SDK does. The same applies past 100 connections from one client address. Behind a reverse proxy, set security.trusted_proxies so the real client address is used; until then, connections from a loopback peer (a proxy on the same host) are only held to the per-process limit. A proxy on another host or in a container network must be listed, or all its clients share one 100-connection allowance.
  • WebSocket messages over 64 KB close the connection.
  • { "type": "ping" } is answered with { "type": "pong" }.
  • A connection’s token is re-checked every minute and at its expiry. When it no longer holds (signed out, password reset, expired), the connection gets { "type": "auth_expired" } and continues as anonymous. Send a fresh token ({ "type": "auth" } or the SSE POST) to keep receiving protected events.
// Browser EventSource example
const es = new EventSource("/api/v1/realtime");
let clientId = null;
es.addEventListener("connect", (ev) => {
({ clientId } = JSON.parse(ev.data));
fetch("/api/v1/realtime", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${jwt}` },
body: JSON.stringify({ clientId, topics: ["posts"], token: jwt }),
});
});
es.addEventListener("message", (ev) => {
const event = JSON.parse(ev.data);
// event.type === "create" | "update" | "delete"
});
  • Broadcast reaches every subscriber of one server, and of every server sharing one Postgres database. Under the deprecated vaultbase cluster it stays per worker.
  • No durable replay — events sent while a client was disconnected aren’t delivered later. Refetch after a reconnect.
  • Hooks bypass realtime — helpers.find/query reads but doesn’t fire events.