Uploading Assets to Playbook
You can add assets to Playbook either by pointing at a public URL or by using a two-step signed-upload flow.
Prerequisites
- Access token: an API token with asset-upload permission, sent as a Bearer token.
- Organization Slug (
slug): Your org's identifier (e.g.,coolclient-ltd). - Board Token (
board_token): (Optional) Where to place the asset. If omitted, asset goes to a default location (Uploaded todayboard). You can fetch board tokens using the boards endpoint.
Direct Upload from Public URL
Endpoint
POST /v1/{slug}/assets
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| uri | string | yes | Publicly accessible URL to fetch the file. |
| filename | string | yes | Desired filename in Playbook (e.g., image.jpg). |
| title | string | no | Display name for the asset. |
| description | string | no | Optional description or notes. |
| board_token | string | no | Token of the target board. |
| as_link | boolean | no | If true, asset is stored as an external link without processing. |
Sample Request
curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"asset": {
"uri": "https://example.com/photo.jpg",
"title": "User Photo",
"collection_token": "homepage-assets"
}
}'
Sample Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"id": 101,
"token": "assetToken123",
"display_url": "https://cdn.playbook.com/photo.jpg",
"media_type": "image/jpeg",
"collection_token": "boardToken123",
"is_skeleton": true,
"is_link": false,
"source_error": null,
...
}
}
URL ingest is asynchronous. The response returns immediately with
is_skeleton: true. Playbook fetches the bytes in the background. See Async Ingest Semantics below for how to detect completion.
Async Ingest Semantics
Both POST /v1/{slug}/assets (with a uri) and POST /v1/{slug}/assets/batch_create_from_urls enqueue a background worker that fetches the bytes from the supplied URL. The HTTP response returns immediately with a placeholder asset (is_skeleton: true). To know when the upload has finished, poll GET /v1/{slug}/assets/{asset_token} and inspect the following fields:
| Field | Type | Meaning |
|---|---|---|
is_skeleton | boolean | true while the worker is still fetching. Becomes false when the worker is finished. |
media_type | string | Populated on success (e.g., image/jpeg). Check source_error first — see below. |
source_error | string | The latest error message from the URL-download worker. null on success. |
is_link | boolean | true when the asset was stored as a bare link (as_link: true, or the URL was a page). |
Terminal states
The ingest is over once is_skeleton: false. Read the outcome in this order — a populated media_type is never on its own proof of success:
- Failed —
source_erroris non-null. The message describes why the worker could not fetch or store the bytes: a non-2xx response from the source, an unreachable host, a file too large to fetch in one pass, or a URL that answered with an error or sign-in page instead of the file. - Stored as link —
is_link: true. This happens whenas_link: truewas passed, or when the URL resolved to a web page rather than a file. The asset exists as a bare link with no fetched bytes. - Success — neither of the above.
media_type,sizeand the thumbnails describe the stored file.
Expired source URLs
Signed URLs from image and video generators are usually valid for minutes or hours. If one expires before Playbook fetches it, the source answers with an HTTP error and the asset ends in the Failed state with a source_error naming the status code. Retrying the same URL will not succeed — generate a fresh one and submit that instead.
When you cannot generate a fresh one, stop retrying this endpoint: fetch the bytes yourself and use the two-step flow. The same applies to any source Playbook's servers cannot reach anonymously — a link behind a login, or a host that blocks datacenter addresses. "It opens in my browser" is not evidence that Playbook can fetch it.
Playbook never stores an error response as the asset's content. A non-2xx response is rejected before any bytes are written, and a 200 whose body is an HTML or XML document where a media file was expected is rejected after sniffing.
Recommended polling cadence
Most uploads finish within a few seconds. Polling every 1–2 seconds with exponential backoff up to ~60 seconds is sufficient. If is_skeleton is still true after 60 seconds, treat it as a worker delay rather than a failure and continue polling at a slower cadence.
Batch Upload from Public URLs
Use this endpoint to ingest up to 100 public URLs in a single request. All assets land in the same board, the entire batch is wrapped in a database transaction (so a single bad asset rolls back the whole batch), and one UPLOAD_ASSETS event is emitted for the batch.
Endpoint
POST /v1/{slug}/assets/batch_create_from_urls
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Requires the write scope.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
batch.collection_token | string | no | Token of the destination board, applied to every asset in the batch. |
batch.collection_id | integer | no | Numeric ID alternative to collection_token. |
batch.assets | array | yes | 1 to 100 asset specs (see below). |
Each item in batch.assets:
| Field | Type | Required | Description |
|---|---|---|---|
uri | string | yes | Public URL to ingest. |
uuid | string | no | Client-supplied correlation id, returned untouched on the matching response row. |
title | string | no | Override the title (defaults to filename derived from URL). |
description | string | no | Optional description. |
as_link | boolean | no | If true, store as a bare link instead of fetching bytes. |
tags | array | no | Manual tags to apply on creation. |
status | string | no | Status label to set on creation. |
Sample Request
curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/batch_create_from_urls \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"batch": {
"collection_token": "homepage-assets",
"assets": [
{ "uuid": "client-1", "uri": "https://example.com/a.jpg", "title": "Hero" },
{ "uuid": "client-2", "uri": "https://example.com/b.png", "as_link": true }
]
}
}'
Sample Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": [
{
"uuid": "client-1",
"asset": {
"token": "asset-tok-1",
"title": "Hero",
"is_skeleton": true,
"is_link": false,
"source_error": null,
...
}
},
{
"uuid": "client-2",
"asset": {
"token": "asset-tok-2",
"is_skeleton": false,
"is_link": true,
"source_error": null,
...
}
}
]
}
The response is an array of { uuid, asset } rows. Each asset starts as a skeleton and reaches its terminal state asynchronously — see Async Ingest Semantics above. Poll GET /v1/{slug}/assets/{asset_token} for each asset.
Limits and validation
- Maximum 100 assets per request — exceeding this returns
422. - Every asset must include a
uri— missing or blank returns406. - The org's total-asset limit is enforced — exceeding it returns
422. - All-or-nothing: if any asset fails to be created, the entire batch is rolled back.
Two-Step Upload Flow (Prepare & Complete)
Use this flow when you want to upload large files or have more control over the upload process.
Step 1: Request Upload Credentials
Endpoint
POST /v1/{slug}/assets/upload_prepare
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| title | string | yes | File name or display label. |
| media_type | string | yes | MIME type (e.g., video/mp4). |
| size | integer | yes | File byte size. |
Sample Request
curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/upload_prepare \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"asset": {
"title": "Vacation Video",
"media_type": "video/mp4",
"size": 52428800
}
}'
Sample Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"upload_url": "https://storage.googleapis.com/playbook-uploads/...",
"signed_gcs_id": "abc123def456",
"file_extension": "mp4"
}
}
Step 2: Upload the File to Storage
How you send the bytes depends on storage_provider in the prepare response. The headers are
covered by the signature: send them exactly, and omit any whose value came back blank — an added,
renamed or empty one invalidates the signature and the upload fails with an opaque error. Never
attach your Authorization header here. The signed URL is itself the credential, and sending your
Playbook token would hand it to the storage provider.
storage_provider: "gcs" — two requests
A POST opens a resumable session and answers with a Location header; the bytes then go to that
Location. Note that the byte PUT carries Content-Type: text/plain, not the file's own media
type — that is what the session signature expects.
# 1. Open the session. No body. Replay file_extension and
# encrypted_organization_metadata from the prepare response.
curl -i -X POST "$UPLOAD_URL" \
-H "x-goog-resumable: start" \
-H "Content-Type: video/mp4" \
-H "x-goog-meta-extension: .mp4" \
-H "x-goog-meta-encrypted-organization-metadata: $ENCRYPTED_ORG_METADATA"
# 2. Send the file to the Location the previous response returned.
curl -X PUT "$SESSION_URL" \
-H "Content-Type: text/plain" \
-H "Content-Range: bytes 0-52428799/52428800" \
--data-binary @/path/to/Vacation.mp4
A zero-length file still needs a valid range: send Content-Range: bytes */0.
storage_provider: "backblaze" — one request
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: video/mp4" \
-H "x-amz-meta-extension: .mp4" \
-H "x-amz-meta-encrypted-organization-metadata: $ENCRYPTED_ORG_METADATA" \
--data-binary @/path/to/Vacation.mp4
Above the multipart threshold Backblaze returns no upload_url at all — the response carries a
parts array and a multipart_upload_id instead, and each part is uploaded to its own URL.
Step 3: Complete the Upload
Endpoint
POST /v1/{slug}/assets/upload_complete
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| signed_gcs_id | string | yes | ID returned from the prepare step. |
| title | string | no | (Optional) override title. |
| description | string | no | (Optional) asset description. |
| media_type | string | yes | Same MIME type as the prepare call. |
| size | integer | yes | Byte size (same as prepare). |
| collection_token | string | no | Target board token. An unknown token is not an error: the asset lands in the day's automatic "Uploaded" board. |
Sample Request
curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/upload_complete \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"asset": {
"signed_gcs_id": "abc123def456",
"title": "Vacation Video",
"media_type": "video/mp4",
"size": 52428800,
"collection_token": "video-collection"
}
}'
Sample Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"token": "vacation-video-mp4",
"display_url": "https://cdn.playbook.com/vacation-video.mp4",
"media_type": "video/mp4"
}
}
Batch Two-Step Upload (Prepare & Complete)
For ingesting many large files in one round, the two-step flow above has a batch counterpart. It mirrors the single-asset flow exactly — batch_upload_prepare returns one upload_url per asset and a shared batch_id, then you PUT each file to its URL, then call batch_upload_complete once with all the signed_gcs_id values to materialize the asset records.
Unlike batch_create_from_urls (which is all-or-nothing), batch_upload_complete creates assets individually — a single failure does not roll back already-created assets in the same batch.
Step 1 — batch_upload_prepare
POST /v1/{slug}/assets/batch_upload_prepare
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Request:
| Field | Type | Required | Description |
|---|---|---|---|
batch.assets | array | yes | 1 to 100 asset specs (see below). |
Each item in batch.assets:
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Filename of the asset. |
size | integer | yes | File size in bytes. |
uuid | string | yes | Client-generated correlation id, returned untouched in the response. |
media_type | string | no | MIME type (inferred from title extension if omitted). |
Sample request:
curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/batch_upload_prepare \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"batch": {
"assets": [
{ "uuid": "client-1", "title": "photo.jpg", "size": 1024000, "media_type": "image/jpeg" },
{ "uuid": "client-2", "title": "doc.pdf", "size": 5242880, "media_type": "application/pdf" }
]
}
}'
Sample response:
{
"data": {
"batch_id": "uuid-of-batch",
"assets": [
{
"uuid": "client-1",
"upload_url": "https://storage.googleapis.com/...",
"signed_gcs_id": "abc...",
"file_extension": "jpg",
"storage_provider": "gcs"
},
{
"uuid": "client-2",
"upload_url": "https://storage.googleapis.com/...",
"signed_gcs_id": "def...",
"file_extension": "pdf",
"storage_provider": "gcs"
}
]
}
}
Step 2 — Upload each file
PUT each file to its upload_url, the same as in the single-asset two-step flow. Run uploads in parallel for throughput.
Step 3 — batch_upload_complete
POST /v1/{slug}/assets/batch_upload_complete
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
Request:
| Field | Type | Required | Description |
|---|---|---|---|
batch.batch_id | string | no | Value from batch_upload_prepare. Recommended — releases the upload reservation. |
batch.collection_token | string | no | Destination board for every asset in the batch. |
batch.collection_id | integer | no | Numeric alternative to collection_token. |
batch.assets | array | yes | 1 to 100 completion specs (see below). |
Each item in batch.assets:
| Field | Type | Required | Description |
|---|---|---|---|
uuid | string | yes | Same client uuid you sent in batch_upload_prepare. |
signed_gcs_id | string | yes | Exact value returned in the matching prepare response row. |
title | string | yes | Display title for the asset. |
description | string | no | Optional description. |
media_type | string | no | MIME type — same value used in prepare. |
width / height | integer | no | Image dimensions, when known client-side. |
multipart_upload_id | string | no | Required only for Backblaze multipart uploads. |
Sample request:
curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/batch_upload_complete \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"batch": {
"batch_id": "uuid-of-batch",
"collection_token": "homepage-assets",
"assets": [
{ "uuid": "client-1", "signed_gcs_id": "abc...", "title": "photo.jpg",
"media_type": "image/jpeg", "width": 1920, "height": 1080 },
{ "uuid": "client-2", "signed_gcs_id": "def...", "title": "doc.pdf",
"media_type": "application/pdf" }
]
}
}'
Sample response:
{
"data": [
{ "uuid": "client-1", "asset": { "token": "asset-tok-1", "media_type": "image/jpeg", ... } },
{ "uuid": "client-2", "asset": { "token": "asset-tok-2", "media_type": "application/pdf", ... } }
]
}
When to choose which flow
| Use case | Endpoint |
|---|---|
| Asset bytes already live at a public URL | batch_create_from_urls |
| Files live on the client (browser, server, CI) | batch_upload_prepare + batch_upload_complete |
| One file at a time | assets (URL) or upload_prepare + upload_complete (signed) |
| No fetchable URL and no way to make HTTP requests — the file is on a person's machine | share with enable_uploads |
Two things decide between the first two rows, and neither is file size. Playbook fetches a URL
itself, from its own servers, anonymously — so a link behind a login, an expired signed
URL, and a host that blocks datacenter addresses all fail no matter how small the file. And
the fetch runs to a five-minute streaming budget, so a very large or very slow source can fail
with Source URL too large to fetch; upload manually. In any of those cases, send the bytes
yourself with the two-step flow.
The last row is the only route that does not move bytes through your own code: you hand the returned link to a person, they drop the files on it without signing in, and you poll the board for what arrives. It needs a live human and the same link shows the board's existing contents to whoever opens it, so use it once the others are impossible — not before.
All four upload endpoints require a token with the write scope.
The batch completion is not atomic. batch_upload_complete creates its assets one at a
time and does not roll back, so a failure partway through leaves the earlier ones created while
the call reports an error — and those are incomplete, because the post-upload processing that
gives an asset its checksum, content check and previews runs only after the whole batch
commits. Do not retry the call: primary_gcs_id has no uniqueness constraint, so a retry
creates duplicates. Delete what it created, then complete each file individually with
upload_complete, reusing the same signed_gcs_id values. The bytes are still in storage.
Playbook's MCP server exposes this two-step flow as create_upload_url / finish_upload (and
create_upload_urls / finish_uploads for batches), so an agent holding file bytes can use it
without a Playbook API token for the transfer itself — the signed upload address carries its own
authority. See the Playbook MCP server documentation for the tool contract.
Agent Context: ai_agent_payload and ai_generated
Every upload endpoint accepts two optional fields, per asset. They exist for AI agents writing into Playbook, and Playbook never interprets either of them.
ai_agent_payload is free-form JSON — whatever you need to recognise the asset in a later session: the prompt that produced it, the model, the seed, the id of the job it came from, a link back to the source. An agent that generates forty variants and comes back tomorrow can otherwise only tell them apart by filename.
ai_generated is a boolean marking the asset as produced by an AI tool. On the two-step (upload_complete / batch_upload_complete) paths it also switches off automatic duplicate merging and auto-grouping for that asset, which is what you want for a set of near-identical variants.
{
"batch": {
"collection_token": "BOARD_TOKEN",
"assets": [
{
"uuid": "v-9x16",
"uri": "https://generator.example.com/out/9x16.png?sig=...",
"ai_generated": true,
"ai_agent_payload": {
"prompt": "product on seamless white, vertical crop",
"model": "flux-1.1-pro",
"seed": 8412,
"job": "gen_01J8XY"
}
}
]
}
}
Five rules worth knowing before you build on it:
- Send it with the create, not afterwards. Both fields are accepted on
POST /assets,batch_create_from_urls,upload_completeandbatch_upload_complete, as well as onPATCH /assets/{token}. Sending them up front means the context cannot be orphaned if your agent stops between the two calls — andai_generatedonly suppresses duplicate merging when it arrives with the create, because that runs as the upload finishes. - A write replaces the whole payload. It is not merged. Read the asset first if you mean to keep existing keys. This is deliberate: without it nothing would ever be removed and the object would only grow.
- 4096 bytes, measured on the serialised JSON. Bytes, not characters — 1000 characters of Cyrillic or CJK is roughly 4000 bytes. Over the limit is rejected with a 422, never silently truncated.
- It must be a JSON object, not an array or a bare string.
- You can read it back, but you cannot search it.
GET /assets/{token}always returns it. Listing and search endpoints omit it unless you passget_ai_payload=true, and when omitted the key is absent rather thannull, so "I didn't ask" stays distinguishable from "nothing is stored". There is no way to filter or query by payload contents — do not design a lookup around it.
curl -s "https://api.playbook.com/v1/{slug}/assets?collection_token=BOARD_TOKEN&get_ai_payload=true" \
-H "Authorization: Bearer $PLAYBOOK_API_TOKEN"
Use uuid on batch requests whenever the items differ from one another: it is echoed back beside the asset each one produced, and it is the only reliable way to attach the right payload to the right variant.
Error Handling
400 Bad Request: Missing or malformed JSON fields.401 Unauthorized: Invalid or missing token.403 Forbidden: Token lacks thewritescope, or the user lacksupdatepermission on the target board.404 Not Found(batch complete): One or moresigned_gcs_idvalues point to objects not present in storage — re-upload before retrying.406 Not Acceptable: A required field is missing (e.g., a batch asset withouturi).422 Unprocessable Entity: File size/type mismatch, expired upload URL, batch size out of bounds (must be 1–100), workspace asset limit exceeded, or anai_agent_payloadover 4096 bytes or not a JSON object.
Tips
- For very large files, monitor your upload progress and retry on failures.
- Clean up or retry failed
signed_gcs_ids by re-calling the prepare step. - Track asset
tokenfor later operations like update or delete.