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/wsFoir 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:
| Field | Type | Description |
|---|---|---|
action | String! | Change type (see below) |
recordId | ID! | ID of the changed record |
modelKey | String! | Model the record belongs to |
userId | String | Actor that made the change, if known |
Change Event Types
action value | Description |
|---|---|
created | A new record was created |
updated | An existing record was modified |
published | A record version was published |
unpublished | A record was unpublished |
deleted | A 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:
| Channel | Emits | Notes |
|---|---|---|
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
recordChangedGraphQL subscription onapi.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, orfoir records save+foir records publish). This keeps each published version an intentional, reviewable snapshot.
Gateway status
| Endpoint | Returns |
|---|---|
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 |