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",
  "scheduled_date": null,
  "scheduled_end": null,
  "day_label": null,
  "sched_status": "draft",
  "sched_version": 0,
  "sched_published_at": null
}
FieldNotes
idUUID
nameUp to 200 characters; trimmed
workspace_idUUID
parent_board_idUUID, or null at the top level
view"canvas" or "list"
scheduled_dateYYYY-MM-DD, or null. Puts the board on a calendar
scheduled_endYYYY-MM-DD, or null. Inclusive end of a multi-day block
day_labelThe durable half of a scheduled day's name, e.g. "Day 12"
sched_status"draft", "published" or "cancelled" — read-only
sched_versionCall-sheet version, bumped on publish — read-only
sched_published_atISO timestamp, or null — read-only

Scheduling a board

scheduled_date, scheduled_end and day_label are writable on POST /boards and PATCH /boards/:id. They do not go straight to the column: like parent_board_id, they are routed through a server function, because moving a day is a thing other people need to hear about.

A board is draft until it is published in the app. Drafts are silent, so an import that creates sixty dated days notifies nobody. Once a day is published, changing its date notifies everyone who can read it. Pass "notify": false to move a published day quietly.

sched_status, sched_version and sched_published_at are read-only over the API — publishing is a deliberate act with an audience, not a field write. See Production schedules.

PATCH /boards/9f1c…
{ "scheduled_date": "2026-09-14", "day_label": "Day 12" }

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=.

Restoring a board that is already live is a no-op, so a retry is safe.

This call can be refused. A deleted cluster stops counting against the card limit the moment it is deleted, so the room it held is immediately usable. The other side of that is that a restore has to fit: if the space has been used since, putting the cluster back would take the account over its limit, and the call answers 402 limit_reached with a message naming the number it would reach. Free some room or upgrade, then retry. Nothing is lost in the meantime — the 30-day trash window is the only clock running.

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?

Usually. DELETE is a soft delete and POST /boards/:id/restore puts it back, within the 30-day trash window. One case is refused: a deleted cluster stops counting against the card limit, so if the free space has been used since, restoring would take the account over its cap and the call answers 402 limit_reached saying by how much.

Machine-readable: /docs/api/boards.md · /llms.txt
What changed and when: /changelog