Skip to content

REST API

All endpoints are under /api. Every request must include a bearer token in the Authorization header:

Authorization: Bearer <token>

Tokens are issued during device pairing and are scoped to a single device session.

All non-2xx responses use the same error envelope:

{
"error": {
"code": "not_found",
"message": "Item not found"
}
}

Error codes:

CodeHTTP statusMeaning
unauthorized401Missing or invalid bearer token
forbidden403Token valid but action not permitted
not_found404Resource does not exist
validation400Request body failed schema validation
conflict409Duplicate ID or concurrent write conflict
rate_limited429Too many requests
internal500Unexpected server error
pair_expired4006-word pair code has expired
pair_invalid400Pair code or attestation is wrong

Write a new item. The payload must be encrypted on-device before this call.

Request body:

{
id: string; // ULID, client-generated
binId: string; // ULID of the destination bin
type: ItemType; // see Content Types
ts: number; // ms since epoch, client clock
payloadEnc?: { // for payloads <= 64 KB after encryption
ct: string; // base64url ciphertext (XChaCha20-Poly1305)
nonce: string; // base64url 24-byte nonce
keys: { // item key wrapped per device
[deviceId: string]: string; // base64url crypto_box_seal output
};
};
blobRef?: string; // blob name for large payloads (mutually exclusive with payloadEnc)
blobNonce?: string; // base64url nonce used to encrypt the blob
blobKeys?: { // same structure as payloadEnc.keys, for the blob key
[deviceId: string]: string;
};
}

Exactly one of payloadEnc or blobRef must be set. For blobs, first request an upload URL via POST /api/blobs/upload-url, upload the encrypted bytes directly to storage, then call this endpoint with the returned blobRef.

Response 200:

{
"id": "01HXXX...",
"version": 42
}

version is a server-assigned monotonic integer used by the sync protocol.


Fetch items with a version greater than N. Used to catch up after being offline or on initial load.

Query parameters:

ParameterDefaultDescription
since0Return only items with version > since
limit500Max items per response (max 1000)

Response 200:

{
"items": [
{
"id": "01HXXX...",
"binId": "01HYYY...",
"type": "link",
"senderDevice": "01HZZZ...",
"ts": 1716000000000,
"version": 43,
"deletedAt": null,
"payloadEnc": { "ct": "...", "nonce": "...", "keys": { "...": "..." } },
"blobRef": null,
"blobNonce": null,
"blobKeys": null
}
],
"nextSince": 43,
"hasMore": false
}

When hasMore is true, call again with since=nextSince to page through the backlog.


Soft-delete an item. Items are retained for 30 days, then hard-deleted by a nightly job.

Response 200:

{
"id": "01HXXX...",
"version": 44
}

Large items (images, files, audio, video) are uploaded directly to blob storage. The server issues a short-lived signed URL; the client uploads the encrypted bytes to that URL without routing them through the server.

Request a pre-signed upload URL.

Request body:

{
"contentLength": 2097152,
"contentType": "application/octet-stream"
}

Response 200:

{
"blobRef": "01HXXX....bin",
"url": "https://blob.example.com/syncbins/01HXXX....bin?sas=...",
"expiresAt": 1716000900000
}

url is a user-delegated SAS URL (Azure) or pre-signed URL (S3/R2). Upload the encrypted ciphertext with a PUT to this URL before expiresAt. Then call POST /api/items with the blobRef.


Get a short-lived download URL for a blob.

Response 200:

{
"url": "https://blob.example.com/syncbins/01HXXX....bin?sas=...",
"expiresAt": 1716000600000
}

Clients should refresh the URL before expiresAt. A reasonable strategy is to refresh when less than 60 seconds remain.


Complete the device pairing handshake. See Pairing Devices for the full protocol.

Request body:

{
"code": "apple banana cherry delta echo foxtrot",
"pubkey": "<base64url Curve25519 public key>",
"name": "My Laptop",
"glyph": "laptop",
"attestation": "<base64url HMAC-SHA256>"
}

glyph is one of laptop, phone, tablet, globe.

attestation = HMAC-SHA256(sharedSecret, pubkey || ephemeralPubkey) encoded as base64url. Proves the new device knows the pair code without revealing it.

Response 200:

{
"device": {
"id": "01HXXX...",
"name": "My Laptop",
"glyph": "laptop",
"pubkey": "...",
"pairedAt": 1716000000000
},
"token": "<bearer token>",
"wrappedMasterKey": "<base64url crypto_box_seal output>"
}

Store token as the bearer token for future requests. wrappedMasterKey is the master key sealed to this device’s public key — unseal it with the device’s private key to get the master key.


Returns information about the current device and server.

Response 200:

{
"device": {
"id": "01HXXX...",
"name": "My Laptop",
"glyph": "laptop",
"pairedAt": 1716000000000
},
"server": {
"version": "1.2.3",
"region": "eastus",
"storageUsedBytes": 52428800,
"storageCapBytes": 53687091200,
"itemCount": 1247,
"deviceCount": 4,
"devicesOnline": 2
}
}

All resource IDs are ULIDs — 26-character, lexicographically sortable, globally unique identifiers. Clients generate item IDs locally before writing; the server validates format but does not generate IDs for items.