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.
1. Connect
Section titled “1. Connect”wss://{SERVER}/api/v0/realtimeAuthenticate 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 logsconst 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.
2. Subscribe to a table
Section titled “2. Subscribe to a table”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.
Object ids that move
Section titled “Object ids that move”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.
3. Actors
Section titled “3. Actors”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.
4. Presence
Section titled “4. Presence”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.
5. Activity log
Section titled “5. Activity log”GET {SERVER}/api/v0/bases/{baseId}/tables/{tableName}/activity?objectId=42&limit=50GET {SERVER}/api/v0/bases/{baseId}/tables/{tableName}/activity?key=recXXXXGET {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.
Losing an audit entry
Section titled “Losing an audit entry”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.
Retention
Section titled “Retention”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).
6. Rollback
Section titled “6. Rollback”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
conflictuntil you passforce; - 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,stoppedAfterand awarningnaming 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.
7. In the UI
Section titled “7. In the UI”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.
8. In the grid
Section titled “8. In the grid”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.
9. Refreshing a map layer
Section titled “9. Refreshing a map layer”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.
The protocol
Section titled “The protocol”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”.
Troubleshooting
Section titled “Troubleshooting”401on upgrade — no credential reached the server. Behind a proxy, make sure the upgrade is forwarded (proxy_http_version 1.1,Upgrade/Connectionheaders) 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: unauthorizedon subscribe — the credential does not cover that base. Same rules as a/queryon the table.- Events arrive but the row is gone — the object id changed (content hash). Match by
records[].key/prevObjectId.