Incremental sync
Ask for what changed, not for everything.
The loop
GET /api/orgs/{orgId}/spaces/{spaceId}/items?updated_since=2026-08-18T03:00:00Z&include_deleted=true&limit=200{
"items": [
{ "id": "item_912", "updatedAt": "2026-08-18T04:12:09Z", "contentHash": "9f86d081884c7d659a2f" },
{ "id": "item_733", "updatedAt": "2026-08-18T04:41:55Z", "deleted": true }
],
"page": { "has_more": false, "next_cursor": null, "limit": 200, "total": 2 },
"sync": { "checkpoint": "2026-08-18T04:41:55Z" }
}Store sync.checkpoint verbatim and pass it back as updated_since next time. The server defines the watermark so you never have to reason about clock skew or about the window between a write landing and becoming visible.
Deletions
include_deleted=true returns a tombstone — { id, updatedAt, deleted: true } — for anything removed in the last 30 days. Without tombstones you cannot tell "this was deleted" from "this was not in my window", and you would have to do a full list anyway. If you have been away longer than 30 days, reconcile with a full list.
Content, without a second request
include=sections folds the markdown per named slot into every row; include=body folds in the whole piece as markdown. fields=id,title,updatedAt trims what comes back — id always survives.
A 400-page space costs 4 requests, not 401.
Diffing without transferring
Every row carries contentHash, a hash of the content itself rather than of a row version, so a save that changed nothing does not invalidate your cache. Compare hashes to decide what to fetch. A single item read also carries an ETag: send it back as If-None-Match and an unchanged piece answers 304 with no body.