Skip to main content

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

  1. Access token: an API token with asset-upload permission, sent as a Bearer token.
  2. Organization Slug (slug): Your org's identifier (e.g., coolclient-ltd).
  3. Board Token (board_token): (Optional) Where to place the asset. If omitted, asset goes to a default location (Uploaded today board). 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

FieldTypeRequiredDescription
uristringyesPublicly accessible URL to fetch the file.
filenamestringyesDesired filename in Playbook (e.g., image.jpg).
titlestringnoDisplay name for the asset.
descriptionstringnoOptional description or notes.
board_tokenstringnoToken of the target board.
as_linkbooleannoIf 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:

FieldTypeMeaning
is_skeletonbooleantrue while the worker is still fetching. Becomes false when the worker is finished.
media_typestringPopulated on success (e.g., image/jpeg). Check source_error first — see below.
source_errorstringThe latest error message from the URL-download worker. null on success.
is_linkbooleantrue 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:

  1. Failedsource_error is 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.
  2. Stored as linkis_link: true. This happens when as_link: true was 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.
  3. Success — neither of the above. media_type, size and 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.

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

FieldTypeRequiredDescription
batch.collection_tokenstringnoToken of the destination board, applied to every asset in the batch.
batch.collection_idintegernoNumeric ID alternative to collection_token.
batch.assetsarrayyes1 to 100 asset specs (see below).

Each item in batch.assets:

FieldTypeRequiredDescription
uristringyesPublic URL to ingest.
uuidstringnoClient-supplied correlation id, returned untouched on the matching response row.
titlestringnoOverride the title (defaults to filename derived from URL).
descriptionstringnoOptional description.
as_linkbooleannoIf true, store as a bare link instead of fetching bytes.
tagsarraynoManual tags to apply on creation.
statusstringnoStatus 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 returns 406.
  • 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

FieldTypeRequiredDescription
titlestringyesFile name or display label.
media_typestringyesMIME type (e.g., video/mp4).
sizeintegeryesFile 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

FieldTypeRequiredDescription
signed_gcs_idstringyesID returned from the prepare step.
titlestringno(Optional) override title.
descriptionstringno(Optional) asset description.
media_typestringyesSame MIME type as the prepare call.
sizeintegeryesByte size (same as prepare).
collection_tokenstringnoTarget 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:

FieldTypeRequiredDescription
batch.assetsarrayyes1 to 100 asset specs (see below).

Each item in batch.assets:

FieldTypeRequiredDescription
titlestringyesFilename of the asset.
sizeintegeryesFile size in bytes.
uuidstringyesClient-generated correlation id, returned untouched in the response.
media_typestringnoMIME 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:

FieldTypeRequiredDescription
batch.batch_idstringnoValue from batch_upload_prepare. Recommended — releases the upload reservation.
batch.collection_tokenstringnoDestination board for every asset in the batch.
batch.collection_idintegernoNumeric alternative to collection_token.
batch.assetsarrayyes1 to 100 completion specs (see below).

Each item in batch.assets:

FieldTypeRequiredDescription
uuidstringyesSame client uuid you sent in batch_upload_prepare.
signed_gcs_idstringyesExact value returned in the matching prepare response row.
titlestringyesDisplay title for the asset.
descriptionstringnoOptional description.
media_typestringnoMIME type — same value used in prepare.
width / heightintegernoImage dimensions, when known client-side.
multipart_upload_idstringnoRequired 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 caseEndpoint
Asset bytes already live at a public URLbatch_create_from_urls
Files live on the client (browser, server, CI)batch_upload_prepare + batch_upload_complete
One file at a timeassets (URL) or upload_prepare + upload_complete (signed)
No fetchable URL and no way to make HTTP requests — the file is on a person's machineshare 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.

Using an AI agent?

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:

  1. Send it with the create, not afterwards. Both fields are accepted on POST /assets, batch_create_from_urls, upload_complete and batch_upload_complete, as well as on PATCH /assets/{token}. Sending them up front means the context cannot be orphaned if your agent stops between the two calls — and ai_generated only suppresses duplicate merging when it arrives with the create, because that runs as the upload finishes.
  2. 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.
  3. 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.
  4. It must be a JSON object, not an array or a bare string.
  5. You can read it back, but you cannot search it. GET /assets/{token} always returns it. Listing and search endpoints omit it unless you pass get_ai_payload=true, and when omitted the key is absent rather than null, 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 the write scope, or the user lacks update permission on the target board.
  • 404 Not Found (batch complete): One or more signed_gcs_id values point to objects not present in storage — re-upload before retrying.
  • 406 Not Acceptable: A required field is missing (e.g., a batch asset without uri).
  • 422 Unprocessable Entity: File size/type mismatch, expired upload URL, batch size out of bounds (must be 1–100), workspace asset limit exceeded, or an ai_agent_payload over 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 token for later operations like update or delete.