---
title: Sync
path: api-guides/sync
status: published
---

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](/docs/scaidrive/core-concepts/sync-model) first.

**Base path:** `/api/v1/sync/`

## Full sync loop

A minimal client:

```python
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
```

```typescript
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:

```bash
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:

```bash
curl -X POST $SCAIDRIVE_URL/api/v1/sync/devices/dev_01J3KX/heartbeat \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN"
```

List registered devices:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

1. **`LAST_WRITER_WINS`** — default for interactive editing. Users see "Alice's version replaced yours" in the history.
2. **`KEEP_BOTH`** — safe default for automated clients. Nothing is ever lost; user reviews duplicates later.
3. **`MANUAL`** — surface the conflict, let the user choose.
4. **`SERVER_WINS` / `CLIENT_WINS`** — forceful overrides; rare outside admin recovery tooling.

When conflicts are returned, they're also recorded server-side. List open conflicts:

```bash
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:

```bash
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:

```bash
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):

```bash
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:

```python
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](/docs/scaidrive/advanced/realtime-websocket) for the full WebSocket protocol.

## Putting it together

A production sync client typically:

1. Registers once, stores `device_id` locally.
2. Opens a WebSocket for low-latency nudges.
3. On nudge or a 60-second timer, pulls changes for every subscribed share.
4. Downloads content for new/updated files in parallel.
5. Watches the local filesystem for changes and pushes them back.
6. Heartbeats every 5 minutes.
7. 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

- [Sync Model](/docs/scaidrive/core-concepts/sync-model) — the cursor protocol in detail.
- [Real-time WebSocket](/docs/scaidrive/advanced/realtime-websocket) — push nudges.
- [Sync Reference](/docs/scaidrive/reference/sync) — all sync endpoints.