# 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[​](#prerequisites "Direct link to 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](/docs/guides/fetching_data.md#list-boards-in-an-organization) using the boards endpoint.

***

## Direct Upload from Public URL[​](#direct-upload-from-public-url "Direct link to Direct Upload from Public URL")

### Endpoint[​](#endpoint "Direct link to Endpoint")

```
POST /v1/{slug}/assets

Authorization: Bearer YOUR_TOKEN

Content-Type: application/json
```

### Request Body[​](#request-body "Direct link to 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[​](#sample-request "Direct link to 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[​](#sample-response "Direct link to 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](#async-ingest-semantics) below for how to detect completion.

***

## Async Ingest Semantics[​](#async-ingest-semantics "Direct link to 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[​](#terminal-states "Direct link to 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. **Failed** — `source_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 link** — `is_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[​](#expired-source-urls "Direct link to 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](#two-step-upload-flow-prepare--complete). 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[​](#recommended-polling-cadence "Direct link to 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[​](#batch-upload-from-public-urls "Direct link to 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[​](#endpoint-1 "Direct link to Endpoint")

```
POST /v1/{slug}/assets/batch_create_from_urls

Authorization: Bearer YOUR_TOKEN

Content-Type: application/json
```

Requires the `write` scope.

### Request Body[​](#request-body-1 "Direct link to 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[​](#sample-request-1 "Direct link to 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[​](#sample-response-1 "Direct link to 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](#async-ingest-semantics) above. Poll `GET /v1/{slug}/assets/{asset_token}` for each asset.

### Limits and validation[​](#limits-and-validation "Direct link to 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)[​](#two-step-upload-flow-prepare--complete "Direct link to 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[​](#step-1-request-upload-credentials "Direct link to Step 1: Request Upload Credentials")

#### Endpoint[​](#endpoint-2 "Direct link to Endpoint")

```
POST /v1/{slug}/assets/upload_prepare

Authorization: Bearer YOUR_TOKEN

Content-Type: application/json
```

#### Request Body[​](#request-body-2 "Direct link to 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[​](#sample-request-2 "Direct link to 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[​](#sample-response-2 "Direct link to 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[​](#step-2-upload-the-file-to-storage "Direct link to 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[​](#storage_provider-gcs--two-requests "Direct link to 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[​](#storage_provider-backblaze--one-request "Direct link to 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[​](#step-3-complete-the-upload "Direct link to Step 3: Complete the Upload")

#### Endpoint[​](#endpoint-3 "Direct link to Endpoint")

```
POST /v1/{slug}/assets/upload_complete

Authorization: Bearer YOUR_TOKEN

Content-Type: application/json
```

#### Request Body[​](#request-body-3 "Direct link to 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[​](#sample-request-3 "Direct link to 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[​](#sample-response-3 "Direct link to 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)[​](#batch-two-step-upload-prepare--complete "Direct link to 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`[​](#step-1--batch_upload_prepare "Direct link to 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[​](#step-2--upload-each-file "Direct link to 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`[​](#step-3--batch_upload_complete "Direct link to 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[​](#when-to-choose-which-flow "Direct link to 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`](/docs/guides/sharing_and_publishing.md#accepting-uploads-on-a-shared-link) |

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`[​](#agent-context-ai_agent_payload-and-ai_generated "Direct link to 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[​](#error-handling "Direct link to 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[​](#tips "Direct link to Tips")

* For very large files, monitor your upload progress and retry on failures.
* Clean up or retry failed `signed_gcs_id`s by re-calling the prepare step.
* Track asset `token` for later operations like update or delete.

***
