Sync API
Cursor-based delta sync for efficiently pulling changes since your last sync. Designed for third-party apps that need to keep a local index or cache in sync with a user's Paradigm graph.
Base URL
https://api.ofself.ai/api/v1
Authentication
All requests require:
- API Key:
X-API-Key: <your-key>+X-User-ID: <user-id>(third-party apps) - JWT:
Authorization: Bearer <token>(first-party only)
Endpoints
GET /sync
GET Delta sync — returns changes (creates, updates, deletes) since the given cursor, filtered by the app's exposure profile.
curl -X GET "https://api.ofself.ai/api/v1/sync?cursor=abc123&limit=100&include_tags=true" \
-H "X-API-Key: your-api-key" \
-H "X-User-ID: user-123"
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | - | Opaque cursor from a previous sync response. Omit for initial full sync. |
scope | string | all | Filter scope: all, tag:<tag_uuid>, or schema:<schema_uuid> |
limit | integer | 100 | Max changes per page (max 1000) |
ids_only | boolean | false | If true, return only node IDs instead of full node data |
include_tags | boolean | false | If true, include tags in node data |
Response: 200 OK
{
"changes": [
{
"action": "create",
"timestamp": "2026-01-15T10:30:00Z",
"version": 1,
"node": {
"id": "node-uuid",
"title": "First day teaching",
"node_type": "EXPERIENCE",
"meaning_level": "IDENTITY",
"metadata": { "properties": { "significance": "Defining career moment" } },
"created_at": "2026-01-15T10:30:00Z"
}
},
{
"action": "update",
"timestamp": "2026-01-16T14:00:00Z",
"version": 2,
"changed_fields": ["title", "value"],
"node": {
"id": "node-uuid",
"title": "Updated title",
"node_type": "EXPERIENCE"
}
},
{
"action": "delete",
"timestamp": "2026-01-17T09:00:00Z",
"version": 3,
"node_id": "node-uuid",
"deleted_title": "Old title"
}
],
"cursor": "opaque_cursor_string",
"has_more": false,
"stats": {
"created": 1,
"updated": 1,
"deleted": 1,
"total": 3
}
}
- Omit
cursorfor the first sync — you'll receive all historical changes. - Pass the
cursorfrom each response in the next request to resume from where you left off. - The server automatically stores a
SyncCursorper(app, user, scope)for third-party apps. - If
has_moreistrue, immediately request again with the new cursor to get the next page.
Use scoped sync to track only a subset of a user's graph:
scope=tag:<uuid>— only nodes tagged with a specific tagscope=schema:<uuid>— only nodes using a specific schema
Each scope maintains its own cursor, so you can sync different subsets at different rates.
GET /sync/status
GET Sync health and statistics. Returns pending change count, last sync time, and per-scope breakdown.
curl -X GET "https://api.ofself.ai/api/v1/sync/status" \
-H "X-API-Key: your-api-key" \
-H "X-User-ID: user-123"
Response: 200 OK
{
"total_versions": 150,
"latest_change": "2026-01-17T09:00:00Z",
"last_sync_at": "2026-01-16T14:00:00Z",
"pending_changes": 5,
"scopes": {
"all": {
"last_sync_at": "2026-01-16T14:00:00Z",
"last_version_seen": 145,
"pending_changes": 5
},
"tag:abc-123-def": {
"last_sync_at": "2026-01-15T10:30:00Z",
"last_version_seen": 100,
"pending_changes": 12
}
}
}
For JWT (direct user) access, pending_changes is 0 and scopes is omitted since cursor tracking is for third-party apps.
Sync Workflow
Initial Full Sync
import requests
API_KEY = "ofs_tp_xxxx.yyyy"
USER_ID = "user-123"
headers = {"X-API-Key": API_KEY, "X-User-ID": USER_ID}
cursor = None
all_changes = []
while True:
params = {"limit": 500, "include_tags": "true"}
if cursor:
params["cursor"] = cursor
resp = requests.get(
"https://api.ofself.ai/api/v1/sync",
headers=headers, params=params
).json()
all_changes.extend(resp["changes"])
cursor = resp["cursor"]
if not resp["has_more"]:
break
# Store cursor for next sync
save_cursor(cursor)
print(f"Synced {len(all_changes)} changes")
Incremental Sync
cursor = load_saved_cursor()
resp = requests.get(
"https://api.ofself.ai/api/v1/sync",
headers=headers,
params={"cursor": cursor, "include_tags": "true"}
).json()
for change in resp["changes"]:
if change["action"] == "create":
index_node(change["node"])
elif change["action"] == "update":
update_indexed_node(change["node"])
elif change["action"] == "delete":
remove_from_index(change["node_id"])
save_cursor(resp["cursor"])
Lightweight ID-only Sync
For bandwidth-sensitive scenarios, fetch only node IDs first, then selectively fetch full data:
resp = requests.get(
"https://api.ofself.ai/api/v1/sync",
headers=headers,
params={"cursor": cursor, "ids_only": "true"}
).json()
# Only fetch full data for nodes you actually need
for change in resp["changes"]:
if change["action"] != "delete" and is_relevant(change["node_id"]):
node = requests.get(
f"https://api.ofself.ai/api/v1/nodes/{change['node_id']}",
headers=headers
).json()
process_node(node)
Error Responses
400 Bad Request — Invalid cursor or scope
{
"error": {
"code": "INVALID_CURSOR",
"message": "The provided cursor is invalid or corrupted. Omit cursor for a full sync."
}
}
{
"error": {
"code": "INVALID_SCOPE",
"message": "Scope must be 'all', 'tag:<uuid>', or 'schema:<uuid>'"
}
}
403 Forbidden — App lacks read permission
{
"error": {
"code": "FORBIDDEN",
"message": "App does not have read permission for nodes"
}
}
Next Steps
- Nodes API — Create and manage nodes
- Webhooks — Real-time push notifications (alternative to polling sync)
- Exposure Profiles — Control what data apps can access