All routes live in apps/worker/src/ and are registered in index.ts. Every request except the sign-in routes below needs a user (see Sign-in); without one the answer is 401. Errors are JSON { "error": "…" } with a 4xx/5xx status. Ids are UUIDs chosen by the client unless noted.
Every JSON answer carries cache-control: no-store, x-content-type-options: nosniff and referrer-policy: same-origin (json() in packages/service-kit); the image and render proxies keep their own caching and add nosniff. A JSON body needs content-type: application/json exactly (parameters such as charset allowed), else 415. A path parameter with a malformed % sequence is 400. A change (anything but GET/HEAD) that rides on the Studio's cookie and that the browser marks Sec-Fetch-Site: cross-site is refused with 403, the sign-in routes included; bearer requests (the phone) are not affected (#224).
| Method | Path | Notes |
|---|---|---|
| POST | /api/auth/login |
Public. { email, password, client?: "studio" | "mobile" } (anything but mobile is stored as sessions.client = dashboard) → { user: { id, email, name }, expires_at }. The Studio gets the session as an HttpOnly cookie openclapper_session; client: "mobile" gets token in the body, to send as Authorization: Bearer <token>. One 401 for an unknown email and a wrong password (no lockout, no rate limit yet: #224, accounts); 403 for a blocked account (only after the right password) |
| POST | /api/auth/logout |
Public. Deletes the request's session if any and clears the cookie; always 204 |
| POST | /api/auth/password |
Session required (cookie or bearer; under /api/auth/ because only the first-party provider has passwords). { current, password } → 204. The new password follows passwordProblem (400), must differ from the current one (400). A wrong current password is 403 (not 401, which the clients read as signed out). Every other session of the account ends (#148) |
| GET | /api/me |
{ user: { id, email, name }, workspaces: [{ id, name, role, personal }] }; the clients' signed-in probe |
| GET | /api/ping |
Public. { ok: true, version, commit, instance }: the product version, the deployed commit (dev locally, releasing) and the instance (INSTANCE_LABEL, "" for production; the Studio shows it as a badge, even on sign-in); touches nothing |
| GET | /api/workspaces |
The caller's: [{ id, name, role, personal, member_count, project_count, created_at }], personal first |
| PUT | /api/workspaces/:id |
{ name }: a new shared workspace, the caller its owner (201), or a rename (admin) |
| GET | /api/workspaces/:id/members |
[{ user_id, email, name, role, created_at }], any member |
| POST | /api/workspaces/:id/members |
{ email, role } (admin, editor, viewer): adds an existing account of this instance (404 for an unknown email) or changes its role. Admins manage editors and viewers, the owner also admins; the owner's role never changes |
| DELETE | /api/workspaces/:id/members/:user |
Remove (same rule), or leave (your own id). The owner cannot leave (400) |
Who a request is (index.ts identify): a session (bearer header, else cookie; sliding: Studio 30 days, phone 180, extended at most once a day). Local development too: there is no bypass. Before any change the auth provider's allow is asked (403 when it says no; the first-party provider always allows).
Access (tenancy.ts). Lists hold what is in the caller's workspaces. A route by id answers 404 for a row of a workspace the caller is not in (exactly like a missing one) and 403 when their role there is too low: viewer reads, editor changes content, admin/owner manage the workspace. A PUT upsert to an id of another workspace is a 404, never an overwrite. A project named in a body (an upload, a move target) that the caller cannot reach is 400 project_id does not exist, as an unknown one, so a phone parks such an upload as stuck. Shots never move to a project of another workspace (400). Lists without ?project_id= (locations, spots, fields, views, days, shots) cover every project of the caller's workspaces. A location or spot named by id is only ever matched or copied by name within the same workspace: an upload naming one of another workspace drops it (as a deleted one), an edit is 400. Files, the desktop and the recycle bin need viewer on the project to read and editor to change. Timelines and days never move to a project of another workspace (400); a sketch is copied only within its workspace, and a framing's rig must be one of the photo's workspace.
| Method | Path | Notes |
|---|---|---|
| GET | /api/projects |
Each with workspace_id, shot_count and field_ids (ordered) |
| PUT | /api/projects/:id |
{ name, notes?, workspace_id?, bake_approved_only? } upsert (bake_approved_only a boolean; absent keeps the project's setting; every shot payload carries it too). A new project goes to workspace_id (editor there), default the caller's own workspace; an existing one keeps its workspace. Names are unique per workspace: clash → 409 { existing_id } (of that workspace) |
| DELETE | /api/projects/:id |
Only without live shots, else 409; the project's recycle bin is emptied first |
| PUT | /api/projects/:id/fields |
{ field_ids: [...] } reorders the project's own fields (must list exactly them) |
| Method | Path | Notes |
|---|---|---|
| GET | /api/shots?project_id=&state=&location_id=&limit=&before=&before_id= |
Newest first, keyset pagination on (captured_at, id), next cursor. Photos embedded. |
| GET | /api/shots/:id |
|
| POST | /api/shots |
Multipart: metadata (JSON) + one photo.<photoId> file per photo not yet stored. See below. |
| PATCH | /api/shots/:id |
Any of name, light, artificial, weather, int_ext, shot_size, camera_support, movement, location_id, extra, description, position_from_location, state, project_id. extra is validated against the project's fields. Moving to another project removes the shot from the old project's shooting days and timelines. |
| PATCH | /api/shots |
Bulk edit: { ids: uuid[] (≤ 500), set?: { any single-PATCH field except extra }, extra?: { key: value | null } }. Only the given fields change. extra is per key and merged into each shot's values (groups child by child, null clears, dependent selects that no longer fit are dropped), then the edited keys are checked against each shot's (new) project. Unknown ids → 404, any error → nothing written (one transaction). Moving removes the shots from the old project's shooting days. Returns { shots }. |
| DELETE | /api/shots/:id |
Moves the shot to the recycle bin ({ deleted, binned: true }); its timeline clips and day places go now. Purging deletes photos, layers, bakes, sketches and their R2 objects |
| GET | /api/photos/:id/image |
Image from R2, immutable caching, conditional requests: If-None-Match hit 304, a failed If-Match 412 (#224) |
| PATCH | /api/photos/:id |
{ lat, lon, all_in_shot? } position correction (also sets a position on photos captured without one); sets position_corrected |
| PUT | /api/shots/:id/steps |
{ steps: [{ id, photo_id, framing_id? }] } replaces the shot's stored steps (composition); [] makes the photos the steps again. Strict (interactive): 400 for a photo not of the shot, a framing not of that photo, a repeated id, more than 500 steps; 404 for a binned shot. Layer ranges keep ids of removed steps (those layers show nowhere then); bakes of removed steps are deleted. Returns { shot } |
| POST | /api/shots/:id/photos |
Merge: { photo_ids, steps? } moves photos of other shots into this one, appended in the given order (framings and photo-space layers go with them, the layers on every step; their clips' own layers follow the clips; steps, if given, replaces the list afterwards in the same transaction). The photos must be in live shots of the same project (400 otherwise, also for unknown ids), not already here (400), total ≤ 200 (413). A source shot left without photos is deleted for good (its sketches and frame-space layers move here, the layers hidden; its day places, shortcuts and bakes go); timeline clips of the moved photos leave their sequence blocks. The only route that moves photos: POST /api/shots keeps answering 409. Returns { shot, removed_shot_ids } |
| POST | /api/shots/:id/photos/:photo/extract |
Extract one photo as a shot of its own, a copy: { id?, framing_id?, step_id? }. The new shot (id, else fresh; 409 when taken) copies the tags, description and state (name "… · photo n"), the photo row with its own R2 object, its framings (ids remapped), and the layers the step shows (step_id, a stored step of that photo, 400 otherwise; without it the photo as a step), each on every step of the copy; framing_id (a framing of the photo, 400 otherwise) becomes the copy's root, else the photo's root carries over. The source is untouched; no link back; the Studio bakes the copy. Returns { shot }, 201 |
POST /api/shots metadata:
{ "id": "<shot uuid>", "project_id": "…", "name": null, "location_id": null, "int_ext": null,
"light": ["dusk"], "artificial": false, "weather": null, "shot_size": "ws", "camera_support": null, "movement": ["pan"],
"extra": {}, "state": "unreviewed",
"photos": [{ "id": "…", "ordinal": 0, "timestamp": "ISO", "lat": 52.5, "lon": 13.4, "gps_accuracy_m": 5, "position_corrected": false,
"preset_id": null, "lens_mm": 24, "width": 1280, "height": 960, "framing": {…}, "device": {…} }] }- Idempotent. An existing shot keeps its tags (they may have been edited since); only photos not stored yet are added. That is how retries and chunked sequence uploads work (the phone sends 12 photos per request, the server allows 60). A photo id already stored in another shot is
409before anything is written (the R2 key comes from the id). - Limits (#224), all checked before anything is written: a declared
content-lengthabove 60 × 3 MB + 1 MB is413before the body is read; each image at most 3 MB (413); the stored type comes from the file's magic bytes (JPEGFF D8 FF, PNG89 50 4E 47 0D 0A 1A 0A, WebPRIFF….WEBP), whatever the part's declared type, and anything else is415; a shot holds at most 200 photos across its uploads (413with the count). The phone parks such a rejection asstuckand keeps the files (capture). lat/lonmay be null or missing (captured without GPS); half a position is dropped.position_correctedmarks a position set by hand on the phone before upload.statecarries a review decision made on the phone before upload; anything invalid becomesunreviewed.photos[].source:camera(default),uploadordrawn(Studio-made, new shot); for the last twolens_mmis optional and stored as 0.- Lenient on purpose: unknown
preset_id/location_idbecome null, names over 120 characters are cut,extrais only pruned, not validated. 201for a new shot,200for an existing one;duplicate: truewhen nothing was added.
| Method | Path | Notes |
|---|---|---|
| PUT | /api/framings/:id |
Upsert { photo_id, name, rig_id?, lens_mm, frame: { width_fraction, height_fraction, x?, y? }, root? }. The centre is clamped to the photo; root: true also makes it the photo's root. A framing cannot move to another photo (409). Returns { shot }. |
| DELETE | /api/framings/:id |
A deleted root falls back to as captured. Returns { deleted, shot } |
| PUT | /api/photos/:id/root |
{ framing_id: uuid | null } (null = as captured) → { shot } |
Timeline-clip presentations accept frame.x/frame.y and framing_id.
Shots embed their layers (stack order, without shapes) and bakes ([{ step_key, hash, url }]); see composition for the model.
| Method | Path | Notes |
|---|---|---|
| GET | /api/shots/:id/layers |
The shot's own layers with their shapes (the Studio's editor and baker) |
| GET | /api/layers/:id |
One layer with shapes |
| PUT | /api/layers/:id |
Upsert { shot_id, kind: drawing | adjustment, name, description?, position (−1000..1000; < 0 below the footage), visible, locked, opacity (0..1), space: frame | photo, photo_id? (photo space: a photo of the shot), range? (null = every step, else stored step ids of the shot), shapes? (drawing), look? (adjustment: exposure … blur), timeline_id? + clip_id? (a timeline clip's own; the timeline in the shot's project) }, checked by validateLayerInput (strict). The shot, the kind and the owner are fixed at creation (409). At most 50 layers per shot (or per clip), shapes ≤ 1 MB. Returns { layer, shot }; 201 when new |
| DELETE | /api/layers/:id |
→ { deleted, shot } |
| POST | /api/layers/:id/copy |
{ id, timeline_id, clip_id }: copy as a timeline clip's own layer (a duplicated clip); the timeline must be in the layer's shot's project. 201 |
| PUT | /api/shots/:id/bakes/:key |
A step's flattened image: multipart metadata { hash } (16 hex, vocab bakeHash) + image (JPEG, PNG or WebP by its bytes, ≤ 6 MB). key is a stored step of the shot, or one of its photos when it has none (400 otherwise). Stored at bakes/<shot>/<key>-<hash>.<ext>, replacing the previous image; bakes of steps that are gone are removed. → { shot } |
| GET | /api/shots/:id/bakes/:key |
The bake; the embedded url carries ?v=<hash>, so it is cached as immutable. Conditional requests as for photos |
| Method | Path | Notes |
|---|---|---|
| GET / PUT / PATCH / DELETE | /api/sketches/:id |
Upsert { shot_id, name, kind?, description?, drawing (look null), aspect (0.25..4), position? } as JSON, or multipart with metadata (that JSON) and render (JPEG, PNG or WebP by its magic bytes, else 415; ≤ 6 MB; a declared content-length above 6 MB + 1 MB + 64 KB is 413 before the body is read, #224). PATCH takes name, kind, description, position |
| GET | /api/sketches/:id/render |
The render; render_url carries ?v=<updated_at>, so it is cached as immutable. Conditional requests as for photos (304, 412) |
| POST | /api/sketches/:id/copy |
{ id, timeline_id, clip_id }: copy (with the render) as a timeline clip's own sketch; used when a clip is duplicated. 201 |
Clip-owned sketches (issue #31): with timeline_id + clip_id instead of shot_id the sketch belongs to that timeline clip. The owner is fixed at creation (409 on a change). Shots never embed clip-owned rows. A PUT/PATCH/DELETE of a clip-owned sketch returns shot: null.
The old overlay routes are gone (migration 0030 copied overlays into layers).
| Method | Path | Notes |
|---|---|---|
| GET | /api/timelines?project_id= |
Each with clips: [{ id, photo_id, shot_id, step_id, presentation, duration_ms, notes, title, sketch_id, group_id }] in order, groups: [{ id, mode, total_ms, split, edge_weight }] (sequence blocks, issue #37), plus layers and sketches: the clips' own (issues #31, #275), without shapes or drawings. A placeholder clip has photo_id/shot_id/step_id/presentation null and a title. |
| GET | /api/timelines/:id |
|
| PUT | /api/timelines/:id |
{ project_id, name, notes?, lock_mode?, groups?, clips: [...] } replaces the whole timeline (≤ 500 clips). lock_mode (a presentation mode or null) forces one mode on every clip. A clip is { id, photo_id, step_id?, presentation, duration_ms, notes? } or a placeholder { id, title, duration_ms, notes? }; a placeholder with sketch_id is a sketch clip. step_id is a stored step of the clip's own photo (anything else is nulled): the clip shows that step's layers, plus its own; a sketch clip's sketch must be its own or a shot sketch in the project (else it falls back to a placeholder). Clips that leave the timeline take their own layers and sketches (and renders) along. Clips whose photo is not in the project are dropped silently. Sequence blocks: a photo clip may carry group_id of one of groups (mode each · total, total_ms, split even · edges, edge_weight 1..3, rules in packages/vocab/src/timeline.ts). A block's clips must be adjacent and show photos of one shot (400 otherwise); blocks without clips are dropped, and a total block's clip durations are recomputed with splitDuration on every save. |
| DELETE | /api/timelines/:id |
To the recycle bin. Purging deletes the clips' own layers and sketches (with their renders) too. |
| POST | /api/timelines/:id/clips/:clip/promote |
{ what: "framing" | "layer" | "sketch", name?, layer_id?, shot_id? }: the clip's own re-frame becomes a saved framing of its photo (an identical framing — same rig, lens and frame — is linked instead; the clip then references it), one of its own layers (layer_id) becomes a layer of the shot on the clip's step (every step when the clip has none), or a sketch clip's own sketch becomes a sketch of shot_id (a shot in the project). The clip must be saved. → { timeline, shot } |
| Method | Path | Notes |
|---|---|---|
| GET / PUT / DELETE | /api/locations[/:id] |
{ project_id, name, lat?, lon? }: project_id is required to create and never changes (GET takes ?project_id=); names are unique per project. Without lat/lon the stored pin is kept, null for both removes it; shot_count, approved_count; clash → 409 { existing_id } (a binned location's name is free); DELETE bins it: its shots read as without a location until it is restored, uploads naming it drop it, edits naming it are 400 |
| GET (websocket) | /api/preview/socket?role=phone|studio |
Live layout preview (viewfinder layouts): one Durable Object room per user. The Studio sends { type: "preview", layout, orientation } / { type: "stop" } / { type: "readout" }; the phone { type: "hello", device: { id, model } }, { type: "exit" }, { type: "specs", id, specs } (relayed to dashboards, checked by validateDeviceSpecs); dashboards get { type: "presence", phones: [{ id, model }], previewing }. Without Upgrade: websocket: 426 |
| GET / PUT / DELETE | /api/viewfinder-layouts[/:id] |
{ name, layout, device_id? }, layout checked by validateViewfinderLayout (viewfinder layouts); device_id absent keeps it, null clears it, an unknown one or another user's device is 400; a user's own (user_id): lists, PUT and DELETE see only yours |
| GET / PUT / DELETE | /api/devices[/:id] |
Device specs (viewfinder layouts): PUT { name?, specs }, specs checked by validateDeviceSpecs; name required to create, absent keeps the stored one (the phone's updates); a user's own, keyed (user_id, id): PUT upserts the caller's own row (201 new, 200 updated); the same phone id under another account is that account's row and is never touched (#224). DELETE clears the caller's layouts' device_id for it; 404 when the caller has no such device |
| GET / PUT / DELETE | /api/spots[/:id] |
{ location_id, name, lat?, lon? }: a place within a location (projects); location_id required to create, never changes (GET takes ?project_id=); names unique per location (409 + existing_id); shot_count; delete nulls shots.spot_id |
| GET / PUT / DELETE | /api/fixtures[/:id] |
Lights (lights): PUT { label, kind_id, address, mesh_network_id?, mesh_unicast?, mesh_elements?, workspace_id? }, strict: kind_id from FIXTURE_KINDS, address like AA:BB:CC:DD:EE:FF, mesh fields only on a mesh kind and only with a network of the fixture's own workspace (another workspace's network answers 400 like a missing one), 409 when another fixture has that unicast address in the network. A new fixture goes to workspace_id (default the caller's own workspace); an existing one keeps its own. Lists hold the fixtures of all the caller's workspaces |
| GET / PUT / DELETE | /api/mesh-networks[/:id] |
Bluetooth Mesh networks the phone provisions: PUT { name, iv_index?, next_unicast?, workspace_id? } (the phone's counters, whole numbers). No keys. DELETE leaves its fixtures, with their mesh fields cleared |
| GET / PUT / DELETE | /api/presets[/:id] |
Rigs: name, camera_id, format_id, sensor_width_mm, sensor_height_mm, speedbooster_factor, lens_min_mm, lens_max_mm, vignetting ([{ mm, start, end, opacity }], at most 12, checked by validateVignetting, stored sorted; absent or [] = none; always an array in responses), plus workspace_id for a new rig (default the caller's own workspace; an existing rig keeps its own). Lists hold the rigs of all the caller's workspaces, each with its workspace_id. An upload naming a rig of another workspace stores the photo without it |
| GET / PUT / DELETE | /api/views[/:id] |
{ project_id, name, filter } (project_id required to create), filter validated by validateFilter |
| Method | Path | Notes |
|---|---|---|
| GET | /api/fields |
{ fields: [{ id, project_id, position, key, definition, … }] }; ?project_id= for one project |
| PUT | /api/fields/:id |
{ project_id, definition } (project_id required to create; new fields go last), validated by validateFieldDef; key clash within the project → 409 |
| POST | /api/fields/import |
{ project_id, fields: [def, …] }: new keys created in the project, its existing keys replaced; all validated first. Returns the project's list plus created/updated. |
| DELETE | /api/fields/:id |
To the recycle bin; stored values on shots stay. A binned field's key is free |
| Method | Path | Notes |
|---|---|---|
| GET | /api/days?project_id= |
Days ordered by date, each with shots: [{ shot_id, planned_time, notes }] in order |
| GET | /api/days/:id |
|
| PUT | /api/days/:id |
{ project_id, date, title?, notes?, shots: [...] } replaces the whole day. Shots not (or no longer) in the project are dropped silently. |
| DELETE | /api/days/:id |
To the recycle bin |
| Method | Path | Notes |
|---|---|---|
| GET | /api/files?project_id= |
{ folders, items, desktop }: the project's live folders (each with kind: normal, or desktop for the Desktop folder, created here on first use; a top-level folder already named "Desktop" becomes it), placements in them ({ resource_type, resource_id, folder_id }, none = root) and desktop items of both areas (taskbar ones by position), in one payload |
| PUT | /api/folders/:id |
{ project_id, name, parent_id? }: project_id required to create, never changes; parent_id absent keeps, null = root. 400 for a cycle, more than 8 levels, a parent in another project, or renaming/moving the Desktop folder; a name taken in the directory → 409 + existing_id; a binned folder → 409 |
| DELETE | /api/folders/:id |
Moves the folder to the recycle bin with its subfolders and the files placed in them ({ deleted, binned: true }); 400 for the Desktop folder |
| PUT | /api/folder-items/:type/:id |
{ folder_id } puts the resource in a folder of its own project (400 otherwise, 404 for an unknown resource); null moves it back to the root. :type from FILE_RESOURCE_TYPES |
| PUT | /api/desktop/:id |
{ project_id, kind, resource_type + resource_id | folder_id | app_id, label?, area? (desktopdefault |taskbar), position?, x, y }, checked by validateDesktopItem; target and area never change after create (409); a target already in that area → 409 + existing_id. Updates set label, x, y, position |
| DELETE | /api/desktop/:id |
Binning keeps a file's placement and shortcuts (they come back with it); purging deletes them. A shot moved to another project (single or bulk PATCH) lands at that project's root and keeps only shortcuts on the target project's desktop.
Every DELETE of a shot, timeline, day, view, location, field or folder only marks the row (deleted_at); every list and lookup leaves binned rows out. A folder takes its subtree along (deleted_with = that folder). Rows are purged for good when the bin is emptied, one at a time, or by the daily cron (scheduled in index.ts, 03:17 UTC) once they are TRASH_RETENTION_DAYS (30) old. The same cron snapshots storage for the admin board (monitoring).
| Method | Path | Notes |
|---|---|---|
| GET | /api/trash?project_id= |
{ entries: TrashEntry[] }, newest first: { type (a file type or folder), id, name, sub, deleted_at, folder_id (the live folder it comes back to, null = top level), photo_id? (a shot's cover), count? (a folder's files) }. Only what was binned on its own; a folder's contents are inside it |
| POST | /api/trash/:type/:id/restore |
Back where it was. A file whose folder is gone or binned comes back at the top level; a folder whose parent is gone or binned too. A taken name gets " (restored)" (a field key _restored). A folder binned along with another → 400. { restored, name? } |
| DELETE | /api/trash/:type/:id |
Purge one entry (a folder with everything binned with it); shots take their R2 images and renders |
| DELETE | /api/trash?project_id= |
Empty the project's bin → { purged } |
A binned shot comes back by itself when the phone uploads to it again (a continued or retried capture) or anything PATCHes it: a capture or an offline edit is never lost.
GET /health: D1 and R2 reachability plus the caller's email; needs a user.