FUTO Notes sync server HTTP API reference
The FUTO Notes sync server is a small JSON-and-bytes HTTP API. It stores opaque encrypted blobs with version metadata, and your client does all encryption and all conflict resolution. This page is for anyone writing their own client or tooling against a self-hosted server. It describes the Go server in futo-notes-server; the repo’s docs/API.md covers the same ground in more depth.
To read or write notes that the FUTO Notes apps can open, your client also needs the app’s key and blob formats. Those are in how the encryption works.
Conventions
- Base URL. Paths below are relative to your server, for example
http://192.168.1.10:3005. - Bodies are JSON, except blob uploads, blob downloads, and the batch and single-round-trip object routes, which use
application/octet-stream. - Errors are
{"error": "..."}with a status code, sometimes with a machine-readablecode. - Not yours means 404. A collection, object, or blob that belongs to another user returns
404, never403. - Numbers as strings. In object and collection rows,
version,change_seq,size_bytes, andcurrent_versionare JSON strings. The server-computed valuescollectionVersion,currentVersion, andnextCursorare JSON numbers. Coerce before comparing. You sendversionandsize_bytesas numbers. - No CORS headers. The server sends none, so a browser client has to be served from the same origin.
Endpoint summary
| Method and path | Purpose |
|---|---|
GET / | Capability document (no auth) |
GET /health | Health check (no auth) |
POST /api/auth/password/login | Sign in with the password (no auth) |
GET /api/auth | Current user |
POST /api/auth/logout | End this session |
POST /api/collections | Create or return your vault |
GET /api/collections | List collections, oldest first |
GET /api/collections/:id | One collection |
DELETE /api/collections/:id | Delete a collection and everything in it |
GET /api/collections/:id/key | Read vault key material |
PUT /api/collections/:id/key | Claim or replace vault key material |
GET /api/collections/:id/objects?sinceVersion=N | Pull changes |
GET /api/collections/:id/objects/:oid | One object |
POST /api/collections/:id/objects | Create from a staged blob |
PUT /api/collections/:id/objects/:oid | Update from a staged blob |
DELETE /api/collections/:id/objects/:oid | Soft delete |
POST /api/collections/:id/blob-objects | Create, blob in the body |
PUT /api/collections/:id/blob-objects/:oid?version=N | Update, blob in the body |
POST /api/collections/:id/blob-objects/batch | Many creates and updates in one request |
GET /api/collections/:id/create-mutations/:mutationId | Recover a create whose response was lost |
POST /api/blobs | Stage a blob |
GET /api/blobs/:userId/:blobId | Download a blob |
DELETE /api/blobs/:userId/:blobId | Delete a staged blob |
POST /api/blobs/batch | Download many blobs |
GET /api/sync/events | Server-Sent Events stream |
Capability and health
GET /{ "name": "futo-notes", "version": "<server version>", "auth_mode": "password", "signup": "closed", "billing": false, "mutation_ids": { "supported": true, "required": false, "retention_days": 30, "successful_create_outcomes": "durable" }}auth_mode is password on a normal install. dev is for development only: it accepts POST /api/auth/dev/login with {"email": "...", "name": "..."} and no password, so never expose a server running in that mode.
GET /health returns 200 {"status": "ok", "db": "connected"}, or 503 {"status": "degraded", "db": "unreachable"}.
Sign-in and sessions
POST /api/auth/password/loginContent-Type: application/json
{"password": "your sync password"}{ "token": "<64 hex characters>"}400ifpasswordis missing,401if it is wrong,429withRetry-Afterafter 10 attempts in 60 seconds from one client address.- Send the token as
Authorization: Bearer <token>on every other/api/*request. The response also sets anhttpOnlysessioncookie, which the server checks before the header. - Sessions expire seven days after sign-in, and use does not extend them. An expired or unknown token returns
401with{"error": "session expired or invalid", "code": "invalid_session"}and aWWW-Authenticate: Bearer ... error="invalid_token"header. Sign in again and retry; your sync cursor is still valid. - A request with no credentials at all returns
401 {"error": "unauthorized"}.
There is one user. The password you send is the same one the FUTO Notes apps use to unwrap the vault key, so treat the connection as sensitive and use TLS off your own network. See what the server can see.
GET /api/auth returns {"user": {...}}. POST /api/auth/logout deletes the session and returns 204.
Collections
A collection is a vault: it holds the objects, the change cursor, and the vault key material.
POST /api/collectionsReturns 201 {"collection": {...}} the first time and 200 with the same collection on every later call, so two devices setting up at once end up in one vault. Read the ID from the response.
{ "id": "<uuid>", "user_id": "<uuid>", "current_version": "0", "created_at": "2026-09-01T12:00:00Z" }GET /api/collections returns {"collections": [...]}, oldest first. Older accounts can hold more than one; sync against the oldest, as the apps do. DELETE /api/collections/:id returns 204 and removes the collection, its objects, and its key material; its blobs are removed later by background maintenance.
Vault key material
GET /api/collections/:id/key{ "key": { "key_salt": "<string>", "key_kdf": { "kdf": "pbkdf2-sha256", "iterations": 100000, "hash": "SHA-256" }, "encrypted_vault_key": "<string>", "key_updated_at": "2026-09-01T12:00:00.123Z" }}{"key": null} means no key has been set yet. All three values are opaque to the server; the example shows what the FUTO Notes apps store, as described in the key hierarchy.
PUT /api/collections/:id/keyContent-Type: application/json
{ "key_salt": "<non-empty string>", "key_kdf": { }, "encrypted_vault_key": "<non-empty string>", "previous_key_updated_at": "<key_updated_at you last read>"}- Claim (no
previous_key_updated_at): stores the material if none exists. If a key already exists, the server returns200with the existing material unchanged. Adopt what comes back; a vault has exactly one key. - Replace (with
previous_key_updated_at): replaces the material only if the token matches the storedkey_updated_atexactly. A stale token returns409 {"error": "key conflict", "currentKey": {...}}. A token when no key exists returns400. Treat the token as opaque. key_kdfmust be a JSON object. A replacement does not send a live-update event.
Replacing the material is how a tool could re-wrap the vault key under a new password. The FUTO Notes apps never do this; see changing the sync password.
Objects and the sync model
An object is a metadata row pointing at one blob:
{ "id": "<uuid>", "collection_id": "<uuid>", "version": "3", "change_seq": "42", "deleted": false, "blob_key": "<user-id>/<blob-id>", "size_bytes": "1024", "created_at": "2026-09-01T12:00:00Z", "updated_at": "2026-09-01T12:05:00Z"}current_versionon the collection goes up by one on every create, update, or delete. It is your pull cursor.change_seqis the collection’scurrent_versionat the object’s last change.versioncounts changes to that object. A new object is1, and every update or delete must name the next number.
Pull
GET /api/collections/:id/objects?sinceVersion=42Returns {"objects": [...]}: every object with change_seq greater than the cursor, in ascending order, including deleted ones. Apply them and advance your cursor to the highest change_seq. Start at 0. Add &limit=N (capped at 1000) to page; the response then adds hasMore and nextCursor, which you pass as the next sinceVersion.
Write with a blob in the body
POST /api/collections/:id/blob-objectsContent-Type: application/octet-streamMutation-Id: <your id>
<ciphertext>PUT /api/collections/:id/blob-objects/:oid?version=4Content-Type: application/octet-stream
<ciphertext>Success returns 201 or 200 with {"object": {...}, "collectionVersion": 7}; a create also carries "replayed": true|false. A stale version returns:
{ "error": "version conflict", "currentVersion": 4, "currentBlobKey": "<user-id>/<blob-id>" }with 409. Download the current blob, merge, and retry at currentVersion + 1. Empty bodies return 400, and bodies over 100 MiB return 413.
Write in two steps
POST /api/blobs with the raw bytes returns 201 {"key": "<user-id>/<blob-id>"}, staged for 24 hours. Then POST /api/collections/:id/objects with {"blob_key": "...", "size_bytes": 1024}, or PUT /api/collections/:id/objects/:oid with {"version": 4, "blob_key": "...", "size_bytes": 1024}. A blob key that is already used or has expired returns 409 {"error": "blob is not staged"}; upload again and retry under a new Mutation ID.
Delete
DELETE /api/collections/:id/objects/:oid?version=4Sets deleted: true, bumps the version, and returns {"object": {"id", "version", "change_seq", "deleted"}, "collectionVersion": N}. With ?version, a delete loses to a newer edit with 409; without it, the delete is unconditional. Deleting an object that is already deleted returns the existing marker and changes nothing.
Retry safety with Mutation IDs
Send a Mutation-Id header on each create, update, and delete, and reuse it only to retry the same change. The server records the first outcome and returns it again on a retry, ignoring the retried body. Use 1 to 128 characters from letters, digits, ., _, ~, and -. Reusing an ID for a different change returns 409 {"error": "Mutation-Id reused for different intent"}.
If a create’s response was lost, GET /api/collections/:id/create-mutations/:mutationId returns the original create with "replayed": true, 409 {"error": "mutation still in progress"} while it is still committing, or 404 if it never committed. Successful creates are remembered for the life of the collection; other outcomes for 30 days.
Batch writes
POST /api/collections/:id/blob-objects/batch takes up to 200 frames, concatenated, integers big-endian:
[u8 operation][u16 identifier length][identifier, UTF-8][u32 version][u32 blob length][blob]Operation 0 is a create: the identifier is a Mutation ID and the version is 0. Operation 1 is an update: the identifier is the object ID and the version is the next version. The response is JSON, one result per frame in order:
{ "results": [ { "status": "created", "object": { }, "collectionVersion": 1 } ] }Statuses are created, replayed, updated, conflict (with currentVersion and currentBlobKey), not_found, too_large, and error. A malformed request, an empty batch, or more than 200 frames returns 400; a body over 32 MiB returns 413.
Blobs
GET /api/blobs/:userId/:blobIdreturns the raw bytes, or404.DELETE /api/blobs/:userId/:blobIdreturns204for a staged or missing blob, and409 {"error": "blob is in use"}for one attached to an object.POST /api/blobs/batchwith{"keys": [...]}(1 to 200 keys, each up to 128 characters) returns binary frames in request order:
[u16 key length][key, UTF-8][u8 status][u32 blob length][blob]Status 0 is found, with the bytes following. 1 is missing or not yours. 2 means the blob was left out to keep the response under 32 MiB; request it again in a new batch. The first available blob is always included, so a batch always makes progress.
Blob lifetimes are fixed. A staged blob that no object claims is removed after 24 hours. When an object is updated or deleted, its previous blob is kept for 365 days so clients can use it as a merge ancestor. Deleting a collection frees all of its blobs.
Live updates (SSE)
GET /api/sync/eventsAccept: text/event-streamAuthorization: Bearer <token>| Event | Data | Meaning |
|---|---|---|
ready | empty | Sent on every connect. Pull from your cursor now. |
change | {"collectionId": "...", "currentVersion": N} | Something changed. Pull that collection. |
ping | empty | Heartbeat every 25 seconds. |
Events carry no content, and events fired while you are disconnected are not replayed. Pull on every ready, and keep a slow safety poll running, so a missed event is caught within one interval.
Limits
| Limit | Value |
|---|---|
| One blob or single-object upload | 100 MiB |
| Batch upload body | 32 MiB, 200 frames |
| Batch download | 200 keys, 32 MiB response, 64 KiB request |
Pull page size with limit | 1000 |
| Sign-in attempts | 10 per 60 seconds per client address |
| Session lifetime | 7 days from sign-in |
| Staged blob lifetime | 24 hours |
| Previous blob retention | 365 days |
These are fixed in the server code. They are not configuration settings.
Next steps
- How the encryption works: the key and blob formats a client needs to read notes.
- What the server can see: what this API exposes to whoever runs the server.
- Self-host FUTO Notes sync: run a server to test a client against.