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 and send it on every subsequent call. 401on bad credentials (the response does not distinguish unknown user from wrong password).429when the client address has run through its sign-in allowance;Retry-Aftersays how long to wait. This is the only rate-limited part of the API.
A token expires on idleness — by default after 180 days without a request, configurable server-side — so a client that keeps syncing never needs to log in again, while a token that goes quiet eventually reads as 401. Treat a 401 on a previously working token as "log in again", not as an error to retry.
POST /v1/logout
- Authenticated like any other call; revokes the token it was made with and nothing else. Other devices on the same account are unaffected.
- Returns
204with no body. The token is invalid from that point on.
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 your account lacks. Every chunk operation is scoped to the authenticated user — chunks are stored per user, and another account's identical chunk is invisible to yours.
POST /v1/chunks/missing
- Body:
{"hashes": ["sha256hex", ...]}— up to 10,000 per request; batch larger sets. - Returns
{"missing": [...]}— the subset your account does not hold. Upload those, skip the rest. A chunk only another user has uploaded still counts as missing.
PUT /v1/chunks/{hash}
- Body: the raw chunk bytes, up to 16 MiB — an operator can raise or lower that with the server's
-max-chunk-sizeflag. Over the limit is413. - The server recomputes the hash and rejects a mismatch with
400— a client-claimed hash is never trusted.201on success; re-uploading a chunk you already have is a harmless no-op.
GET /v1/chunks/{hash}
- Returns the chunk bytes.
404unless your account holds the chunk (uploading it is what grants access) — reads never cross accounts.
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).
POST /v1/rename-folder
- Body:
{"from": "old/folder", "to": "new/folder", "device": "name"}. - A folder is a path prefix, so this moves every live file under it to the same relative path under
to— in one transaction, so the whole move either happens or doesn't. Each moved file keeps its stable id and reportsrenamed_fromin the change feed. Returns200with{"renamed": <count>}. - There is no
base: the transaction itself serializes against concurrent writes — an edit lands wholly before the move (and is carried along) or wholly after (and gets a412at the old path like any write to a vanished head). 400nested or equal prefixes ·404no live files underfrom·409any destination path already occupied (nothing moves).
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.429— too many sign-in attempts from this address; wait forRetry-Afterseconds. OnlyPOST /v1/loginanswers this.