Skip to content

Realtime changes and presence

Sync Server pushes a message the moment a table’s data changes — whether the change came from a person in the grid, an API key, an Airtable webhook, a scheduled sync or an MCP tool — and tells you who did it. The same socket carries collaborator presence (which cell each person is on), and every change is also written to a per-record activity log you can read over REST.

It is a plain WebSocket with a handful of JSON messages; the protocol is documented below. The Sync Server UI uses the same socket.

wss://{SERVER}/api/v0/realtime

Authenticate exactly as you would a FeatureServer read — any one of:

Credential How to send it
Session cookie Nothing to do on the same origin (the Sync Server UI).
App token from @imaps/sync-server-auth ?token=<jwt> or the bearer.<jwt> subprotocol.
API key (ims_…) ?token=<key> or the bearer.<key> subprotocol.

An unauthenticated upgrade is refused with 401. Which bases you may subscribe to is decided per base with the same rules as reads: public base, org membership, or a key/token covering the base.

// Token in the query string…
const ws = new WebSocket(`wss://sync.example.com/api/v0/realtime?token=${encodeURIComponent(auth.token)}`);
// …or as a subprotocol, which keeps it out of access logs
const ws = new WebSocket('wss://sync.example.com/api/v0/realtime', [`bearer.${auth.token}`]);

The first message the server sends is hello, naming your connection and who it thinks you are. Every message in both directions is one JSON object with a type.

ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', baseId: 'appXXXXXXXX', tableName: 'Locations' }));
});
ws.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
switch (msg.type) {
case 'subscribed': console.log('live from revision', msg.revision); break;
case 'change':
// msg.op: 'add' | 'update' | 'delete' | 'reload'
// msg.records: [{ key, objectId, prevObjectId }] (absent for 'reload')
// msg.actor: who did it — see "Actors"
layer.refresh();
break;
case 'presence': showCursors(msg.members); break;
case 'error': console.warn(msg.code, msg.message); break;
}
});

revision is the cache’s modification time in milliseconds. Compare it with the metadata.cache.created you loaded to decide whether a refresh is needed — and, after a reconnect, compare the new subscribed.revision with the last one you saw: if it moved, reload.

For Airtable tables without a configured id field the served OBJECTID is a content hash, so it changes when the record changes. Events therefore carry the provider’s stable key (the Airtable record id) plus prevObjectId → objectId. Re-key any client-side state by key, not by object id.

Every event names its cause:

type Actor =
| { kind: 'user'; id; name?; email?; image? } // a person in the UI or with an app token
| { kind: 'apiKey'; id; name; orgId? } // an ims_ key
| { kind: 'webhook'; provider: 'airtable' | 'nocodb' }
| { kind: 'sync'; trigger: 'scheduled' | 'manual' | 'on-demand'; requestedBy?: Actor }
| { kind: 'mcp'; via: Actor }; // an LLM tool, on behalf of `via`

The Sync Server UI renders these as “Sam”, “Reporting bot (API key)”, “Airtable webhook”, “Scheduled sync”, “Sam via MCP”; use the same wording and your users will recognise it.

Send your own cursor; receive everyone else’s. The server assigns each person a stable colour and drops frames beyond 10 per second (so throttle, or let the client do it).

ws.send(JSON.stringify({ type: 'presence', baseId: 'appXXXXXXXX', tableName: 'Locations', cursor: { objectId: 42, field: 'Status' }, editing: false }));

API-key connections may subscribe but are never shown as collaborators.

GET {SERVER}/api/v0/bases/{baseId}/tables/{tableName}/activity?objectId=42&limit=50
GET {SERVER}/api/v0/bases/{baseId}/tables/{tableName}/activity?key=recXXXX
GET {SERVER}/api/v0/bases/{baseId}/tables/{tableName}/activity (whole table)

Returns { items, nextBefore }; pass before=<nextBefore> for older pages. Each item has op, actor, fields (names touched, when known), diff (before/after values when the writer had them — records REST and applyEdits do, webhooks only know field names, syncs only that the table was refreshed), hasSnapshot, context (writer ip/user agent, and rollback provenance when the entry is a rollback) and createdAt.

Every update and delete also stores a snapshot of the record as it was before the change. One entry with its snapshot body:

GET {SERVER}/api/v0/bases/{baseId}/tables/{tableName}/activity/{id}

An objectId that is no longer in the cache (a deleted record) is resolved against the log itself, so history survives the record.

Logging is best-effort by default: if the insert fails, the write still succeeds and the failure is logged at error level. A deployment can be configured so the trail matters more than the write: the request then fails with a 500 saying the change was applied but could not be recorded. The data write has already committed by that point, so that mode surfaces an unrecorded change rather than preventing one.

GET {SERVER}/api/v0/bases/{baseId}/activity/retention
→ { baseDays, orgDays, envDays, effectiveDays }

The window is resolved base → org → an instance-wide default. null at a level means inherit; 0 means keep forever, so a base can opt out of an org-wide window. effectiveDays: null = nothing is ever purged, which is the default with nothing configured. Set it with activityRetentionDays on PATCH /orgs/{orgId} (org admin+) or PATCH /base/{baseDbId} (base owner).

POST {SERVER}/api/v0/bases/{baseId}/tables/{tableName}/rollback
{ at: "2026-09-08T10:00:00Z" } restore the table's state as of then
{ changeId: "…" } undo one change
{ recordKey: "recXXXX" } narrow either to one record
{ dryRun: true } return the plan, write nothing
{ force: true } restore fields edited since (undo only)
{ expectedRevision: 1757… } 409 if the table moved on meanwhile
POST {SERVER}/api/v0/bases/{baseId}/rollback
{ at, dryRun?, expectedRevisions?: { [tableName]: revision } }

How wide a restore may be depends on the provider.

A restore scoped to one record (recordKey) is a single provider call — applied or not — so it works on every writable provider, Airtable and NocoDB included. This is the common case: “put this row back the way it was”.

A restore spanning records needs a transaction to be undoable if it fails part way, and only the SQL writers have one. So table-wide and base-wide write-back is Postgres/PostGIS only; on Airtable/NocoDB it returns 501 and points you at recordKey or dryRun. SQL restores are also atomic per table and can put a deleted row back under its original primary key. writeBackSupported in the response says which side of the line you are on.

The restore is written through the ordinary edit pipeline, so it is permission-checked, respects read-only fields, and is itself logged with context.rollback.

The response carries the plan — adds, updates, deletes, plus skipped, conflicts and warnings — and, for a real run, results including rekeyed (re-created records get a new provider key, so { from, to } maps the old one).

What rollback deliberately will not do:

  • restore a change logged without a snapshot (skipped: 'no-snapshot') — pre-upgrade history and some webhook entries;
  • restore provider-computed fields (formulas, rollups, lookups, last-modified);
  • restore attachment or link cells (plan.unrestorable). An attachment’s cached URL points at our proxy, not the provider’s file, and a link cell is two-sided — restoring one would silently rewrite the far table. Attachments have their own write path (the Esri attachment endpoints);
  • restore geometry for a table with no geometry column configured — it warns instead;
  • overwrite a field someone has edited since the change being undone — that is a conflict until you pass force;
  • undo a table-wide reload: a provider-side re-sync is not per-record, and a warning says so when one falls inside the window;
  • guarantee a whole-base rollback is atomic. Each table is its own transaction, so if one fails the run stops there and the response carries partial, stoppedAfter and a warning naming the tables already restored.

A scheduled sync logs a table-wide reload, so for a base whose data is edited in the provider’s own UI most history is reload rows: rollback covers changes made through this server, not everything that ever happened to the data.

Three surfaces, all reading the endpoints above.

Record history + restore — open a row in the grid’s record sheet and scroll to Activity: who changed what, when, with the field-level before/after. Each entry has a restore action — “Undo this change”, “Restore record to just before this”, or “Recreate this record” for a deletion. It always previews first: a dialog shows the exact fields that would change, names anything it will not touch (an attachment has to be re-added by hand), and makes you tick a box before overwriting a value someone has edited since. Enabled by passing rollbackUrl to the ArcGIS adapter alongside activityUrl.

Table history — the base view’s History tab: every change to a table, filterable by operation, actor or record and by date range, with the writer’s ip on hover, and an Export CSV button that writes one row per changed field for whoever has to file it. Sync entries are labelled as such, since per-record edits made in the source during that window are not attributable here.

Retention — a “Keep record history” control on the organization settings page and in the base dialog: Inherit, Forever, 30/90/365 days, or a custom number. The base control shows what it would inherit, so “Inherit” is never a guess.

The Sync Server UI’s grid is wired to this socket: coloured cursor outlines with name flags, a pulse on cells changed elsewhere, attribution toasts (“Reporting bot (API key) updated 3 records”), a “Live / Reconnecting” indicator, conflict handling when someone edits a cell you are editing, and an Activity section in the record sheet. See Editing in the grid.

const refresh = debounce(() => {
map.getSource('locations').setData(geojsonUrl); // MapLibre GeoJSON source
// layer.refresh(); // ArcGIS Maps SDK FeatureLayer
}, 250);
ws.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'change' || msg.type === 'subscribed') refresh();
});

Coalesce bursts (a webhook drain can emit several events in a second) with a short debounce, and treat subscribed after a reconnect as a change — the server may have moved on while you were away.

Client → server:

{ "type": "subscribe", "baseId": "appX", "tableName": "Locations" }
{ "type": "unsubscribe", "baseId": "appX", "tableName": "Locations" }
{ "type": "presence", "baseId": "appX", "tableName": "Locations", "cursor": { "objectId": 42, "field": "Status" }, "editing": false }
{ "type": "ping" }

Server → client:

{ "type": "hello", "connectionId": "…", "self": { "kind": "user", "id": "…" }, "serverTime": 1757… }
{ "type": "subscribed", "baseId": "appX", "tableName": "Locations", "revision": 1757… }
{ "type": "change", "baseId": "appX", "tableName": "Locations", "op": "update",
"records": [{ "key": "recA1", "objectId": 812345, "prevObjectId": 331222 }],
"fields": ["Status"], "diffs": { "recA1": [{ "field": "Status", "from": "Pending", "to": "Approved" }] },
"revision": 1757…, "actor": { "kind": "apiKey", "id": "…", "name": "Reporting bot" },
"at": 1757…, "origin": "server-1:1234:ab12cd", "changeId": "uuid" }
{ "type": "presence", "baseId": "appX", "tableName": "Locations", "members": [ { "connectionId": "…", "actor": {…}, "color": "#1971c2", "cursor": {…}, "editing": true, "updatedAt": 1757… } ] }
{ "type": "pong" }
{ "type": "error", "code": "unauthorized" | "not-found" | "bad-message" | "rate-limited" | "internal", "message": "…", "ref": { "type": "subscribe", "baseId": "…", "tableName": "…" } }

The server pings every 25 s and drops a connection silent for 60 s. Reconnect with backoff, re-send your subscribes, and treat a subscribed.revision different from the one you last saw as “reload”.

  • 401 on upgrade — no credential reached the server. Behind a proxy, make sure the upgrade is forwarded (proxy_http_version 1.1, Upgrade/Connection headers) and that cookies or ?token= survive the hop.
  • Connects, then drops after ~30–100 s — an intermediate proxy is closing idle connections; forward the endpoint with a long proxy_read_timeout (the server keeps it alive with pings).
  • error: unauthorized on subscribe — the credential does not cover that base. Same rules as a /query on the table.
  • Events arrive but the row is gone — the object id changed (content hash). Match by records[].key / prevObjectId.