Skip to Content
API ReferenceReal-time & Subscriptions

Real-time and Subscriptions

Foir offers two real-time systems:

  • GraphQL WebSocket subscriptions on api.foir.dev/graphql/ws — subscribe to record-change and operation-execution events with the same origin and API key as your queries and mutations.
  • The realtime gateway on realtime.foir.io — lightweight SSE / WebSocket event channels (record changes, notifications, job progress) and Yjs-based collaborative record editing, authenticated with a customer access token.

Reach for GraphQL subscriptions when you’re already on the GraphQL / API-key path; reach for the gateway for per-customer event streams and collaborative editing inside your app.

GraphQL WebSocket Subscriptions

GraphQL subscriptions use the same origin as queries and mutations, so your client setup is straightforward.

Endpoint

wss://api.foir.dev/graphql/ws

Foir uses the graphql-ws  protocol for WebSocket subscriptions.

Connection Setup

Initialize the connection with your API key in the connection parameters:

import { createClient } from "graphql-ws"; const client = createClient({ url: "wss://api.foir.dev/graphql/ws", connectionParams: { apiKey: "pk_your_public_key", }, });

If you are using customer authentication, include the customer JWT as well:

const client = createClient({ url: "wss://api.foir.dev/graphql/ws", connectionParams: { apiKey: "pk_your_public_key", authorization: `Bearer ${customerAccessToken}`, }, });

Subscribing to Model Changes

Subscribe to real-time change events for specific models:

const unsubscribe = client.subscribe( { query: ` subscription OnRecordChange($modelKey: String!) { recordChanged(modelKey: $modelKey) { action recordId modelKey userId } } `, variables: { modelKey: "product" }, }, { next(data) { console.log("Record changed:", data); }, error(err) { console.error("Subscription error:", err); }, complete() { console.log("Subscription complete"); }, } ); // Later, to unsubscribe: unsubscribe();

The RecordChangedEvent payload is flat — there is no nested record object:

FieldTypeDescription
actionString!Change type (see below)
recordIdID!ID of the changed record
modelKeyString!Model the record belongs to
userIdStringActor that made the change, if known

Change Event Types

action valueDescription
createdA new record was created
updatedAn existing record was modified
publishedA record version was published
unpublishedA record was unpublished
deletedA record was deleted

Subscribing to Operation Progress

operationExecution streams progress and terminal events for a single operation execution (the id returned by ExecuteOperation). Each event carries the current status, so you can drive UI state from one field:

const unsubscribe = client.subscribe( { query: ` subscription OnExecution($id: String!) { operationExecution(id: $id) { id status # pending|dispatched|running|completed|failed|cancelled|timed_out progressPct # 0-100; null on terminal events progressMessage resultJson # present on status=completed errorJson # present on status=failed|timed_out } } `, variables: { id: "exec_abc123" }, }, { next(data) { console.log("Execution update:", data); }, error(err) { console.error("Subscription error:", err); }, complete() { console.log("Execution finished"); }, } );

Using with Apollo Client

import { ApolloClient, InMemoryCache, split, HttpLink } from "@apollo/client"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; import { createClient } from "graphql-ws"; import { getMainDefinition } from "@apollo/client/utilities"; const httpLink = new HttpLink({ uri: "https://api.foir.dev/graphql", headers: { "x-api-key": "pk_your_public_key", }, }); const wsLink = new GraphQLWsLink( createClient({ url: "wss://api.foir.dev/graphql/ws", connectionParams: { apiKey: "pk_your_public_key", }, }) ); const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); return ( definition.kind === "OperationDefinition" && definition.operation === "subscription" ); }, wsLink, httpLink ); const client = new ApolloClient({ link: splitLink, cache: new InMemoryCache(), });

Connection Lifecycle

The graphql-ws client handles reconnection automatically. You can configure retry behavior:

const client = createClient({ url: "wss://api.foir.dev/graphql/ws", connectionParams: { apiKey: "pk_your_public_key", }, retryAttempts: 5, retryWait: async (retryCount) => { await new Promise((resolve) => setTimeout(resolve, Math.min(1000 * 2 ** retryCount, 16000)) ); }, on: { connected: () => console.log("WebSocket connected"), closed: () => console.log("WebSocket closed"), error: (err) => console.error("WebSocket error:", err), }, });

Realtime Gateway

The realtime gateway at realtime.foir.io powers two things for your app: event channels (a lightweight pub/sub stream over SSE or WebSocket) and collaborative record editing (Yjs). Discover the gateway URL at runtime from the public API:

curl "https://api.foir.dev/collab/info?recordId=rec_123&modelKey=product" -H "x-api-key: pk_your_public_key"
{ "enabled": true, "wsUrl": "wss://realtime.foir.io", "roomName": "product:rec_123:default", "auth": { "method": "query-param", "params": "token (customer JWT) or apiKey" } }

Authentication

The gateway authenticates with a customer access token passed as a ?token= query parameter — SSE (EventSource) and browser WebSocket clients can’t set headers, so the token rides on the URL. Use the EdDSA access token your app already holds from Login with Foir (the same token you send as Authorization: Bearer to the public API). Tokens are verified against https://api.foir.dev/.well-known/jwks.json.

Event Channels

Subscribe to a channel to receive a live stream of events. Every event is a JSON object with the same shape:

{ "channel": "changes:product", "event": "record.updated", "data": { "modelKey": "product", "...": "..." } }

Available channels:

ChannelEmitsNotes
changes:{modelKey}record.created, record.updated, …Record changes for one model. Use changes:* for every model.
notifications:{customerId}notification.{type}Per-customer notifications. You may only subscribe to your own customer id.
jobs:{executionId}job.{status}Operation / job progress. Use jobs:* for all.

changes:* and jobs:* are wildcards matching every channel under the prefix.

Server-Sent Events (one channel per stream)

EventSource opens one channel per connection and reconnects automatically:

const es = new EventSource( `https://realtime.foir.io/sse/changes:product?token=${customerAccessToken}` ); es.addEventListener("connected", (e) => console.log("connected", JSON.parse(e.data))); es.onmessage = (e) => { const { channel, event, data } = JSON.parse(e.data); console.log(event, data); };

The stream opens with an event: connected frame, then delivers data: frames; a : heartbeat comment is sent every 30 seconds to keep the connection alive.

WebSocket (many channels per connection)

To multiplex several channels over a single socket, connect to /_events and send subscribe / unsubscribe control frames:

const ws = new WebSocket(`wss://realtime.foir.io/_events?token=${customerAccessToken}`); ws.onopen = () => { ws.send(JSON.stringify({ type: "subscribe", channel: "changes:product" })); ws.send(JSON.stringify({ type: "subscribe", channel: `notifications:${customerId}` })); }; ws.onmessage = (e) => { const { channel, event, data } = JSON.parse(e.data); console.log(channel, event, data); }; // Stop receiving a channel: ws.send(JSON.stringify({ type: "unsubscribe", channel: "changes:product" }));

Record changes are also available as a typed recordChanged GraphQL subscription on api.foir.dev. Use that when you’re on the GraphQL / API-key path; use gateway channels for per-customer notifications, job progress, and customer-token-authenticated streams.

Collaborative Editing

The gateway runs a Yjs  collaboration server so several users can edit the same record live, with shared cursors and presence. Open one room per record:

wss://realtime.foir.io/{modelKey}:{recordId}:{variantId}?token={customerAccessToken}

The room name is {modelKey}:{recordId}:{variantId} (use the record’s default variant id when you aren’t using variants). Connect any standard Yjs WebSocket provider; the shared document’s content map holds the record’s field values, and Yjs awareness carries presence — who’s editing and where their cursor is.

Access control. A customer may only join a record’s room if they can write that record — they own it, or have been granted edit access (an accepted edit or admin share). Otherwise the connection is rejected with 403. Each identity may hold up to 10 open rooms at once by default; exceeding that rejects the connection with 429 (a Retry-After header and a JSON body { "error": "room_limit_exceeded", "limit", "held" }). Reconnecting to a room you already hold does not count against the limit.

How edits are saved depends on whether the model is versioned:

  • Unversioned models — auto-commit. Edits are debounced and persisted to the record automatically, attributed to the last editor, with a final flush when the last editor disconnects. There’s nothing extra to call — collaborative edits just save themselves.
  • Versioned models — collaborate, then save. Live edits are shared across all participants (and recovered if everyone disconnects), but they are not auto-persisted. Committing is an explicit step your app drives through the normal record APIs — save the draft, then publish a version (update<Model> + publish<Model>Version, or foir records save + foir records publish). This keeps each published version an intentional, reviewable snapshot.

Gateway status

EndpointReturns
GET https://realtime.foir.io/health{ "status": "ok" }
GET https://realtime.foir.io/api/realtime/status{ channels, clients } — active channel and client counts
GET https://realtime.foir.io/api/collab/room-status/{room}{ exists, clients } for a collab room
Last updated on