Platform

Realtime

Stream inserts, updates and deletes from your tables over WebSocket, and send broadcast messages and presence between connected clients.

Realtime pushes row changes from the tables you enable to connected clients over a WebSocket. The same connection carries broadcast messages between clients and a presence roster of who is online.

How it works

  • Enabling realtime on a table installs an AFTER INSERT OR UPDATE OR DELETE row trigger. The trigger publishes each change with pg_notify, and the API forwards it to every connection subscribed to that table.
  • Clients connect to wss://api.orbitnest.io/realtime/v1/ws?project=<slug>. This path isn't under /api.
  • A connection subscribes to channels:
    • table:<schema>.<table> for row changes, for example table:public.messages. table:messages means the public schema.
    • broadcast:<name> for broadcast messages and presence.

The SDKs build these channel names for you.

Enable realtime on a table

Tables don't stream changes until you enable them. You can enable realtime in three ways:

  • Studio: open Realtime in the project, then switch a table on or off. The page also shows channel names and can send a test broadcast.

  • MCP server: orbitnest_enable_realtime, orbitnest_disable_realtime and orbitnest_list_realtime_tables.

  • REST, with a service-role key:

    Terminal
    curl -X POST "https://api.orbitnest.io/api/projects/my-app/realtime/tables" \
      -H "Authorization: Bearer $ORBITNEST_SERVICE_KEY" \
      -H "Content-Type: application/json" \
      -d '{"table": "messages", "schema": "public"}'

    DELETE /api/projects/:slug/realtime/tables/:schema/:table disables a table, and GET /api/projects/:slug/realtime/tables lists them with any of the project's current API keys.

Tables whose names start with auth_ or _realtime, and the pg_catalog and information_schema schemas, can't be enabled.

Realtime is available on every plan, including the Studio Realtime page and the MCP tools.

A webhook that targets a specific table also enables realtime on that table, so the table appears in this list.

Connect and authenticate

The SDKs handle the connection. If you write your own client, send the project API key on the WebSocket handshake:

  • As a subprotocol, orbitnest.apikey.<api key>. This works in browsers, which can't set handshake headers. The SDKs also offer orbitnest-realtime-v1.
  • Or as an Authorization: Bearer <api key> handshake header, from non-browser clients.

The API still accepts ?apikey= in the URL for older SDK versions. Avoid it: URLs end up in proxy logs and browser history.

The key must be the project's current anon or service role key. Keys replaced by Regenerate keys are rejected.

To have row events checked against a signed-in user's RLS policies, also offer the subprotocol orbitnest.user-jwt.<user access token>. The token is verified when the connection opens:

  • It must be signed with the project's JWT secret and must be an access token. Refresh tokens and the project's API keys are rejected.
  • For tokens issued by OrbitNest Auth, the user's session must still be active. Tokens from signed-out sessions, and tokens for deleted or banned users, are rejected.

If the token fails these checks, the server sends {"type":"error","code":"auth_failed","message":"..."} and closes the connection with code 4001. The token is checked only on connect, so reconnect with a fresh token when it is refreshed. See Row visibility.

Browser connections must come from an allowed origin: one of the project's App URLs (Project Settings), *.orbitnest.io, localhost or 127.0.0.1. Clients that send no Origin header, such as servers and mobile apps, are allowed.

The server sends a WebSocket ping every 30 seconds. It closes connections with these codes:

CodeReason
4001Missing, invalid or revoked API key, unknown project, or a user token that failed verification (auth_failed)
4029Rate limit exceeded
4403Origin not allowed for this project

Listen to row changes

JavaScript

TypeScript
import { createClient, localStorageAdapter } from '@orbitneststudio/js';

const orbit = createClient({
  apiKey: import.meta.env.VITE_ORBITNEST_ANON_KEY,
  storage: localStorageAdapter,
});

const channel = orbit
  .channel('room-42')
  .on(
    'postgres_changes',
    { event: 'INSERT', table: 'messages', filter: { room_id: { eq: 'room-42' } } },
    (change) => console.log('new message', change.new),
  )
  .subscribe((status, err) => console.log(status, err));

// Later
await channel.unsubscribe();

event is INSERT, UPDATE, DELETE, * or an array of these. schema defaults to public. The callback receives { op, schema, table, new, old, commit_ts }. The status callback receives connecting, connected, disconnected or error. The client reconnects automatically with exponential backoff.

Node.js

The Node.js SDK has the same API. Node.js 22 and later have a global WebSocket. On older versions, pass one from the ws package:

TypeScript
import WebSocket from 'ws';
import { createClient } from '@orbitneststudio/node';

const client = createClient({
  apiKey: process.env.ORBITNEST_SERVICE_KEY!,
  realtimeOptions: { WebSocket },
});

client
  .channel('orders')
  .on('postgres_changes', { event: '*', table: 'orders' }, (change) => {
    console.log(change.op, change.new ?? change.old);
  })
  .subscribe();

Flutter

Dart
final channel = orbitnest.channel('room-42')
  ..onPostgresChanges(
    event: PgEvent.insert,
    table: 'messages',
    filter: const RealtimeFilter(column: 'room_id', op: 'eq', value: 'room-42'),
    callback: (change) => print(change.newRow),
  );
await channel.subscribe();

// Later
await channel.unsubscribe();

PgEvent is insert, update, delete or all. The payload has op, schema, table, newRow, oldRow and commitTs. Row changes, broadcasts, presence updates and system messages on the channel are also available as a stream on channel.events.

Filters

A filter maps column names to one condition each. A row must match every column in the filter.

OperatorMatches when the column value is
eqEqual to the value
neqNot equal to the value
inOne of the values in the array
gtGreater than the value
ltLess than the value
likeA match for an SQL LIKE pattern, where % is any run of characters and _ is one character

The API evaluates filters against the new row for inserts and updates, and against the old row for deletes. In Flutter, pass one RealtimeFilter per handler.

Note

The server holds one subscription per table on each connection, and the last subscribe for a table replaces the previous one. The JavaScript and Node.js SDKs handle this for you: they subscribe with the combined events of every handler on the table, and each handler checks its own filter on the client. In Flutter, two onPostgresChanges handlers for the same table, even on different channels, share one subscription, and the last one's events and filter apply.

Payloads

  • new is the full row after an insert or update. old is the full row before an update or delete.
  • When a change is larger than about 7,900 bytes of JSON, the API re-reads the row by its id before sending it. For such deletes, old contains only id.
  • Realtime identifies rows by a column named id. Give realtime tables an id primary key. Connections that don't use the service role key receive no events from tables without an id column, because the row can't be checked.

Row visibility

What a connection receives depends on its credentials:

ConnectionReceives
Service-role keyEvery change on subscribed tables, including deletes
Anon key onlyInserts and updates whose row the anon role can SELECT under RLS
Anon key plus a user access tokenInserts and updates whose row the user can SELECT under RLS

For every connection except the service role, the API re-reads each changed row by id, as the anon role or as the authenticated role with the user's claims, and sends the event only if the row comes back. That role needs SELECT on the table. Delete events are only sent to service-role connections, because a deleted row no longer exists when it's checked. See Database → Row-level security.

The JavaScript and Node.js SDKs connect with the signed-in user's access token automatically. They fetch a fresh token before each connect and reconnect when the user signs in, signs out or the session is refreshed.

To manage the token yourself, pass realtimeOptions.userJwt when you create the client. That turns off the automatic behaviour, and realtime.setAuth(token) switches to another token:

TypeScript
const live = createClient({
  apiKey: import.meta.env.VITE_ORBITNEST_ANON_KEY,
  realtimeOptions: { userJwt: initialToken },
});

live.realtime.setAuth(newToken); // reconnects with the new token; null drops it

The Flutter SDK also sends the signed-in user's access token on each connect, and reconnects realtime when the signed-in user changes. Call orbitnest.realtime.reconnect() to reconnect manually.

Broadcast

Broadcast sends messages between clients on a named channel without touching the database: chat typing indicators, cursor positions, game state. A client must subscribe to a channel before it can send on it, and the sender receives its own messages. A payload can be up to 64 KB of JSON.

TypeScript
const room = orbit
  .channel('cursors')
  .on('broadcast', { event: 'move' }, ({ event, payload }) => {
    console.log(event, payload);
  })
  .subscribe();

await room.send('move', { x: 120, y: 48 });

Use { event: '*' } to receive every event on the channel.

Dart
final room = orbitnest.channel('cursors')
  ..onBroadcast(event: 'move', callback: (msg) => print(msg.payload));
await room.subscribe();

await room.send(event: 'move', payload: {'x': 120, 'y': 48});

Broadcast from a server or edge function

Servers and edge functions can broadcast without opening a WebSocket. This endpoint requires a service-role key. A broadcast: prefix is added to channel if it's missing. The response is { "ok": true }.

Terminal
curl -X POST "https://api.orbitnest.io/api/projects/my-app/realtime/broadcast" \
  -H "Authorization: Bearer $ORBITNEST_SERVICE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"channel": "notifications", "event": "new_order", "payload": {"orderId": "12345"}}'

The MCP tool orbitnest_broadcast does the same.

Presence

Presence tracks who is connected to a channel. Each connection can track a state object of up to 8 KB on a channel. Whenever a connection tracks, untracks or disconnects, the API sends the full roster to every connection subscribed to the channel. The roster maps a server-generated connection id to a list containing that connection's state. Tracking again replaces the connection's state.

To receive roster updates, a client must be subscribed to the channel's broadcast stream, so register a broadcast handler too.

TypeScript
const lobby = orbit.channel('lobby');

lobby.presence
  .onSync((state) => console.log('online:', Object.keys(state).length))
  .onJoin((key, presences) => console.log('joined', key, presences))
  .onLeave((key, presences) => console.log('left', key, presences));

lobby.on('broadcast', { event: '*' }, () => {}).subscribe();

await lobby.presence.track({ userId: 'u_123', name: 'Ada' });
// lobby.presence.getState() returns the last roster
Dart
final lobby = orbitnest.channel('lobby')
  ..onBroadcast(event: '*', callback: (_) {})
  ..onPresenceSync((state) => print('online: ${state.length}'))
  ..onPresenceJoin((key, presences) => print('joined $key'))
  ..onPresenceLeave((key, presences) => print('left $key'));
await lobby.subscribe();

await lobby.track({'userId': 'u_123', 'name': 'Ada'});

Join and leave callbacks are computed by the SDK by comparing consecutive rosters. Presence is held in the API server's memory and is cleared when a connection closes.

To stop sharing presence without leaving the channel, call untrack() (lobby.presence.untrack() in JavaScript, lobby.untrack() in Flutter). The SDK sends {"type": "presence.untrack", "channel": "broadcast:<name>"}. The server removes the connection's state and sends the updated roster to every subscriber, and the connection keeps receiving broadcasts. There is no acknowledgement message.

Limits

LimitValue
Subscriptions per connection100
Client messages per connectionBursts of 60, refilled at 30 per second. Exceeding it closes the connection with code 4029. Pings don't count.
Broadcast payload64 KB
Presence state8 KB
Server ping interval30 seconds