- WebSocket:
wss://{ref}.p.powabase.ai/realtime/v1/websocket— for subscriptions. - REST:
https://{ref}.p.powabase.ai/realtime/v1/api— for server-side broadcast.
Authentication
Realtime authenticates the WebSocket at upgrade time via query parameters. There are no headers on the WS handshake because most browser WebSocket APIs don’t let you set them.| Parameter | Required | Meaning |
|---|---|---|
apikey | Yes | The Anon Key, a signed-in user’s access token, or the Service Role Key |
vsn | Yes | Protocol version. Use 1.0.0. |
log_level | No | error, warn, info, debug. Server-side logging verbosity for this connection. |
apikey + Authorization: Bearer <token> — and requires the Service Role Key (not the Anon Key).
Frame format
All WebSocket frames are Phoenix Channels JSON envelopes:topic— the channel name. Convention isrealtime:<schema>:<table>for postgres_changes channels, or any arbitrary string for broadcast/presence.event— what kind of frame (see below).payload— event-specific data.ref— client-chosen ID that the server echoes back onphx_reply. Useful for correlating sends with their acknowledgments.
WebSocket events
phx_join
Client → server. Subscribes to a channel.config.broadcast:
self(bool, defaultfalse) — receive your own broadcast messages.ack(bool, defaultfalse) — receivephx_replyconfirming the broadcast was routed.
config.presence:
key(string) — unique identifier for this presence (typically the user id). Used for dedupe across multiple connections.
config.postgres_changes — array of filter specs:
event(string) —INSERT,UPDATE,DELETE, or*for all three.schema(string) — target schema.publicis the common case.table(string) — target table.filter(string, optional) — column equality in PostgREST-style syntax (e.g.,user_id=eq.<uuid>,status=eq.active).
config.private (bool, default false) — when true, Realtime checks the SELECT policy on realtime.messages before letting you join. Without a passing policy, the join returns an error.
phx_reply
Server → client. Acknowledgment for aphx_join, broadcast, or other client-initiated frame.
ref matches the client’s send. On error, response includes { reason: "<machine-readable>" }.
postgres_changes
Server → client. A row change matching a previously-subscribed postgres_changes config.UPDATE, both new and old are populated. For DELETE, only old is. The columns the client sees depend on the table’s REPLICA IDENTITY — DEFAULT shows changed columns plus the primary key on UPDATE; FULL shows every column. To set FULL:
broadcast
Client → server, and server → client. Custom messages on the channel. Client send:event field inside payload is your custom event type (e.g., chat_message, typing, cursor_move). The outer event is always broadcast.
presence_state
Server → client. Initial snapshot of who’s currently tracked in the channel, sent right after a successfulphx_join with presence config.
key values from each presence — typically user ids. The values are arrays because the same key can be tracked from multiple connections (e.g., two browser tabs by the same user).
presence_diff
Server → client. Incremental changes to the presence state after the initialpresence_state.
action is track or untrack. data is what other subscribers see in presence_state.
system
Server → client. Notifications about the channel itself (subscribed, unsubscribed, errors).system frame with status: "error" here usually means the publication isn’t set up — see Realtime model.
phoenix heartbeat
Client → server. The Phoenix Channels convention is a heartbeat every 30 seconds; if no heartbeat arrives for roughly two intervals (the Phoenix default — Powabase doesn’t override it), Realtime closes the connection. Most clients send heartbeats automatically.phx_reply of { status: "ok" }. If you don’t get an OK within a few seconds, the connection is probably half-open — disconnect and reconnect.
phx_leave
Client → server. Cleanly leave a channel without closing the WebSocket.phx_close
Server → client. Channel was closed (by you, by an error, or by the server). After this, send a newphx_join to re-subscribe.
REST: broadcast
For server-side message emission. Use the Service Role Key.POST /realtime/v1/api/broadcast
Send one or more broadcast messages to channels.Array of message objects.
Channel name to broadcast on.
Your custom event type (matches the inner
event field clients see).Your message data.
Default
false. When true, only clients subscribed to a private version of the channel receive the message.{ "message": "ok" } on success. The endpoint is fire-and-forget — it doesn’t tell you how many subscribers received the message.
SQL: realtime.send() and realtime.broadcast_changes()
The Realtime image installs two functions in therealtime schema that let your Postgres triggers emit broadcast messages directly:
realtime.send(payload jsonb, event text, topic text, private boolean default false)
Send a single broadcast message from SQL. Equivalent to a single-message POST to/realtime/v1/api/broadcast.
realtime.broadcast_changes(topic text, event text, op text, table text, schema text, new record, old record, level text default ‘topic’)
Higher-level wrapper for “broadcast this row change.” Designed to be called from a trigger function (see Realtime model for a worked trigger example). Thelevel parameter is the channel-privacy level:
'topic'(default) — sends to a private channel; subscribers need a passing RLS policy onrealtime.messagesto receive.- Any other string — treated as public.
Channel-private auth: realtime.messages
Private channels gate join with a SELECT policy onrealtime.messages. The function realtime.topic() returns the channel name being checked, which lets the policy condition the decision on the topic:
Error responses
WebSocket errors arrive asphx_reply frames with payload.status: "error":
Reason (in payload.response.reason) | When |
|---|---|
unauthorized | The JWT failed signature verification or has expired |
unmatched_topic | The topic shape doesn’t match a known pattern (e.g., empty string) |
invalid_join_payload | The config block is malformed |
private_unauthorized | private: true but no matching SELECT policy on realtime.messages |
pg_publication_missing | Subscribed to postgres_changes but supabase_realtime publication doesn’t exist |
pg_filter_invalid | The filter syntax doesn’t parse (e.g., wrong operator) |
| HTTP status | Reason | When |
|---|---|---|
| 403 | TenantNotFound | Kong didn’t preserve the Host header. Self-hosted only; on Powabase managed cloud this is a platform bug. |
| 401 | Bad apikey query param | The apikey is missing, malformed, or rejected |
{ "error": "<message>" }.
Service versions and notes
- Realtime:
v2.65.3 - Both routes (
/realtime/v1/and/realtime/v1/api) use Kong’spreserve_host: trueto let Realtime parsetenant_idfrom the Host header. - Per-project Realtime pods are seeded with
SEED_SELF_HOST=true, so the tenant is created on first connect rather than requiring an out-of-band provisioning step. - The Realtime
DB_ENC_KEYis shared across Powabase deployments — it’s an internal multi-tenant encryption key, not a per-project secret. Not user-relevant.
Next steps
Realtime model
The conceptual underpinning: three channels, auth, the publication gotcha.
Realtime subscriptions
Three worked patterns in TypeScript with the protocol details from this page applied.
RLS Cookbook
The policies that gate private channels and the underlying tables for Postgres Changes.
Auth Reference
Where the access tokens that authenticate Realtime connections come from.