Skip to content

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.

GET /api/v0/bases/{baseId}/tables/{tableName}/activity?key={recordKey}&limit=50
GET /api/v0/bases/{baseId}/tables/{tableName}/activity?objectId={oid}
GET /api/v0/bases/{baseId}/tables/{tableName}/activity ← the whole table
GET /api/v0/bases/{baseId}/tables/{tableName}/activity/{id} ← one entry, with its snapshot

Read 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:

  • objectId is 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.
  • actor distinguishes a signed-in user (user), an API key (apiKey), a provider webhook (webhook), a sync (sync, with trigger: scheduled | manual | on-demand) and an MCP call (mcp, which carries the human via who was driving the model).
  • context carries 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.
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.

POST /api/v0/bases/{baseId}/tables/{tableName}/rollback
POST /api/v0/bases/{baseId}/rollback

Both 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.

Terminal window
# 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.

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.

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.