Boards API

Boards are what the app calls clusters. List them with GET /boards, filtered by workspace or parent and paginated, create with POST /boards, read one with GET /boards/:id, rename or reparent with PATCH, soft-delete with DELETE and put it back with POST to the restore endpoint. Reparenting is cycle-safe and refused rather than allowed to create a loop.

A board is what the interface calls a cluster. Same object, two words.

The board object

{
  "id": "9f1c…",
  "name": "Scene 4 — Diner",
  "workspace_id": "3b7e…",
  "parent_board_id": null,
  "view": "canvas",
  "created_at": "2026-08-01T09:12:44.000Z",
  "updated_at": "2026-08-03T14:02:10.000Z"
}
FieldNotes
idUUID
nameUp to 200 characters; trimmed
workspace_idUUID
parent_board_idUUID, or null at the top level
view"canvas" or "list"

GET /workspaces

{ "workspaces": [ { "id": "…", "name": "Personal", "created_at": "…" } ] }

GET /boards

QueryEffect
workspace=<uuid>Only boards in that workspace
parent=<uuid>Only children of that board
parent=rootOnly top-level boards
deleted=Include soft-deleted boards
since=<ISO>Only boards changed at or after that time — see below
cursor=Continue a since walk
include=props, identifiers — see Identifiers and properties
limit / offsetPage size (default 100, max 500) and start
curl "$SOLEIL_API/boards?parent=root&limit=50" -H "Authorization: Bearer $SOLEIL_TOKEN"
{ "boards": [ … ], "limit": 50, "offset": 0, "has_more": true, "next_offset": 50 }

Follow next_offset until it comes back null.

Asking what changed

Pass since and the listing becomes a change feed: ordered by updated_at rather than created_at, and paged by cursor rather than offset.

curl "$SOLEIL_API/boards?workspace=$WS&since=2026-08-09T00:00:00Z" \
  -H "Authorization: Bearer $SOLEIL_TOKEN"
{ "boards": [ … ], "limit": 100, "has_more": true,
  "next_cursor": "2026-08-09T12:00:00Z|3b7e…" }

Pass next_cursor back as cursor until has_more is false, then keep the last updated_at you saw as the since for your next run.

Offset paging is wrong for this and cursor paging is right, for a specific reason: rows are being written while you walk, so offsets shift under you and a page boundary both skips and repeats. The cursor carries a board id alongside the timestamp because two boards touched in the same transaction share a timestamp exactly, and a timestamp alone would drop whichever came second.

GET /boards/tree

The hierarchy in one call, rather than one call per level.

curl "$SOLEIL_API/boards/tree?root=$BOARD&depth=6" -H "Authorization: Bearer $SOLEIL_TOKEN"
{ "root": "3b7e…", "count": 214,
  "boards": [
    { "id": "3b7e…", "parent_board_id": null, "name": "THE FALL",
      "depth": 0, "card_count": 0, "updated_at": "…", "deleted": false },
    { "id": "9f1c…", "parent_board_id": "3b7e…", "name": "Costume",
      "depth": 1, "card_count": 42, "updated_at": "…", "deleted": false }
  ] }

Pass root (a board id) or workspace, and optionally depth (default 10, maximum 20). Results are flat with a depth and a parent_board_id, so you can rebuild the tree in whatever shape you need without the response nesting for you.

A show's structure — title, department, sequence, shot — is the first thing any integration walks, and ?parent= costs a request per node.

POST /boards

{
  "name": "Scene 4 — Diner",
  "workspace_id": "optional uuid",
  "parent_board_id": "optional uuid",
  "view": "canvas"
}

Only name is required.

  • No workspace_id → your personal workspace, created if needed.
  • No parent_board_id → top level.
  • view is "canvas" unless you pass exactly "list".

Returns 201 with { "board": … }.

Creating many at once

Pass a boards array instead, up to 500 per call:

{
  "workspace_id": "optional uuid — the default for every entry",
  "boards": [
    { "name": "Scene 4 — Diner", "parent_board_id": "…" },
    { "name": "Scene 5 — Motel",  "parent_board_id": "…" }
  ]
}

Returns 201 with { "boards": [ … ], "created": 2 }.

This matters when you are importing an existing library, because a large one is a tree — a board per scene, reel or shoot — so the first thing an import does is create thousands of boards. One request each is the slowest possible way to do that; this is two inserts however many you pass.

Every entry is validated before anything is written, so a bad entry at index 900 is a clean 400 rather than 900 boards and an error. The whole batch is one insert as you, so a workspace you cannot write refuses the batch rather than half-applying it.

Creating the same boards twice

Give each board an identifiers array and pass "on_conflict": "identifier", and a board already carrying one of those identifiers is updated instead of created again — with its id unchanged:

{
  "on_conflict": "identifier",
  "boards": [
    { "name": "SEQ 0100",
      "identifiers": [{ "scope": "shotgrid", "value": "Sequence:88" }] }
  ]
}

The response adds updated, and each board carries created: true|false. This is what makes an import re-runnable — see Identifiers and properties.

GET /boards/:id

{ "board": … }, or 404 — which is also what you get for a board that exists but is not yours. The API never confirms the existence of something you cannot see.

PATCH /boards/:id

Every field optional:

{ "name": "Scene 4 — Diner (rev)", "view": "list", "parent_board_id": "…" }

Reparenting is cycle-safe: moving a board into one of its own descendants is refused with 409 conflict, not allowed to create a loop. parent_board_id: null moves it to the top level.

DELETE /boards/:id

A soft delete, requiring the delete scope. The board goes to the trash and stays restorable for 30 days.

{ "deleted": true, "board": { … }, "restorable": true }

Descendants are deliberately not touched — deleting a parent does not cascade.

POST /boards/move — restructure in one call

{ "board_ids": ["3b7e…", "9f1c…"], "parent_board_id": "a2c4…" }

Pass "parent_board_id": null to move boards to the top level. Up to 500 at a time.

Reparenting always goes through the app's own move path — the only thing that checks for cycles, because a board made its own ancestor detaches that whole subtree from every view there is. Anything it refuses comes back in skipped with a named reason (cycle, cross-workspace, no-write, same-parent, missing, self) rather than as one opaque failure for the batch.

DELETE /boards — many at once

{ "board_ids": ["3b7e…", "9f1c…"] }

Soft, and restorable one at a time, exactly like the single delete. Sent as a JSON body on DELETE so a destructive call stays a DELETE.

POST /boards/:id/restore

Puts a soft-deleted board back. Find deleted boards with GET /boards?deleted=.

Worked example

# A project with two scenes under it
PROJECT=$(curl -s -X POST "$SOLEIL_API/boards" \
  -H "Authorization: Bearer $SOLEIL_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name":"Feature — Act One"}' | jq -r .board.id)

for scene in "Scene 3 — Motel" "Scene 4 — Diner"; do
  curl -s -X POST "$SOLEIL_API/boards" \
    -H "Authorization: Bearer $SOLEIL_TOKEN" -H "Content-Type: application/json" \
    -H "Idempotency-Key: $(uuidgen)" \
    -d "{\"name\":\"$scene\",\"parent_board_id\":\"$PROJECT\"}"
done

curl -s "$SOLEIL_API/boards?parent=$PROJECT" -H "Authorization: Bearer $SOLEIL_TOKEN"

Next: Cards API · Images · Search.

Frequently asked questions

What is the difference between a board and a cluster?

Nothing. The interface says cluster, the API says board. Same object.

How do I create a board at the top level?

Omit parent_board_id. Omit workspace_id too and it goes in your personal workspace, which is created if it does not exist.

Can I undo a delete?

Yes. DELETE is a soft delete and POST /boards/:id/restore puts it back.

Machine-readable: /docs/api/boards.md · /llms.txt