Conventions
The sync API lives under /v1 and speaks JSON unless noted. Every endpoint except POST /v1/login and GET /healthz requires a bearer token:
Authorization: Bearer <token>
All data is scoped to the authenticated user. Anything that isn't yours reads as 404 — the API does not reveal whether something exists for another user. Errors come as {"error": "message"} with a matching status code.
Two ideas recur throughout. A file has a stable id that never changes across edits and renames, and a version — the id of its newest manifest — that increases with every write, globally across all of a user's files, which is what lets a version double as a change-feed cursor. Writes carry a base: the version the client last synced. If the file has moved past that base, the server answers 412 and the client keeps both copies; see Conflicts and versioning.
Authentication
POST /v1/login
- Body:
{"username": "...", "password": "..."} - Returns
{"token": "..."}. Store it; there is no expiry yet (token lifecycle is roadmap). 401on bad credentials (the response does not distinguish unknown user from wrong password).
Chunks
File content is transferred as content-addressed chunks: the client cuts each file into pieces, hashes each with SHA-256, and only uploads pieces the server lacks.
POST /v1/chunks/missing
- Body:
{"hashes": ["sha256hex", ...]}— up to 10,000 per request; batch larger sets. - Returns
{"missing": [...]}— the subset the server does not hold. Upload those, skip the rest.
PUT /v1/chunks/{hash}
- Body: the raw chunk bytes (up to 16 MiB).
- The server recomputes the hash and rejects a mismatch with
400— a client-claimed hash is never trusted.201on success; re-uploading an existing chunk is a harmless no-op.
GET /v1/chunks/{hash}
- Returns the chunk bytes.
404unless one of your file versions references the chunk — storage is deduplicated globally, but reads are authorized per user.
Files and manifests
A manifest is one saved version of a file: an ordered list of chunk hashes plus metadata. Recording a manifest is what creates a new version.
PUT /v1/files/{path}
- Body:
{"device": "name", "deleted": false, "chunks": ["hash", ...], "base": 0}. Every referenced chunk must already be uploaded. A delete is the same call with"deleted": trueand no chunks — versions are kept, so a delete is reversible history, not erasure. baseis the version you edited from (0for a new file). Omitting it skips the concurrency check entirely — the sync client always sends it.- Returns
201with the stored manifest (see below).409with{"missing": [...]}if chunks are absent — upload them and retry.412ifbaseis stale: reconcile and keep both.
GET /v1/files/{path}
- Returns the file's newest manifest:
{"path", "file_id", "version", "size", "deleted", "device", "updated_at", "chunks": [{"hash", "size"}, ...]}. Download the chunks in order and concatenate to reassemble the file.404if the path does not exist (a renamed-away path stops resolving; a deleted file still resolves, with"deleted": true).
GET /v1/by-id/{id}
- The same manifest, addressed by the file's stable id instead of its path — how an id-keyed client resolves an item after renames.
404for an unknown id or another user's.
GET /v1/files
- Returns
{"files": [{"id", "path", "size", "deleted", "version", "updated_at"}, ...]}— every file at its latest version, deleted ones included with their tombstone flag.
Change feed
GET /v1/changes?since=<cursor>&limit=<n>
- The incremental form of the listing: only files whose latest version advanced past the cursor, oldest first, capped at
limit(default 1000, max 10,000). - Returns
{"changes": [...], "cursor": <next>}. Entries are the listing shape plus"renamed_from"when that newest version was a rename. Pass the returned cursor back assinceto page; an empty page means you are caught up.since=0is a full enumeration equal toGET /v1/files. - A cursor is never invalidated — replaying from
0is always safe and converges to the same state.
Rename
POST /v1/rename
- Body:
{"id": <stable id>, "to": "new/path", "base": <version>, "device": "name"}. - Moves the file in one operation, keeping its id and history; content is not re-uploaded. Returns
201with the new manifest. 404unknown or deleted file ·409destination already occupied ·412stalebase(the file changed since — reconcile first).
Events
GET /v1/events
- A Server-Sent Events stream. Each time one of your files gains a version, one event arrives:
data: {"path", "version", "device", "deleted"}. Comment lines (: ping) keep the connection alive through proxies. - Events are hints to re-sync, not a complete log — a client that misses one catches up from its change-feed cursor on the next pass.
Health
GET /healthz
- Unauthenticated, outside
/v1.200with bodyokwhen the database responds,503otherwise — made for container health checks and probes.
Status codes at a glance
200/201— success (created, for uploads and writes).400— malformed request: bad JSON, invalid path, hash mismatch, oversized body.401— missing or invalid token (or bad login).404— not found for you: absent, or another user's.409— a precondition about content: manifest chunks not yet uploaded, or a rename destination already taken.412— stalebase: the file advanced past the version you edited from; reconcile and keep both.413— chunk over the 16 MiB limit.