Nimbu Developer Docs

Realtime API

Receive live channel entry changes over a websocket: request a one-time grant, subscribe to RealtimeApiChannel, renew leases, handle added/changed/removed/resync events, and filter with live queries.

The Realtime API pushes channel entry changes to connected clients over a websocket. Events are hints: they tell you something changed, and the REST API stays the source of truth. A client subscribes, applies events as they arrive, and re-reads over REST whenever the server asks it to resync.

Realtime is opt-in per site. Nimbu support enables it for your site (the "Enable Realtime API" site setting). Until then, grant requests return 403 Realtime API is disabled. The grant endpoint is in the API reference as POST /realtime/grants.

There is no official JavaScript SDK client for realtime yet. Any ActionCable-compatible client works with the protocol below.

1. Request a grant

A grant is a short-lived, one-time credential for opening the websocket. It is the browser-safe way to connect: your page never needs to put a long-lived token in a websocket URL.

curl -X POST "https://api.nimbu.io/realtime/grants" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Nimbu-Site: $SITE_ID"
{
  "grant": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_at": "2026-09-25T10:02:00Z"
}

The endpoint returns 201 and accepts the same credentials as the rest of the API:

IdentityHow to request
OAuth bearer tokenAuthorization: Bearer …, plus X-Nimbu-Site for user-scoped tokens.
Customer sessionX-Nimbu-Session-Token (or Session-Token) and the site.
AnonymousNo credentials; pass the site as X-Nimbu-Site or {"site_id": "…"} in the body.

Grants expire after two minutes and are consumed on first use. Request a fresh grant for every (re)connect. Treat a grant like a token: keep it out of logs, analytics, and error reports.

StatusMeaning
401Credentials were sent but are invalid.
403Realtime is disabled for the site, or the OAuth token cannot read channels.
422No site could be resolved (site_id is required).
429Too many anonymous grant requests from the same client (limit: 60 per minute).

2. Connect

Open the websocket with the grant in the query string:

wss://<your-site-host>/ws?grant=<grant>

The connection speaks the standard ActionCable protocol. A rejected or reused grant closes the connection; request a new grant and reconnect.

3. Subscribe

Subscribe to RealtimeApiChannel. The subscription parameters live in the ActionCable identifier:

{
  "command": "subscribe",
  "identifier": "{\"channel\":\"RealtimeApiChannel\",\"resource\":\"channel_entries\",\"parent_id\":\"news\"}"
}
ParameterNotes
resourceRequired. Only channel_entries is supported.
parent_idChannel id or slug, same as {channel_id} in REST. Recommended: watch one channel.
target_idOptional entry id. Only events for that entry are delivered.
queryOptional live query filter. Requires parent_id.
firehosetrue to watch every channel, including sensitive ones. Master site tokens with channel and sensitive-channel read scopes only.

Without parent_id, anonymous and customer-session clients watch all non-sensitive channels of the site; entry ACLs still apply to every event. A connection can hold up to 25 subscriptions.

Who receives what

Every event is checked against the subscriber's own identity before it is sent:

  • Entry ACLs are applied per event, as in Channel access control.
  • Unpublished entries are only delivered to master tokens.
  • Sensitive channels are never delivered to anonymous or customer-session connections. OAuth tokens need read_sensitive_channels (or write_sensitive_channels), and a user without sensitive-channel permission on the site is refused even with the scope. Master site tokens also qualify.
  • OAuth tokens need read_channels (or write_channels) to subscribe at all.

Control messages

The server answers subscribe, renew, and unsubscribe with control messages. They are protocol state, not optional metadata.

subscribed confirms the watch:

{
  "type": "subscribed",
  "subscription_id": "…",
  "resource": "channel_entries",
  "firehose": false,
  "query": { "priority.gte": 3, "status": "open" },
  "seq_at_create": 18234,
  "resync": true,
  "lease": { "expires_at": 1790330520.0, "renew_after": 1790330220.0 }
}

resync: true means: do an authoritative REST read of your scope now. Events that happened while the subscription was being set up are not replayed.

Leases. A subscription is a lease of up to 10 minutes. expires_at and renew_after are Unix seconds. Renew at renew_after (the midpoint) by performing the renew action:

{
  "command": "message",
  "identifier": "{\"channel\":\"RealtimeApiChannel\",\"resource\":\"channel_entries\",\"parent_id\":\"news\"}",
  "data": "{\"action\":\"renew\"}"
}

renewed carries one entry in results. A successful renewal repeats the subscription and lease fields with resync: true. { "status": "inactive", "resync": true } means the watch ended (for example access was revoked, or a live query no longer compiles): resync over REST and subscribe again.

unsubscribed answers the unsubscribe action with { "removed": true }, or { "status": "inactive" } when the watch was already gone.

subscription_error rejects a subscribe with a stable code:

CodeMeaning
not_availableUnknown resource, channel not found, or no access. Deliberately does not say which.
invalid_queryThe live query is malformed or uses an unsupported operator.
limit_reachedThis connection already holds 25 subscriptions.
site_limit_reachedThe site is at its realtime capacity.

Events

{
  "event_id": "5b0c8f7e-3d2a-4a51-9f59-0c6f2a0f8e11",
  "event": "changed",
  "resource": "channel_entries",
  "parent_id": "66f0a1b2c3d4e5f607182900",
  "id": "66f0a1b2c3d4e5f607182940",
  "type": "channel_entries.updated",
  "object": { "id": "66f0a1b2c3d4e5f607182940", "title": "Summer sale starts Monday", "status": "open" },
  "changeset": { "status": "draft" },
  "occurred_at": "2026-09-25T10:00:03.412Z",
  "revision": 18240
}
eventMeaning
addedThe entry became visible to this subscription: created, or updated into your filter/ACL.
changedThe entry was visible before and after an update.
removedThe entry is no longer visible: deleted, or updated out of your filter/ACL. No object.
resyncRe-read your scope over REST. No object. Sent for example when a watched channel is deleted.

type is channel_entries.created, .updated, .deleted, or .resync, describing the write that happened. event describes what it means for your subscription: an update can arrive as added or removed.

  • object is the entry as the REST API would return it to an authorized reader, in the site's default locale. Relations are ids. ACL/owner metadata is left out.
  • changeset (on changed) lists the fields that changed with their old values.
  • Delivery is at-least-once. Deduplicate by event_id.
  • revision is site-wide. Subscriptions legitimately skip revisions for events outside their watch, so a gap is not proof of a lost event. A cautious client may treat a gap as a reason to resync.

Resync over REST after a reconnect, an inactive renewal, a lease expiry, or any resync signal.

Live queries

A channel-scoped subscription can pass a query with the same filters as the REST entries list: field=value, field.op=value, and a where expression. Only matching transitions are delivered (added when an entry starts matching, removed when it stops).

{
  "channel": "RealtimeApiChannel",
  "resource": "channel_entries",
  "parent_id": "tickets",
  "query": { "status": "open", "priority.gte": 3 }
}

query requires parent_id and is not allowed with firehose. The subscribed and renewed controls echo the normalized query; resync over REST with the same filter.

Supported: equality, ne, gt, gte, lt, lte, in, nin, all, contains, not_contains, exists, start, end, matches, select/multi-select by option name, relations by id, dates, times, booleans, _status, slug, and where expressions with and, or, and not.

Rejected with invalid_query, including inside a where expression:

  • search (full-text) and regex (use contains, start, or end)
  • geo operators (near, geoWithin, geoIntersects)
  • inverse_of_* relations, _acl/_owner, and $-prefixed keys
  • sort, pagination, and projection keys (sort, page, per_page, limit, fields, …)
  • more than 20 keys, or a query larger than 4 KB
  • a negated operator (ne, nin, not_contains, !=) on a dotted sub-field path such as color.title.ne; the positive forms work
  • more than one operator on the same dotted sub-field path (for example items.score.gt with items.score.lt)

Localized fields and select option names match against the site default locale only. Encrypted fields never match. A query only narrows delivery; ACL, publication, and channel access are checked first.

Client checklist

  1. POST /realtime/grants, then connect to /ws?grant=….
  2. Subscribe; on subscribed, load your scope over REST.
  3. Apply events, deduplicating by event_id.
  4. Renew at renew_after.
  5. On disconnect, inactive, or resync: get a new grant if needed, resubscribe, and re-read over REST.

On this page