Sync Server API
REST and WebSocket API for the cloud sync server (zudo-sync-server), a Hono-based Cloudflare Worker deployed at https:.
Base URL: https:
Authentication: Application routes under / require a Better Auth RS256 service JWT or PAT in the Authorization header, except the WebSocket upgrade and explicitly public/development routes. Better Auth's own / mount establishes sessions and mints those service JWTs.
Authorization: Bearer <jwt-token>Personal Access Tokens (PATs) can be used as Bearer tokens for programmatic access. PAT holders cannot manage tokens (mint, list, revoke) — those operations require an interactive sign-in session.
For headless/programmatic workspace access — listing, reading, searching, creating, updating, and deleting notes without driving the app UI — see the Automation API, a / route group on this same Worker with its own scoped-PAT and key-session model.
Better Auth and Handoff Routes
These routes are registered before the application bearer middleware. They have their own Better Auth session, cookie, origin, and one-time-token checks. See Better Auth for the complete desktop and browser sequences.
| Method | Route | Purpose |
|---|---|---|
GET, POST | / | Better Auth handler mount |
GET | / | Email/password page and OTT return to a validated ROOT/LEAF deep-link scheme |
GET | / | Same page with an exact allowlisted HTTPS / return target |
Common / endpoints used by zudo-text are:
| Method | Route | Purpose |
|---|---|---|
POST | / | Establish an email/password session |
GET | / | Validate or resume the sliding session |
GET | / | Mint a single-use, approximately three-minute handoff token from the browser session |
POST | / | Consume an OTT and return the session token/user to the client context |
GET | / | Mint a 15-minute RS256 service JWT from the Better Auth session |
GET | / | Serve the public RS256 key set used by sync, publish, and notifications |
POST | / | Revoke the Better Auth session |
/ requires a random client_state and accepts only zudotext or canonical zudotext-<app-name> schemes. / requires client_state plus an exact return_to; production accepts both https: (canonical) and https:. Neither route is a general-purpose redirector.
Public Routes
GET /health
Health check. No authentication required.
Response:
{ "ok": true }POST /api/v1/auth/dev-login
Development-only login. Creates or finds a user by email and returns a signed JWT. The endpoint is gated by the DEV_LOGIN_KEY secret — if DEV_LOGIN_KEY is not configured in the Worker environment (production default), this endpoint returns 403 regardless of any header value.
To call this endpoint, supply the matching secret in the X-Dev-Login-Key request header.
Request:
{
"email": "developer@example.com",
"name": "Dev User"
}Response:
{
"token": "signed-jwt",
"userId": "uuid",
"email": "developer@example.com",
"name": "Dev User"
}Auth Routes
GET /api/v1/auth/me
Get the currently authenticated user's profile.
Response:
{
"id": "user-uuid",
"email": "user@example.com",
"name": "User Name",
"created_at": "2026-04-06T10:00:00Z",
"updated_at": "2026-04-06T10:00:00Z"
}GET /api/v1/auth/tokens
List the caller's Personal Access Tokens (metadata only — raw token values are never returned after minting). Revoked tokens remain listed with revoked_at populated.
Response:
{
"tokens": [
{
"id": "token-uuid",
"name": "CI token",
"created_at": "2026-04-06T10:00:00Z",
"last_used_at": "2026-05-01T08:30:00Z",
"expires_at": null,
"revoked_at": null,
"scopes": ["full"],
"workspace_id": null
}
]
}POST /api/v1/auth/tokens
Mint a new Personal Access Token. The raw token value is returned exactly once in the response — only its SHA-256 hash is persisted.
Request:
{
"name": "CI token",
"expiresAt": "2027-01-01T00:00:00Z",
"scopes": [
"documents:read",
"documents:write",
"assets:read",
"assets:write"
],
"workspaceId": "workspace-uuid"
}expiresAt is optional. If provided it must be a valid future ISO 8601 timestamp. scopes is optional — an array of one or more of "full", "documents:read", "documents:write", "agent:invoke", "assets:read", "assets:write"; omitted defaults to ["full"], preserving pre-scoped-token behavior. The current app form instead preselects the four document/asset authoring scopes and keeps Full access explicit. workspaceId is optional; if provided it must reference a workspace the caller owns, and the minted token is then rejected (404) for every other workspace. See Personal Access Tokens and the Automation API for what each scope grants.
Response (201):
{
"id": "token-uuid",
"name": "CI token",
"token": "raw-token-value-shown-once",
"created_at": "2026-04-06T10:00:00Z",
"last_used_at": null,
"expires_at": null,
"revoked_at": null,
"scopes": [
"documents:read",
"documents:write",
"assets:read",
"assets:write"
],
"workspace_id": "workspace-uuid"
}DELETE /api/v1/auth/tokens/:id
Soft-revoke a PAT. The row stays listable with revoked_at populated.
Response:
{ "ok": true }Subscription Routes
GET /api/v1/subscription
Get the current user's subscription info.
Response:
{
"plan": "pro",
"status": "active",
"trialStartDate": null,
"trialEndDate": null,
"currentPeriodEnd": "2026-05-06T00:00:00Z",
"cancelAtPeriodEnd": false
}Status values: free, trial, active, past_due, cancelled, expired
POST /api/v1/subscription/trial
Start a 30-day free trial.
Response: Updated SubscriptionInfo object (same shape as GET /).
GET /api/v1/subscription/portal-url
Get a Stripe Customer Portal URL for subscription management.
Response:
{
"url": "https://billing.stripe.com/session/..."
}The URL is a single-use Stripe session. The client opens it in the system browser.
Workspace Routes
GET /api/v1/workspaces
List all workspaces belonging to the authenticated user.
Response:
{
"workspaces": [
{
"id": "workspace-uuid",
"user_id": "user-uuid",
"name": "My Workspace",
"encryption_salt": "hex-encoded-salt",
"created_at": "2026-04-06T10:00:00Z",
"updated_at": "2026-04-06T10:00:00Z"
}
]
}POST /api/v1/workspaces
Create a new workspace. Requires an active subscription.
Request:
{
"name": "My Workspace"
}Response (201):
{
"id": "workspace-uuid",
"userId": "user-uuid",
"name": "My Workspace",
"createdAt": "2026-04-06T10:00:00Z",
"updatedAt": "2026-04-06T10:00:00Z"
}GET /api/v1/workspaces/:id
Get workspace details. Workspace must be owned by the authenticated user.
Response: Same shape as the individual workspace object in the list response.
DELETE /api/v1/workspaces/:id
Delete a workspace, all its files, and all associated R2 blobs.
Response:
{ "ok": true }POST /api/v1/workspaces/:id/setup-encryption
Store the encryption salt and verification hash for a workspace. Called once when the user first configures E2E encryption.
Request:
{
"encryptionSalt": "hex-encoded-32-byte-salt",
"verificationHash": "hex-encoded-sha256-hash",
"verificationSalt": "hex-encoded-verification-salt"
}Response:
{ "ok": true }POST /api/v1/workspaces/:id/verify-password
Verify the user's encryption password against the stored verification hash.
Request:
{
"verificationHash": "hex-encoded-sha256-hash"
}Response:
{ "valid": true }File Routes
GET /api/v1/workspaces/:id/files
List all non-deleted files in a workspace.
Response:
{
"files": [
{
"id": "file-uuid",
"encrypted_path": "base64url-encoded-encrypted-path",
"content_hash": "sha256-hex",
"encrypted_size": 1024,
"version": 3,
"created_at": "2026-04-06T10:00:00Z",
"updated_at": "2026-04-06T10:30:00Z"
}
]
}PUT /api/v1/workspaces/:id/files/:encPath
Upload an encrypted file blob. Requires an active subscription. Content-addressed: re-uploading the same ciphertext hash is idempotent.
Headers:
Content-Type: application/octet-stream
X-Content-Hash: <sha256-hex-of-ciphertext>
X-Device-Id: <device-id>Body: Raw encrypted binary data.
Response: 200 if the file already existed (version incremented), 201 if new.
{
"id": "file-uuid",
"workspaceId": "workspace-uuid",
"encryptedPath": "base64url-encoded-path",
"contentHash": "sha256-hex",
"encryptedSize": 1024,
"version": 4
}GET /api/v1/workspaces/:id/files/:encPath
Download an encrypted file blob.
Response: Raw encrypted binary data with Content-Type: application/octet-stream.
DELETE /api/v1/workspaces/:id/files/:encPath
Soft-delete a file. Requires an active subscription. The file row is marked is_deleted = 1 and a tombstone change record is inserted.
All R2 blobs for the file are retained initially — deleting a file no longer purges its version blobs. Every upsert change row must keep a downloadable version behind it so the Checkpoint restore projections below and per-file version restore can materialize any historical state, including a state where the file existed. The nightly retention job later thins unpinned version rows and queues R2 keys that have no remaining file_versions reference; a bounded reclaimer waits a further 24 hours, rechecks the reference at dequeue time, then deletes the object. Current and checkpoint-pinned versions are always kept.
Headers:
X-Device-Id: <device-id>Response:
{ "ok": true }Version History Routes
GET /api/v1/workspaces/:id/files/:encPath/versions
List all retained versions of a file, most recent first. Every version is available at full resolution for its first 24 hours; the nightly retention policy then keeps hourly representatives through 7 days, daily representatives through 30 days, and weekly representatives thereafter. The current version and versions pinned by any checkpoint are exempt from thinning. A version selected for pruning is not immediately removed from R2: its unreferenced key passes through the GC queue's additional 24-hour grace period first.
Response:
{
"versions": [
{
"id": "version-uuid",
"version": 3,
"content_hash": "sha256-hex",
"encrypted_size": 1024,
"device_id": "device-uuid",
"created_at": "2026-04-06T10:30:00Z",
"changeCursor": 45
}
]
}changeCursor is the changes.id of the change-log row that produced this version — it lets a client line a version up against a Checkpoint's cursor (a checkpoint tag maps to the version with the highest changeCursor <= cursor, never by timestamp). null for legacy rows written before this column existed.
GET /api/v1/workspaces/:id/files/:encPath/versions/:vid
Download a specific version of a file.
Response: Raw encrypted binary data with Content-Type: application/octet-stream.
POST /api/v1/workspaces/:id/files/:encPath/restore/:vid
Restore a file to a specific version. Creates a new version record with the restored content hash and bumps the file's current version number. Non-destructive: the file's current content, just before the restore, remains reachable as its own version.
Response:
{
"id": "file-uuid",
"version": 5,
"contentHash": "sha256-hex",
"encryptedSize": 1024
}This endpoint does not push restored content back to the initiating device — that device's own sync drain treats the new version as self-authored and drops it. Every caller of this endpoint (Note History's per-note restore, and Checkpoint restore below) therefore always follows a successful call with an explicit version-content download and writes it to the local file directly, rather than relying on a pull to deliver it. That local write-back is suppressed client-side before it reaches the upload path: the caller arms the sync echo-guard (noteHistory.markLocalWriteAlreadyOnServer — the same settle-window guard the pull drain uses) immediately before writing, so a restore mints exactly one version row per file — the one this endpoint creates (#4777; previously double-minted, old issue #4194).
Sync Routes
GET /api/v1/workspaces/:id/sync/status
Get the current sync status for a workspace (cursor position and live file count). Requires an active subscription.
Response:
{
"cursor": 45,
"fileCount": 128
}GET /api/v1/workspaces/:id/sync/snapshot
Bootstrap snapshot for a fresh device. Returns a page of the current file set plus the head cursor in a single round-trip. Requires an active subscription.
The cursor is read before the file list, on every page, so any concurrent write lands above it and will be fetched by a subsequent pull from this cursor — no changes are missed.
Query params:
| Param | Type | Default | Description |
|---|---|---|---|
limit | string (numeric) | 100 | Page size. Clamped to [1, 1000]; non-numeric, absent, empty, NaN, or non-finite values fall back to the default; fractional values are floored. |
pageCursor | string | none (first page) | A file id from a previous page's nextPageCursor. Omit for the first page. |
Keyset pagination: files are paged by id ASC (not updated_at DESC — the snapshot is treated as a set, not a feed), filtered to id > pageCursor. This gives one stable query shape for every page.
Response:
{
"files": [
{
"id": "file-uuid",
"encryptedPath": "base64url-encoded-path",
"contentHash": "sha256-hex",
"encryptedSize": 1024,
"version": 3,
"createdAt": "2026-04-06T10:00:00Z",
"updatedAt": "2026-04-06T10:30:00Z"
}
],
"cursor": 45,
"nextPageCursor": "file-uuid",
"hasMore": true
}nextPageCursor is the last file's id when hasMore is true, otherwise null.
Client rule — only the FIRST page's cursor may be persisted. A file created mid-bootstrap can have a UUIDv4 id that sorts before the current pageCursor, so it can be skipped by every remaining page of that walk. Only a follow-up pull starting from the FIRST page's head cursor is guaranteed to replay it — persisting a later page's cursor risks permanently skipping that file. A client walking multiple pages must read cursor from the first response only and ignore it on subsequent pages.
POST /api/v1/workspaces/:id/sync/push
Push local metadata changes to the server. The server detects conflicts and assigns change IDs. Requires an active subscription.
Only action: "delete" rows are accepted. An upsert row here would create a change-log row with no file_versions row backing it — a version a checkpoint could reference but never download (see the history invariants above). Content upserts must go through PUT /files/:encPath, which writes the blob and its change/version rows atomically. A request containing any non-delete action is rejected whole with 400:
{ "error": "sync/push accepts action:'delete' rows only; upload content via PUT /files/:encPath" }Request:
{
"deviceId": "device-uuid",
"changes": [
{
"encryptedPath": "base64url-encoded-path",
"action": "upsert",
"contentHash": "sha256-hex",
"encryptedSize": 1024
}
],
"baseCursor": 42
}baseCursor is the last change ID the client has seen. Any change to a path after baseCursor (by a different device) is reported as a conflict.
Response:
{
"cursor": 45,
"accepted": ["base64url-path-1", "base64url-path-2"],
"conflicts": [
{
"encryptedPath": "base64url-path-3",
"serverVersion": 5,
"serverContentHash": "sha256-hex"
}
]
}Same-device retries with identical content are de-duplicated: a matching deviceId + contentHash pair that already landed above baseCursor is treated as accepted without re-applying the write.
POST /api/v1/workspaces/:id/sync/pull
Pull changes from the server since a given cursor. Requires an active subscription.
If the workspace has been compacted and the client's cursor predates the retention window, responds 409 with { "needsBootstrap": true, "compactionFloor": N } — the client should re-bootstrap via GET .../sync/snapshot.
Request:
{
"deviceId": "device-uuid",
"cursor": 42,
"limit": 100
}limit defaults to 100. When hasMore is true, call again with the returned cursor to fetch the next page.
Response:
{
"changes": [
{
"encryptedPath": "base64url-encoded-path",
"action": "upsert",
"contentHash": "sha256-hex",
"encryptedSize": 1024,
"version": 43,
"changedAt": "2026-04-06T10:30:00Z",
"changedByDevice": "device-uuid"
}
],
"cursor": 45,
"hasMore": false
}Checkpoint Routes
A checkpoint is a named marker into the document change log — cursor is the changes.id head at creation time. There is no server-side restore engine; a checkpoint restore is entirely client-orchestrated (see Checkpoints). This unrestricted workspace route family provides checkpoint CRUD plus three read-model projections computed by replaying the document log. The narrow plaintext facade is documented under Automation API → History and Checkpoint Routes.
Assets are not rows in this log and have no checkpoint marker, summary, restore-manifest entry, note-state, version restore, or cursor coverage.
Compaction invariant. The projection endpoints below (summary, restore-manifest, note-state) replay the full change log, so they depend on change-log compaction staying disabled while any checkpoint exists. Every projection responds 409 whenever the workspace's compaction_floor is set at all — even a floor below every checkpoint's cursor still invalidates the replay, because a path whose only changes were compacted away would silently vanish from it:
{ "error": "Workspace history has been compacted; checkpoint projections are unavailable", "compactionFloor": 12 }Nothing sets compaction_floor today; the guard exists for a future compaction job.
POST /api/v1/workspaces/:id/checkpoints
Create a checkpoint at the current head cursor. Requires an active subscription.
Request:
{
"encryptedLabel": "base64-or-hex-encoded-encrypted-label",
"kind": "manual"
}kind must be "manual" or "auto" (v1 clients only ever send "manual"; "auto" is reserved for a future scheduled-checkpoint feature). encryptedLabel is opaque to the server — see "Checkpoint label encryption" below.
Response (201):
{
"id": "checkpoint-uuid",
"workspaceId": "workspace-uuid",
"encryptedLabel": "base64-or-hex-encoded-encrypted-label",
"kind": "manual",
"cursor": 45,
"createdAt": "2026-07-27 07:14:05"
}createdAt (here and on every checkpoint object below) is SQLite datetime('now') format — "YYYY-MM-DD HH:MM:SS", UTC, no T/Z/milliseconds. Parse it as UTC explicitly; naive new Date(createdAt) parsing is not reliably UTC across JS engines.
GET /api/v1/workspaces/:id/checkpoints
List all checkpoints for the workspace, newest cursor first.
Response:
{
"checkpoints": [
{
"id": "checkpoint-uuid",
"encryptedLabel": "base64-or-hex-encoded-encrypted-label",
"kind": "manual",
"cursor": 45,
"createdAt": "2026-07-27 07:14:05"
}
]
}DELETE /api/v1/workspaces/:id/checkpoints/:cpId
Delete a checkpoint marker. git tag -d semantics — only the marker row is removed; the change log and every version blob it could reference are untouched. Requires an active subscription.
Response:
{ "ok": true }GET /api/v1/workspaces/:id/checkpoints/:cpId/summary?fromCursor=
Per-path aggregate of change-log activity in the window (fromCursor, checkpoint.cursor]. If fromCursor is omitted, it defaults to the next-older checkpoint's cursor, or 0 (the whole log up to this checkpoint) when there is none.
Response:
{
"fromCursor": 30,
"toCursor": 45,
"entries": [
{
"encryptedPath": "base64url-encoded-path",
"action": "modified",
"changeCount": 3,
"latestVersion": 7
}
]
}Per path: changeCount is how many change-log rows touched it in the window; latestVersion is from its last row in the window. action is a 3-state value derived from that last row plus the path's pre-window liveness:
action | Meaning |
|---|---|
deleted | The path's last row in the window is a delete (its net outcome — e.g. a path edited twice then deleted nets out to deleted). |
created | The last row is an upsert and the path was not alive at fromCursor. |
modified | The last row is an upsert and the path was alive at fromCursor. |
"Alive at fromCursor" is last-row-wins: the path's most recent change row at or before fromCursor is an upsert — the same liveness rule the restore manifest uses, so a path deleted before the window and re-created inside it reads created in both projections. Only pre-window state counts: a path that churns upsert → delete → upsert inside one window still reads created.
GET /api/v1/workspaces/:id/checkpoints/:cpId/restore-manifest
The client's work order for a checkpoint restore: a cursor-exact diff between the workspace's state at the checkpoint's cursor and its current head, computed against one atomic head snapshot (expectedHeadCursor) so a concurrent write mid-request cannot skew the result.
Response:
{
"expectedHeadCursor": 52,
"entries": [
{
"encryptedPath": "base64url-encoded-path",
"op": "restore",
"beforeVersionId": "current-head-version-uuid",
"afterVersionId": "at-cursor-version-uuid"
}
]
}Per entry, beforeVersionId/afterVersionId name the state before and after the restore operation, not "before/after the checkpoint" on the timeline:
op | Meaning | beforeVersionId | afterVersionId |
|---|---|---|---|
restore | Path must become the at-cursor (checkpoint) version. | Current head version, or null if the path is absent right now. | The version to restore the path TO — the at-cursor/checkpoint version. |
delete | Path exists now but was absent at the checkpoint's cursor (created or re-created after the checkpoint) — delete it as part of the restore. | Current head version. | null. |
noop | Same version on both sides. | Equal to afterVersionId. | Equal to beforeVersionId. |
A path absent on both sides is omitted from entries entirely.
A client should compare its cached expectedHeadCursor against a freshly-fetched one immediately before applying a restore, and re-fetch on drift — this is exactly the staleness re-check the Checkpoints UI performs at its confirm step.
GET /api/v1/workspaces/:id/checkpoints/:cpId/note-state?encPath=
One path's state at the checkpoint's cursor.
Response (present):
{ "versionId": "at-cursor-version-uuid" }Response (absent — deleted, or not yet created at that cursor):
{ "absent": true }versionId may be null for a legacy change-log row written before every upsert was guaranteed a file_versions row (see the history invariants under DELETE / above).
Checkpoint label encryption
Checkpoint labels use a versioned randomized AEAD envelope (AES-GCM, non-deterministic), the same content-style scheme as note bodies — not the deterministic path cipher used for encryptedPath values elsewhere in this API. The server never sees or validates the plaintext label.
Device Routes
GET /api/v1/devices
List all registered devices for the authenticated user.
Response:
{
"devices": [
{
"id": "device-uuid",
"name": "MacBook Pro",
"platform": "macos",
"last_seen_at": "2026-04-06T10:30:00Z",
"created_at": "2026-04-06T10:00:00Z"
}
]
}POST /api/v1/devices
Register a new device.
Request:
{
"name": "MacBook Pro",
"platform": "macos"
}Response (201):
{
"id": "device-uuid",
"userId": "user-uuid",
"name": "MacBook Pro",
"platform": "macos"
}DELETE /api/v1/devices/:id
Remove a device registration.
Response:
{ "ok": true }GET /api/v1/devices/vapid-public-key
Get the VAPID public key needed to register a Web Push subscription. Returns { enabled: false } when Web Push is not configured in the worker environment.
Response:
{
"publicKey": "base64url-encoded-vapid-public-key",
"enabled": true
}POST /api/v1/devices/push-subscription
Register (or rotate) a Web Push subscription for a (workspaceId, deviceId) pair. Used on iOS where background WebSockets are suspended. Upserts: re-subscribing replaces the stored endpoint and keys.
Request:
{
"workspaceId": "workspace-uuid",
"deviceId": "device-uuid",
"endpoint": "https://push.example.com/...",
"keys": {
"p256dh": "base64url-encoded",
"auth": "base64url-encoded"
}
}Response (201):
{ "ok": true }DELETE /api/v1/devices/push-subscription/:workspaceId/:deviceId
Unregister a Web Push subscription.
Response:
{ "ok": true }WebSocket
GET /api/v1/workspaces/:id/ws
Real-time sync notifications via WebSocket. The browser WebSocket API cannot send custom request headers, so authentication uses a ?token= query parameter rather than an Authorization header.
wss://zudo-sync-server.takazudo.workers.dev/api/v1/workspaces/:id/ws?token=<jwt-or-pat> The server validates the token, checks workspace ownership, and delegates to a Durable Object (SyncRoom) for the live connection. Workspace access is owner-only.
Client to Server Messages
hello
Sent immediately after connection opens. Tells the server the client's current cursor and device ID.
{
"type": "hello",
"cursor": 42,
"deviceId": "device-uuid"
}ping
Keep-alive message.
{ "type": "ping" }Server to Client Messages
welcome
Sent in response to hello. Contains the server's current cursor and connected-device count.
{
"type": "welcome",
"cursor": 45,
"deviceCount": 2
}changes
Pushed when another device syncs changes. The client should pull from the sync endpoint and download affected files.
{
"type": "changes",
"changes": [
{
"encryptedPath": "base64url-encoded-path",
"action": "upsert",
"contentHash": "sha256-hex",
"encryptedSize": 1024,
"version": 46
}
],
"cursor": 46
}pong
Response to client ping.
{ "type": "pong" }error
Server-side error notification.
{
"type": "error",
"code": "workspace_not_found",
"message": "The requested workspace does not exist"
}device_connected
Another device connected to the same workspace.
{
"type": "device_connected",
"deviceId": "device-uuid",
"deviceName": "iPhone"
}device_disconnected
A device disconnected from the workspace.
{
"type": "device_disconnected",
"deviceId": "device-uuid"
}AI Routes (moved)
This Worker no longer serves an AI chat endpoint — POST / and its mount were retired (#4639) when AI moved to a dedicated Worker. The two current AI surfaces live on agent-server (zudo-agent-server) instead:
Programmatic / agent document access — the Automation API (
/), still mounted on this Worker, is what the assistant's own tools and MCP document/history/change tools call to read and write plaintext. MCP assets use the separate opaque family below.api/ v1/ automation/ * The AI assistant and inline command — both served by
agent-server. See Flue Agent Platform for the assistant's conversation ingress, tool calls, quota, and retention; the inline command's one-shot/route is documented from the user side in Inline AI command.inline
Asset Routes
Assets are scoped to a workspace. The workspace ID must be supplied as the X-Workspace-Id header or a ?workspaceId= query parameter on every request. The public bridge.assets.* API is still plaintext, but these HTTP routes are a wire-level encrypted contract: :token and encryptedFilename are opaque, deterministically encrypted filename tokens, and data is a base64 transport of an authenticated encrypted byte envelope. The server cannot derive a plaintext filename, MIME type, asset kind, or file contents.
PAT authorization is method-specific and does not use the document subscription/key-session gate:
| Method family | Narrow PAT grant |
|---|---|
GET /, GET /, GET /api/assets/:token | assets:read |
POST /api/assets | assets:write |
PATCH /api/assets/:token, DELETE /api/assets/:token, POST / | interactive auth or full PAT only |
Every path still checks workspace ownership and a bound PAT cannot cross its workspace. Free accounts retain the asset quota. Assets never use X-Key-Session; the client performs crypto locally. An explicit assets:read PAT can fetch the workspace salt from the Automation API metadata exception without an active document subscription; assets:write alone cannot.
GET /api/assets/list
List the opaque metadata rows for a workspace, newest first. The client decrypts each filename, subtracts the fixed 61-byte envelope overhead from sizeBytes, converts uploadedAt to an ISO date, and derives kind/MIME from the extension.
Response: JSON array of asset entries:
[
{
"encryptedFilename": "<base64url-opaque-token>",
"sizeBytes": 204861,
"uploadedAt": 1775469600000
}
]GET /api/assets/usage
Return account-owner-scoped usedBytes, effective quotaBytes, storage plan, addonBytes, and locked. A locked/over-quota account can still use read operations; creation is rejected by quota accounting.
POST /api/assets
Upload a new encrypted asset. The body is { encryptedFilename, data }; data is standard base64 encoding of the complete encrypted envelope, not plaintext file bytes. The server writes it as application/octet-stream under a random per-upload R2 key and inserts the opaque name mapping only if that token does not already exist.
Request:
{
"encryptedFilename": "<base64url-opaque-token>",
"data": "<base64-encoded-encrypted-envelope>"
}Response (201): Plain JSON string — the same opaque token.
"<base64url-opaque-token>"The client may provide Idempotency-Key (1–128 visible ASCII characters), scoped to the workspace for a 24-hour replay window. The same key/token replays the committed result; reuse with another token is 409. An existing token also returns 409; filename suffix collision handling belongs to the client, which encrypts and retries a new plaintext candidate. The decoded envelope must be at least 61 bytes and at most 25 MiB + 61 bytes (a 25 MiB plaintext file).
Quota reservation and the D1 row commit transactionally after the R2 put. A creation that would cross quota cleans up its just-written object best-effort and returns structured 507 STORAGE_QUOTA_EXCEEDED details.
GET /api/assets/:token
Download an encrypted asset envelope by opaque name token. The client verifies the HMAC before decrypting and returns plaintext base64 through the unchanged bridge contract.
Response: 200 text/plain — base64-encoded encrypted envelope.
X-Asset-Size-Bytes contains the encrypted envelope size. There is no MIME header; MIME is client-derived after name decryption.
PATCH /api/assets/:token
Interactive/full only. Re-key one asset to { "newEncryptedFilename": "<new-token>" } without re-uploading bytes. It preserves the random R2 key, size, timestamp, and accounting. Missing source is 404; occupied destination is 409. Narrow asset PATs cannot call this route.
DELETE /api/assets/:token
Interactive/full only. Delete an asset by opaque token. Logical D1 deletion and usage-counter decrement commit in one transaction; R2 cleanup is best-effort afterward. The operation is idempotent and returns { ok: true } when the mapping is already missing. Narrow asset PATs cannot call it.
Response:
{ "ok": true }POST /api/assets/batch-delete
Interactive/full only. Validate the complete token array before mutating, then delete logical rows/accounting transactionally and clean R2 objects best-effort. Duplicate or invalid tokens reject the whole request. Valid already-absent tokens report idempotent deleted: false. Narrow asset PATs cannot call this route.
POST /api/assets/import
Not available in cloud mode — the worker has no access to local filesystem paths. Always returns 400.
Every committed upload, empty-folder marker creation, interactive rename, or interactive delete emits one content-free room message:
{ "type": "assets-changed" }It contains no path, opaque token, body, count, or document cursor. Replays, no-ops, empty batches, and failures emit nothing. It is only a wake-up to refetch listing/usage, not an ordered or replayable asset feed.
Deterministic tokens intentionally reveal same-filename equality within a workspace, and their length tracks plaintext filename length. The server also observes encrypted byte size (plaintext size plus fixed overhead), upload timing, and access patterns. The inherited deterministic 96-bit IV has a theoretical nonce-collision caveat at extreme namespace sizes. Base64 is retained to match the plaintext bridge's transport shape; replacing its roughly 33% expansion with binary streaming remains a follow-up. See Sync Architecture → Asset Encryption.
Webhook Routes
POST /api/v1/webhooks/stripe
Handle Stripe webhook events. Registered before the auth middleware — no JWT required. If STRIPE_WEBHOOK_SECRET is configured, the Stripe-Signature header is verified (HMAC-SHA256). Processes customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, and invoice.payment_failed.
Response:
{ "received": true }Error Responses
All error responses use a flat { error: "message" } shape (not a nested object).
Common HTTP Status Codes
| Status | Meaning |
|---|---|
| 400 | Bad request — missing or invalid field |
| 401 | Missing or invalid JWT / PAT |
| 402 | Active subscription required |
| 403 | Forbidden (e.g., PAT caller attempting token management) |
| 404 | Workspace, file, device, checkpoint, or user not found |
| 409 | Conflict, compaction bootstrap required, or a checkpoint projection blocked by a set compaction_floor |
| 413 | Payload too large |
| 429 | Daily AI quota exceeded |
| 503 | Transient storage error or kill switch active |