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 DELETErow trigger. The trigger publishes each change withpg_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 exampletable:public.messages.table:messagesmeans thepublicschema.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_realtimeandorbitnest_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/:tabledisables a table, andGET /api/projects/:slug/realtime/tableslists 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 offerorbitnest-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:
| Code | Reason |
|---|---|
4001 | Missing, invalid or revoked API key, unknown project, or a user token that failed verification (auth_failed) |
4029 | Rate limit exceeded |
4403 | Origin not allowed for this project |
Listen to row changes
JavaScript
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:
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
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.
| Operator | Matches when the column value is |
|---|---|
eq | Equal to the value |
neq | Not equal to the value |
in | One of the values in the array |
gt | Greater than the value |
lt | Less than the value |
like | A 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
newis the full row after an insert or update.oldis 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
idbefore sending it. For such deletes,oldcontains onlyid. - Realtime identifies rows by a column named
id. Give realtime tables anidprimary key. Connections that don't use the service role key receive no events from tables without anidcolumn, because the row can't be checked.
Row visibility
What a connection receives depends on its credentials:
| Connection | Receives |
|---|---|
| Service-role key | Every change on subscribed tables, including deletes |
| Anon key only | Inserts and updates whose row the anon role can SELECT under RLS |
| Anon key plus a user access token | Inserts 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:
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 itThe 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.
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.
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 }.
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.
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 rosterfinal 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
| Limit | Value |
|---|---|
| Subscriptions per connection | 100 |
| Client messages per connection | Bursts of 60, refilled at 30 per second. Exceeding it closes the connection with code 4029. Pings don't count. |
| Broadcast payload | 64 KB |
| Presence state | 8 KB |
| Server ping interval | 30 seconds |