Reference
Sync API
This page is for anyone building or auditing a sync client. The server is deliberately simple: it stores opaque blobs under opaque keys, with versions, quotas and per-device tokens. All cryptography happens on the client. The server never receives a key, a recovery phrase, or plaintext.
Base URL: https://enso.wicker.life. All responses are JSON unless noted. Errors look like {"error": "version_conflict", ...}.
Client-side encryption
Keys
| Name | Derivation | Purpose |
|---|---|---|
K (account key) |
32 random bytes from a CSPRNG, made once on the first device | Root of everything; stored in the macOS Keychain (service enso-sync) |
| Recovery phrase | BIP-39 English mnemonic of K (256 bits + 8-bit checksum = 24 words) |
Restores K on a new device |
K_enc |
HKDF-SHA256(ikm=K, salt="enso-sync-v1", info="blob-encryption", L=32) |
Encrypts blob contents |
K_name |
HKDF-SHA256(ikm=K, salt="enso-sync-v1", info="blob-names", L=32) |
Hashes blob names |
Blob keys (names)
The logical name of an item, such as conversations/2f1c….jsonl or memory.md, is never sent. The client sends:
key = base64url( HMAC-SHA256(K_name, utf8(logical_name)) ) // 43 characters, no padding
The server only checks that a key matches [A-Za-z0-9._-]{1,128}, so it can't tell what kind of item it holds. Clients keep their own index (itself an encrypted blob under the key derived from the name index) that maps logical names to metadata.
Blob format
blob = 0x01 || nonce (24 bytes) || ciphertext || tag (16 bytes)
nonce = 24 random bytes, fresh for every write
plaintext = pad( gzip(item) )
ciphertext, tag = XChaCha20-Poly1305(K_enc, nonce, plaintext, aad)
aad = utf8("enso-blob-v1|") || utf8(key)
- The leading byte is the format version.
- Binding the hashed key into the AAD means the server can't serve one item's ciphertext in place of another's: decryption fails.
pad(x) = u32_be(len(x)) || x || zero bytes, up to the next multiple of 4 KiB, so sizes reveal less.- AES-256-GCM with a 12-byte random nonce is an acceptable alternative for a client that lacks XChaCha20 (format byte
0x02); never reuse a nonce under the same key.
Key check
On a new device, after the phrase is entered, the client fetches the blob for the name keycheck and decrypts it. Success proves the phrase is right before anything is written. The first device writes it when sync is turned on.
What the server learns
Blob count, sizes (padded), version numbers, write times, which device wrote each blob, the account's email, device names, and request metadata (IP, time). Nothing else.
Authentication
Device sign-in (recommended)
POST /api/device/start
Content-Type: application/json
{"email": "you@example.com", "deviceName": "MacBook Air"}
{"deviceCode": "…", "userCode": "QX4M-7PRA", "expiresIn": 900, "interval": 3}
The server emails a magic link. Show userCode to the user; the link opens a page that shows the same code and asks them to approve the device. Then poll every interval seconds:
POST /api/device/poll
Content-Type: application/json
{"deviceCode": "…"}
| Status | Body | Meaning |
|---|---|---|
202 |
{"status": "pending"} |
Not approved yet; keep polling |
200 |
{"status": "approved", "token": "enso_…", "device": {"id", "name"}} |
Store the token in the Keychain. It is returned once |
410 |
{"status": "denied"} or {"status": "expired"} |
Start again |
Tokens
Send the device token on every sync request:
Authorization: Bearer enso_9b1v…
Tokens are 256-bit random values; the server stores only their SHA-256. A token can also be created by hand on the account page.
Blobs
PUT /api/sync/blobs/:key
Body: the encrypted blob, Content-Type: application/octet-stream. One precondition header is required, so an update can never silently overwrite another device's change:
| Header | Meaning |
|---|---|
If-None-Match: * |
Create: succeeds only if no live blob exists under this key |
If-Match: "<version>" |
Update: succeeds only if the current version is exactly this |
| Status | Meaning |
|---|---|
201 / 200 |
Created / updated. Body {"key", "version", "size"}, header ETag: "<version>" |
412 |
version_conflict. Body has currentVersion; fetch, merge, retry with the new version |
428 |
No precondition header was sent |
413 |
Larger than the per-blob limit (default 16 MiB) |
507 |
quota_exceeded. Body has usedBytes and quotaBytes |
Versions start at 1 and go up by one with every write or delete of that key.
GET /api/sync/blobs/:key
Returns the bytes with ETag: "<version>". Send If-None-Match: "<version>" to get 304 Not Modified when you are up to date. 404 if there is no live blob.
DELETE /api/sync/blobs/:key
Optional If-Match: "<version>". Deletes the bytes and leaves a tombstone (with a new version) so other devices learn about the deletion. Tombstones are kept for 90 days. Returns {"key", "version", "deleted": true}.
GET /api/sync/manifest?since=<cursor>&limit=<n>
Everything that changed after since (default 0, meaning everything), oldest change first:
{
"entries": [
{"key": "p9Xk…", "version": 4, "size": 8192, "deleted": false, "updatedAt": "2026-11-09T10:12:03.120Z", "seq": 1093}
],
"cursor": 1093,
"more": false,
"usage": {"usedBytes": 1048576, "quotaBytes": 1073741824, "blobCount": 212}
}
Store cursor and pass it as since next time. If more is true, call again straight away. Writes within an account are serialised, so a cursor never skips a change.
GET /api/sync/usage
{"usedBytes", "quotaBytes", "blobCount"}.
Devices and account
| Request | Does |
|---|---|
GET /api/devices |
Lists the account's devices, marking the current one |
DELETE /api/devices/:id |
Revokes a device; its token stops working immediately |
DELETE /api/account with {"confirm": "delete"} |
Deletes the account, every blob and every device |
Limits
| Limit | Default |
|---|---|
| Storage per account | 1 GiB |
| Size per blob | 16 MiB |
| Requests per device | 300 per minute (429 with Retry-After beyond that) |
| Device sign-in starts | 5 per email and 10 per IP address per 15 minutes |
| Sign-in links | Valid 15 minutes, usable once |
Other endpoints
| Endpoint | Returns |
|---|---|
GET /api/health |
{"ok": true} when the server and database are up |
GET /api/latest |
Latest extension and helper versions, with download URLs and SHA-256 |
GET /updates.json |
Firefox update manifest for the extension |