Activity history and rollback
Every write Sync Server makes — from the grid, an API key, applyEdits, an MCP tool, a webhook,
a scheduled sync — is recorded with who did it, when, and what the record looked like
beforehand. That log is readable over REST, and it’s what rollback restores from.
Reading history
Section titled “Reading history”GET /api/v0/bases/{baseId}/tables/{tableName}/activity?key={recordKey}&limit=50GET /api/v0/bases/{baseId}/tables/{tableName}/activity?objectId={oid}GET /api/v0/bases/{baseId}/tables/{tableName}/activity ← the whole tableGET /api/v0/bases/{baseId}/tables/{tableName}/activity/{id} ← one entry, with its snapshotRead access to the base is enough. Page with limit (max 200) and before — the response
carries nextBefore, or null when you’ve reached the end.
{ "items": [ { "id": 48213, "op": "update", "tableName": "Locations", "recordKey": "recAbC123", "objectId": "1024", "createdAt": "2026-09-09T14:02:11.000Z", "actor": { "kind": "user", "id": "usr_…", "name": "Alex Rivera" }, "fields": ["Status"], "diff": [{ "field": "Status", "from": "Open", "to": "Closed" }], "revision": 317, "changeId": "8f3c…", "hasSnapshot": true, "context": null } ], "nextBefore": 48190}op is add, update, delete or reload; fields and diff say what moved;
hasSnapshot tells you whether this entry is restorable. Fetch /activity/{id} for a single
entry to get the before snapshot — the record’s full field values prior to the change.
Three details worth knowing:
objectIdis resolved against the log when the record no longer exists in the cache, so a deleted record keeps its history and you can still look it up by the id it used to have.actordistinguishes a signed-in user (user), an API key (apiKey), a provider webhook (webhook), a sync (sync, withtrigger: scheduled | manual | on-demand) and an MCP call (mcp, which carries the humanviawho was driving the model).contextcarries the writer’s IP and user agent, and the provenance of a rollback. It is visible here, to callers who can read the base’s history — but it is stripped from every realtime event before delivery, so it never leaks over a socket.
Retention
Section titled “Retention”GET /api/v0/bases/{baseId}/activity/retention→ { "baseDays": null, "orgDays": 365, "envDays": null, "effectiveDays": 365 }The window in force is resolved base → org → environment, and unset everywhere means keep forever — which is the default, because rollback and security audits both want the whole trail. Expired entries are purged by a periodic job.
A deployment can make a write fail when its change can’t be logged. Off by default, a logging blip costs an audit entry rather than a write; on, the caller gets a 500 saying the change was applied but not recorded. Note the ordering — the data write has already committed by then, so strict mode reports an unrecorded change, it can’t prevent one.
Rollback
Section titled “Rollback”POST /api/v0/bases/{baseId}/tables/{tableName}/rollbackPOST /api/v0/bases/{baseId}/rollbackBoth need write access to the base.
| Field | Meaning |
|---|---|
at |
ISO timestamp — restore the state as of then |
changeId |
Undo that one change (per-table endpoint only) |
recordKey |
Narrow either of the above to a single record |
dryRun |
Return the plan and write nothing |
expectedRevision |
409 if the table changed since you read it (expectedRevisions per table on the base endpoint) |
force |
Apply despite the guardrails |
Exactly one of at or changeId.
# What would restoring this record to yesterday do?curl -X POST '…/tables/Locations/rollback' \ -H 'Content-Type: application/json' -H 'Authorization: Bearer …' \ -d '{ "at": "2026-09-08T00:00:00Z", "recordKey": "recAbC123", "dryRun": true }'A rollback is written through the ordinary edit pipeline, so it’s permission-checked, respects
read-only fields, is broadcast on the realtime feed, and is itself logged — with
context.rollback naming what it rolled back to. You can undo an undo.
How wide a restore can go depends on the provider
Section titled “How wide a restore can go depends on the provider”This is the one rule that surprises people:
| Scope | Airtable / NocoDB | Postgres / PostGIS |
|---|---|---|
One record (recordKey) |
✅ | ✅ |
| A whole table | dryRun only → 501 |
✅ |
| A whole base | dryRun only → 501 |
✅ |
A record-scoped restore is a single provider call — applied or not. Anything wider needs a
transaction to be undoable if it fails half way, and only the SQL writers have one. Airtable and
NocoDB can always preview the full plan with dryRun; they just can’t write it back
atomically. This is why the grid’s per-row Restore buttons work everywhere.
On SQL providers a table-wide restore is atomic, and a deleted row can be recreated with its original primary key.
What never comes back
Section titled “What never comes back”Attachment cells and link (relationship) cells are never restored, on any provider —
attachment values carry rewritten proxy URLs, and link values are two-sided. They’re reported
in plan.unrestorable so you can see what was skipped.
Base-wide rollback is not all-or-nothing
Section titled “Base-wide rollback is not all-or-nothing”Each table is restored in its own transaction, so a base-wide rollback that fails at table 7 of 12 leaves the first six restored. It stops there rather than spreading a half-applied state further, and the response says exactly where it got to:
{ "stoppedAfter": "Regions", "partial": true, "warning": "Rollback stopped at \"Regions\". Tables restored before it stay restored: …"}Fix the cause and re-run with the same at.