Sync
Build a sync client: device registration, cursor-based change fetching, applying changes, conflict resolution. This page shows complete worked examples. For the conceptual model, read Sync Model first.
Base path: /api/v1/sync/
Full sync loop
A minimal client:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77 | import asyncio
import httpx
import os
class SyncClient:
def __init__(self, base, token, device_id):
self.client = httpx.AsyncClient(
base_url=base,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
self.device_id = device_id
async def register(self, name: str, platform: str, version: str):
r = await self.client.post(
"/api/v1/sync/devices",
json={
"device_id": self.device_id,
"device_name": name,
"platform": platform,
"client_version": version,
},
)
r.raise_for_status()
return r.json()
async def cursor(self, share_id: str) -> int:
r = await self.client.get(
"/api/v1/sync/status",
params={"share_id": share_id, "device_id": self.device_id},
)
r.raise_for_status()
return r.json()["cursor_position"]
async def pull(self, share_id: str, cursor: int, limit: int = 1000):
r = await self.client.get(
"/api/v1/sync/changes",
params={"share_id": share_id, "cursor": cursor, "limit": limit},
)
r.raise_for_status()
return r.json()
async def advance(self, share_id: str, position: int):
r = await self.client.post(
"/api/v1/sync/cursor",
params={
"share_id": share_id,
"device_id": self.device_id,
"position": position,
},
)
r.raise_for_status()
async def apply(self, share_id: str, changes: list, resolution="LAST_WRITER_WINS"):
r = await self.client.post(
"/api/v1/sync/apply",
json={
"device_id": self.device_id,
"share_id": share_id,
"changes": changes,
"conflict_resolution": resolution,
},
)
r.raise_for_status()
return r.json()
async def sync_share(client: SyncClient, share_id: str):
cursor = await client.cursor(share_id)
while True:
batch = await client.pull(share_id, cursor)
for change in batch["changes"]:
apply_change_locally(change)
cursor = batch["cursor"]
await client.advance(share_id, cursor)
if not batch["has_more"]:
break
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64 | type Change = {
id: string;
resource_type: "file" | "folder" | "share";
resource_id: string;
change_type: "created" | "updated" | "deleted" | "moved" | "renamed";
changed_by: string;
changed_at: string;
old_path: string | null;
new_path: string | null;
version_id: string | null;
metadata: Record<string, unknown>;
};
class SyncClient {
constructor(private base: string, private token: string, private deviceId: string) {}
private async fetch(path: string, init?: RequestInit) {
const resp = await fetch(`${this.base}${path}`, {
...init,
headers: { Authorization: `Bearer ${this.token}`, ...(init?.headers ?? {}) },
});
if (!resp.ok) throw new Error(`${resp.status}: ${await resp.text()}`);
return resp.json();
}
async cursor(shareId: string): Promise<number> {
const params = new URLSearchParams({ share_id: shareId, device_id: this.deviceId });
const body = await this.fetch(`/api/v1/sync/status?${params}`);
return body.data.cursor_position;
}
async pull(shareId: string, cursor: number, limit = 1000) {
const params = new URLSearchParams({
share_id: shareId,
cursor: String(cursor),
limit: String(limit),
});
const body = await this.fetch(`/api/v1/sync/changes?${params}`);
return body.data as { changes: Change[]; cursor: number; has_more: boolean };
}
async advance(shareId: string, position: number) {
const params = new URLSearchParams({
share_id: shareId,
device_id: this.deviceId,
position: String(position),
});
await this.fetch(`/api/v1/sync/cursor?${params}`, { method: "POST" });
}
async apply(shareId: string, changes: unknown[], resolution = "LAST_WRITER_WINS") {
const body = await this.fetch(`/api/v1/sync/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: this.deviceId,
share_id: shareId,
changes,
conflict_resolution: resolution,
}),
});
return body.data;
}
}
|
Device registration
Every client installation needs a unique device_id. Generate a UUID on first run and persist it:
| curl -X POST $SCAIDRIVE_URL/api/v1/sync/devices \
-H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"device_id": "dev_01J3KX",
"device_name": "Alice MacBook Pro",
"platform": "macos",
"client_version": "1.2.0"
}'
|
Platforms recognized: windows, macos, linux, ios, android, web, cli.
Heartbeat periodically so the server marks the device as active:
| curl -X POST $SCAIDRIVE_URL/api/v1/sync/devices/dev_01J3KX/heartbeat \
-H "Authorization: Bearer $SCAIDRIVE_TOKEN"
|
List registered devices:
| curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
$SCAIDRIVE_URL/api/v1/sync/devices
|
Selective sync
Not every client wants every folder. Set per-device, per-share preferences:
| curl -X PUT "$SCAIDRIVE_URL/api/v1/sync/devices/dev_01J3KX/preferences/shr_01J3K" \
-H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sync_enabled": true,
"selected_folders": ["fld_01J4A", "fld_01J4B"]
}'
|
Three modes:
sync_enabled: false — don't sync this share on this device.
selected_folders: [...] — sync only these folders (and descendants).
excluded_folders: [...] — sync everything except these.
Clients interpret preferences locally — they still pull the full change stream, but skip applying changes for paths they're not tracking. The cursor advances through all changes either way.
Bulk update all preferences at once:
| curl -X PUT $SCAIDRIVE_URL/api/v1/sync/devices/dev_01J3KX/preferences \
-H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"preferences": [
{"share_id": "shr_01J3K", "sync_enabled": true, "selected_folders": null, "excluded_folders": null},
{"share_id": "shr_01J4Z", "sync_enabled": false, "selected_folders": null, "excluded_folders": null}
]
}'
|
Applying client-side changes
When a user saves a file locally, the client pushes the change back:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | curl -X POST $SCAIDRIVE_URL/api/v1/sync/apply \
-H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"device_id": "dev_01J3KX",
"share_id": "shr_01J3K",
"conflict_resolution": "LAST_WRITER_WINS",
"changes": [
{
"resource_type": "file",
"resource_id": "fil_01J3M",
"change_type": "updated",
"version_id": "ver_01J4Q",
"base_version_id": "ver_01J3P",
"new_path": "/Engineering/spec.pdf"
}
]
}'
|
For new files, the client must first upload the content (multipart to /api/v1/files or a resumable session to /api/v1/uploads), then apply the created change referencing the new file ID.
Handling conflicts
Strategies in order of how often you'd pick them in a client:
LAST_WRITER_WINS — default for interactive editing. Users see "Alice's version replaced yours" in the history.
KEEP_BOTH — safe default for automated clients. Nothing is ever lost; user reviews duplicates later.
MANUAL — surface the conflict, let the user choose.
SERVER_WINS / CLIENT_WINS — forceful overrides; rare outside admin recovery tooling.
When conflicts are returned, they're also recorded server-side. List open conflicts:
| curl -G $SCAIDRIVE_URL/api/v1/sync/conflicts \
-H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
--data-urlencode "device_id=dev_01J3KX" \
--data-urlencode "include_resolved=false"
|
Resolve:
| curl -X POST "$SCAIDRIVE_URL/api/v1/sync/conflicts/cfl_01J4R/resolve?resolution=KEEP_BOTH" \
-H "Authorization: Bearer $SCAIDRIVE_TOKEN"
|
Downloading file content during sync
The sync download endpoint supports range requests out of the box — handy for resumable downloads:
| curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
-H "Range: bytes=0-4194303" \
"$SCAIDRIVE_URL/api/v1/sync/download/fil_01J3M?version=4"
|
Or with query-param ranges (some clients find this easier than the Range header):
| curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
"$SCAIDRIVE_URL/api/v1/sync/download/fil_01J3M?range_start=0&range_end=4194303"
|
Real-time updates
Polling works; WebSocket reduces latency. Connect to the sync WebSocket for push nudges:
| import asyncio, json, websockets
async def watch(base_ws, token, device_id):
url = f"{base_ws}/api/v1/sync/ws/{device_id}?token={token}"
async with websockets.connect(url) as ws:
await ws.send(json.dumps({"type": "subscribe"}))
async for msg in ws:
frame = json.loads(msg)
if frame["event_type"] == "change":
# trigger a pull for the affected share
await trigger_sync(frame["share_id"])
|
See Real-time WebSocket for the full WebSocket protocol.
Putting it together
A production sync client typically:
- Registers once, stores
device_id locally.
- Opens a WebSocket for low-latency nudges.
- On nudge or a 60-second timer, pulls changes for every subscribed share.
- Downloads content for new/updated files in parallel.
- Watches the local filesystem for changes and pushes them back.
- Heartbeats every 5 minutes.
- Handles reconnection with exponential backoff.
The official desktop client (client-core + client-desktop) does all of this. Source is under client-core/ in the repository if you want to read reference code.
What's next