Images API

POST raw image bytes to /uploads with a board id and you get back an image key, which you then pass as image_key when creating a card. It is one request rather than a presign dance. Files larger than the one-request ceiling go through /uploads/multipart, where you PUT the parts straight to storage and the bytes never pass through the API. Either way the upload is charged against the board owner's storage quota.

Uploading is one request. No presign, no multi-step S3 dance — which is what makes it usable from a single tool call.

POST /uploads?board=<uuid>

Raw bytes in the body, Content-Type set to the image's real type.

curl -X POST "$SOLEIL_API/uploads?board=$BOARD&filename=diner_ext_dusk_04.jpg" \
  -H "Authorization: Bearer $SOLEIL_TOKEN" \
  -H "Content-Type: image/jpeg" \
  --data-binary @diner_ext_dusk_04.jpg
{
  "image_key": "3b7e…/9f1c….jpg",
  "width": 3024,
  "height": 4032,
  "bytes": 2841923,
  "content_type": "image/jpeg",
  "file_name": "diner_ext_dusk_04.jpg",
  "next": "POST /api/v1/boards/…/cards with {\"kind\":\"image\",\"image_key\":\"…\",\"file_name\":\"diner_ext_dusk_04.jpg\"}"
}

The response spells out the next call, because it is not guessable from the key alone.

filename is optional. Pass it and the file keeps its own name — the stored file carries it, and so does the card when you send file_name on it, which is what the Files view shows and what Download names the file. A path is cut to the basename, and a name a browser invents for a paste (image.png) is not kept, the same rule the app applies to a drop.

The ?board= parameter is required: an upload is charged to a board, and write access to that board is checked before anything is stored.

Then place it

curl -X POST "$SOLEIL_API/boards/$BOARD/cards" \
  -H "Authorization: Bearer $SOLEIL_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"kind":"image","image_key":"3b7e…/9f1c….jpg","file_name":"diner_ext_dusk_04.jpg","alt":"Diner counter, night"}'

Pass alt for a description and file_name to keep the file's name on the card. Omit x/y and the card is placed in free space.

Limits

TypesJPEG, PNG, GIF, WebP, HEIC, AVIF
Size25 MB per image through the API
StorageCounted against the board owner's quota

A missing or unrecognised Content-Type gets 415 — the file extension is derived from the declared type, so it has to be right.

An empty body gets 400. Over the size ceiling gets 413. Past the owner's storage quota gets 402. See Errors.

25 MB is the ceiling on this one-request form, because the whole body is held in memory to read its header. Larger files go through the multipart endpoints below, which have no such limit.

Large files

POST /uploads is the convenient path. For anything bigger than 25 MB — camera media, ProRes, a scan of a whole lookbook — use multipart, where the bytes never pass through the API at all. You get signed URLs and PUT directly to storage, in parallel, at whatever speed your connection allows.

Four calls, and only the first and last are API requests.

1. Start

curl -X POST "$SOLEIL_API/uploads/multipart?board=$BOARD" \
  -H "Authorization: Bearer $SOLEIL_TOKEN" -H "Content-Type: application/json" \
  -d '{"bytes":53687091200,"content_type":"video/quicktime","filename":"reel_01.mov"}'
{ "key": "3b7e…/9f1c….mov", "upload_id": "2~abc…", "part_size": 8388608, "part_count": 6400 }

bytes is required: the storage quota is checked against the total size up front, so a file that will not fit is refused before you send any of it.

2. Get signed URLs

curl -X POST "$SOLEIL_API/uploads/multipart/parts" \
  -H "Authorization: Bearer $SOLEIL_TOKEN" -H "Content-Type: application/json" \
  -d '{"board_id":"'$BOARD'","key":"…","upload_id":"…","part_numbers":[1,2,3]}'

Up to 1000 part numbers per call. Ask for them in batches as you go — signed URLs are time-limited, so requesting 6,400 at once is worse than requesting them as you need them.

3. PUT each part to the returned URL

Not to this API. Each response carries an ETag; keep it with its part number. Parts may be uploaded in any order and in parallel.

Upload many parts at once. Nothing sits between you and storage on this path, so your throughput is your own connection — but only if you keep it busy. Each part is a separate HTTPS request, and a few in flight spends most of its time in handshakes and TCP ramp-up rather than sending bytes. Measured over one 324 Mbit link, same code, varying only how many parts were in flight:

in flightthroughputshare of the link
418.5 MB/s46%
1531.8 MB/s78%
3036.9 MB/s91%

Around 30 concurrent parts saturates a link; the remainder is protocol overhead. Uploading several files at once counts the same way — it is total requests in flight that matters, not how they are grouped.

4. Finish

curl -X POST "$SOLEIL_API/uploads/multipart/complete" \
  -H "Authorization: Bearer $SOLEIL_TOKEN" -H "Content-Type: application/json" \
  -d '{"board_id":"'$BOARD'","key":"…","upload_id":"…",
       "parts":[{"part_number":1,"etag":"\"a1b2…\""}]}'

Returns the image_key, which you place as a card exactly as above. Dimensions come back for formats that carry them in a header; other files report null. Send filename here as well to keep the file's name — the first call only uses it for the extension.

POST /uploads/multipart/abort with the same key and upload_id discards an upload you have given up on, so the parts are not billed as storage.

Multipart needs a paid account on the workspace that owns the board — the same rule the app applies. Without one the first call returns 403.

Every step re-checks write access to the board. A signed part URL is a capability, so nothing relies on a check made three calls earlier.

GET /images

Lists what is already stored, so an interrupted bulk upload can be resumed without re-sending everything.

curl "$SOLEIL_API/images?workspace=$WS&limit=500" -H "Authorization: Bearer $SOLEIL_TOKEN"
{
  "images": [
    { "image_key": "3b7e…/9f1c….jpg", "bytes": 2841923, "width": 3024, "height": 4032,
      "board_id": "…", "workspace_id": "…", "created_at": "2026-08-08T12:00:00Z",
      "file_name": "diner_ext_dusk_04.jpg" }
  ],
  "limit": 500,
  "has_more": true,
  "next_cursor": "2026-08-08T12:00:00Z|9f1c…"
}

Filter with workspace, board and since (an ISO timestamp).

Paging is by cursor, not offset: pass next_cursor back as cursor. Offset paging makes the database walk and discard every row it skips, so it gets slower the further in you go — which only bites once a listing is long, which is exactly when you need it.

GET /images/:key

Reads an image back. Access is authorized the same way as everything else — you get the image if your account can see a board that references it. When the file kept its own name, the response carries it in Content-Disposition, so a client that saves the bytes saves them under that name. file_name is null for anything uploaded before names were kept.

Smaller renditions

Add ?variant=preview for the downscaled copy the app stores when an image is uploaded, usually a small fraction of the original's bytes. It is more than enough to look at and far cheaper to move, which matters if you are reading a whole moodboard or handing images to a model.

Not every image has one, so this falls back to the original rather than failing. The X-Image-Variant response header tells you which you got:

curl -sD- -o shot.webp \
  "https://clusters.soleilpictures.com/api/v1/images/$KEY?variant=preview" \
  -H "Authorization: Bearer $TOKEN" | grep -i x-image-variant
# x-image-variant: preview

The MCP server's view_board shows pictures at this size when it is asked to look closely at a few.

A board's pictures in the order a person reads it, top to bottom and left to right: image cards, and videos with a poster frame. It is how an agent looks at a board without fetching every picture one by one, and what the MCP server's view_board reads.

curl "https://clusters.soleilpictures.com/api/v1/boards/$BOARD/gallery?inline=1" \
  -H "Authorization: Bearer $TOKEN"
{
  "board": { "id": "…", "name": "Diner — night", "url": "https://clusters.soleilpictures.com/?to=b:…",
             "card_count": 42, "picture_count": 25,
             "thumbnail": { "updated_at": "…", "data": "UklGR…", "mime": "image/webp", "bytes": 15800 } },
  "size": "sm",
  "pictures": [
    { "card_id": "c-81", "kind": "image", "position": 1, "title": "Neon sign", "caption": null,
      "image_key": "…", "width": 4032, "height": 3024,
      "rendition": { "variant": "sm", "width": 640, "height": 480 },
      "url": "https://clusters.soleilpictures.com/?to=c:…:c-81",
      "data": "UklGR…", "mime": "image/webp", "bytes": 31200 }
  ],
  "has_more": true,
  "next_cursor": "p12"
}
Parameter
limitPictures per page: 12 by default, at most 24
cursornext_cursor from the previous page
inline1 embeds each picture as base64 in data
sizesm, the smaller rendition (the default), or preview, the larger one
card_idsComma-separated, at most 24: these pictures instead of a page

thumbnail is the board drawn whole, as the app last stored it, on the first page only and null when the board has none. A video's picture is its poster frame, marked "poster": true. With card_ids the pictures come in the order named, position is null and there is no next page. Each picture names the rendition it would show: the smaller one asked for, the other one when it is missing, and the original only when it is at most 400 KB.

With inline=1, pictures are embedded in reading order while they fit: at most 400 KB each (1 MB with size=preview) and 1.5 MB for the whole answer. One that does not fit carries a reason instead of data (too_large, budget, no_rendition, not_found, missing, storage_unavailable). Ask for it by card_ids, or for fewer at a time. Embedding pictures is a read of their bytes, so it is written to the audit log like GET /images/:key.

Why the images row matters

An upload does two things: it stores the object, and it records a row that authorizes reads and marks the object as in use.

If the row cannot be written the upload fails and the object is deleted, rather than returning a key that would never resolve and would be swept away later. An upload that returns 201 is an upload that is fully durable.

Storage lifecycle

Files referenced by a board are protected from cleanup for as long as the board references them. Removing the last card that uses an image makes it eligible for reclamation later, not immediately — so deleting a card and restoring it from the delete response works.

Frequently asked questions

How do I add a photo to a board from code?

Two requests. POST the bytes to /uploads?board=<id> to get an image_key, then POST a card with kind image and that key.

What image formats are accepted?

JPEG, PNG, GIF, WebP, HEIC and AVIF for the one-request upload. Multipart accepts any file type, including video, audio and PDFs.

How do I upload a very large file?

Use POST /uploads/multipart. You declare the total size, get signed URLs for each part, PUT the parts directly to storage in parallel, then call complete. There is no size ceiling on this path.

How do I resume an upload run that failed halfway?

GET /images lists what is already stored, with cursor paging. Diff it against your local manifest and upload only what is missing.

Whose storage does an upload use?

The board owner's, exactly as in the app. Going past it returns 402.

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