Skip to content

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-readable code.
  • Not yours means 404. A collection, object, or blob that belongs to another user returns 404, never 403.
  • Numbers as strings. In object and collection rows, version, change_seq, size_bytes, and current_version are JSON strings. The server-computed values collectionVersion, currentVersion, and nextCursor are JSON numbers. Coerce before comparing. You send version and size_bytes as numbers.
  • No CORS headers. The server sends none, so a browser client has to be served from the same origin.

Endpoint summary

Method and pathPurpose
GET /Capability document (no auth)
GET /healthHealth check (no auth)
POST /api/auth/password/loginSign in with the password (no auth)
GET /api/authCurrent user
POST /api/auth/logoutEnd this session
POST /api/collectionsCreate or return your vault
GET /api/collectionsList collections, oldest first
GET /api/collections/:idOne collection
DELETE /api/collections/:idDelete a collection and everything in it
GET /api/collections/:id/keyRead vault key material
PUT /api/collections/:id/keyClaim or replace vault key material
GET /api/collections/:id/objects?sinceVersion=NPull changes
GET /api/collections/:id/objects/:oidOne object
POST /api/collections/:id/objectsCreate from a staged blob
PUT /api/collections/:id/objects/:oidUpdate from a staged blob
DELETE /api/collections/:id/objects/:oidSoft delete
POST /api/collections/:id/blob-objectsCreate, blob in the body
PUT /api/collections/:id/blob-objects/:oid?version=NUpdate, blob in the body
POST /api/collections/:id/blob-objects/batchMany creates and updates in one request
GET /api/collections/:id/create-mutations/:mutationIdRecover a create whose response was lost
POST /api/blobsStage a blob
GET /api/blobs/:userId/:blobIdDownload a blob
DELETE /api/blobs/:userId/:blobIdDelete a staged blob
POST /api/blobs/batchDownload many blobs
GET /api/sync/eventsServer-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/login
Content-Type: application/json
{"password": "your sync password"}
{
"user": { "id": "<uuid>", "email": "[email protected]", "name": "FUTO Notes" },
"token": "<64 hex characters>"
}
  • 400 if password is missing, 401 if it is wrong, 429 with Retry-After after 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 an httpOnly session cookie, 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 401 with {"error": "session expired or invalid", "code": "invalid_session"} and a WWW-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/collections

Returns 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/key
Content-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 returns 200 with 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 stored key_updated_at exactly. A stale token returns 409 {"error": "key conflict", "currentKey": {...}}. A token when no key exists returns 400. Treat the token as opaque.
  • key_kdf must 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_version on the collection goes up by one on every create, update, or delete. It is your pull cursor.
  • change_seq is the collection’s current_version at the object’s last change.
  • version counts changes to that object. A new object is 1, and every update or delete must name the next number.

Pull

GET /api/collections/:id/objects?sinceVersion=42

Returns {"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-objects
Content-Type: application/octet-stream
Mutation-Id: <your id>
<ciphertext>
PUT /api/collections/:id/blob-objects/:oid?version=4
Content-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=4

Sets 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/:blobId returns the raw bytes, or 404.
  • DELETE /api/blobs/:userId/:blobId returns 204 for a staged or missing blob, and 409 {"error": "blob is in use"} for one attached to an object.
  • POST /api/blobs/batch with {"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/events
Accept: text/event-stream
Authorization: Bearer <token>
EventDataMeaning
readyemptySent on every connect. Pull from your cursor now.
change{"collectionId": "...", "currentVersion": N}Something changed. Pull that collection.
pingemptyHeartbeat 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

LimitValue
One blob or single-object upload100 MiB
Batch upload body32 MiB, 200 frames
Batch download200 keys, 32 MiB response, 64 KiB request
Pull page size with limit1000
Sign-in attempts10 per 60 seconds per client address
Session lifetime7 days from sign-in
Staged blob lifetime24 hours
Previous blob retention365 days

These are fixed in the server code. They are not configuration settings.

Next steps