Source: https://developers.tokportal.com/configure-videos/
Markdown: https://developers.tokportal.com/configure-videos.md

# Configure Videos

Set content and metadata for videos within a bundle. You can configure a single video by position or update multiple videos in one batch request.

## Instagram carousel background music

Use **`instagram_audio_name`** for the background music on an Instagram carousel. Pass the **audio track name** (up to 200 characters), for example `"Song title - Artist"`. This optional field also works for Instagram Reels and Stories.

For a swipeable photo carousel, set `video_type: "carousel"` and `instagram_content_type: "post"`. TikTok uses `tiktok_sound_url`; that field does not set Instagram music and is returned in `ignored_fields` on Instagram bundles.

```json
{
  "video_type": "carousel",
  "instagram_content_type": "post",
  "description": "Our latest collection",
  "target_publish_date": "2026-10-01",
  "carousel_images": [
    "carousel-images/org_xxx/bundle_def456/photo1.jpg",
    "carousel-images/org_xxx/bundle_def456/photo2.jpg"
  ],
  "instagram_audio_name": "Song title - Artist"
}
```

Send this configuration to `PUT /bundles/:id/videos/:position`. Replace sample image paths with the `storage_path` values from [image upload](https://developers.tokportal.com/media-upload), choose a date that meets your bundle's scheduling rules, and include your complete slot configuration: **PUT clears omitted configuration fields**. Use the same audio field inside each video item for [batch configuration](https://developers.tokportal.com/configure-videos/#configure-multiple-videos-batch).

[All Instagram fields](https://developers.tokportal.com/configure-videos/#instagram-specific-fields) · [TikTok carousel example](https://developers.tokportal.com/configure-videos/#tiktok-carousel) · [Sound volume controls](https://developers.tokportal.com/configure-videos/#common-fields)

## Configure a Single Video

```
PUT /bundles/:id/videos/:position
```

Sets or updates the content and metadata for the video at the given position (1-indexed).

> **WARNING: This is a full-replace write**
> `PUT /bundles/:id/videos/:position` and `PUT /bundles/:id/videos/batch` replace the slot's entire configuration: a field you omit is cleared, not preserved. Because of that, an unrecognised field is now refused with `400 UNKNOWN_FIELD` instead of being silently stripped — see [Unknown and Inapplicable Fields](https://developers.tokportal.com/configure-videos/#unknown-and-inapplicable-fields). Use `PATCH` below when you only want to touch `external_ref`, `name`, `description` or the schedule.

## Patch Video Metadata

```
PATCH /bundles/:id/videos/:position
```

Update lightweight metadata (`external_ref`, `name`, `description`) or **reschedule** a video, without re-uploading content. Metadata patches work at any point in the video's lifecycle; description and schedule patches require the video to still be editable (`pending`, `configured` or `accepted`) and are refused with `VIDEO_INVALID_STATUS` once it is finalized.

This is also the reschedule endpoint — and the only place where the publishing window's **end** day can be chosen. Everywhere else the end is derived as start + 1 day.

### Request Body

All fields are optional. At least one must be provided.

| Field                       | Type           | Description                                                                                                                                                               |
| --------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `external_ref`              | string \| null | Your own reference ID for this video. Max 200 characters. Set to `null` to clear.                                                                                         |
| `name`                      | string \| null | Internal name for the video. Max 200 characters. Set to `null` to clear.                                                                                                  |
| `description`               | string \| null | Caption or description. Max 2200 characters. Editable statuses only.                                                                                                      |
| `target_publish_date`       | string \| null | `YYYY-MM-DD`. Shorthand for a 2-day window: the end day is derived as this day + 1. Cannot be combined with the start/end pair.                                           |
| `target_publish_start_date` | string \| null | ISO 8601 date-time. Start of an **explicit** window. Send it together with `target_publish_end_date`.                                                                     |
| `target_publish_end_date`   | string \| null | ISO 8601 date-time. End of an explicit window; must be **strictly after** the start. Requires `target_publish_start_date`. Never valid on the configure or CSV endpoints. |

> **DANGER: Rescheduling now obeys the minimum lead time**
> **Changed 2026-08-26.** Until then this endpoint enforced only the 3-videos-per-day cap, so a `PATCH` could move a video to today or into the past — and it landed in the manager's calendar as instantly-due work. It now applies the **same** minimum lead time as configuration (today + 1 with a delivered or existing account, today + 3 while the account is still being created, in UTC).
>
> An earlier date is rejected with `INVALID_DATE`, carrying `details.min_days_ahead` and `details.earliest_allowed` — the same error shape the configure `PUT` returns:
>
> ```json
> {
>   "error": {
>     "code": "INVALID_DATE",
>     "message": "Date is too early. Videos must be scheduled at least 3 day(s) in advance (new accounts require setup and warming).",
>     "details": {
>       "field": "target_publish_date",
>       "min_days_ahead": 3,
>       "earliest_allowed": "2026-08-29"
>     }
>   }
> }
> ```
>
> If you re-date videos in bulk — especially onto the current day — this is the change most likely to start returning `400` on calls that used to succeed.

### Example

```bash
curl -X PATCH https://app.tokportal.com/api/ext/bundles/bundle_abc123/videos/1 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "external_ref": "correct-campaign-id-42"
  }'
```

### Response

Returns the full video object (same shape as `GET /bundles/:id/videos/:position`).

```json
{
  "data": {
    "id": "vid_xyz",
    "position": 1,
    "status": "published",
    "external_ref": "correct-campaign-id-42",
    "name": "Video 1",
    "...": "..."
  }
}
```

### Error Responses

| Status | Code               | Description                            |
| ------ | ------------------ | -------------------------------------- |
| `400`  | `VALIDATION_ERROR` | No fields provided, or invalid values. |
| `404`  | `BUNDLE_NOT_FOUND` | Bundle does not exist.                 |
| `404`  | `VIDEO_NOT_FOUND`  | No video exists at this position.      |
| `403`  | `BUNDLE_NOT_OWNED` | Bundle belongs to another user.        |

***

## Configure Multiple Videos (Batch)

```
PUT /bundles/:id/videos/batch
```

Updates multiple videos in a single request. The request body must be wrapped in a `videos` object — it is **not** a raw array.

```json
{
  "videos": [
    { "position": 1, "video_type": "video", "..." : "..." },
    { "position": 2, "video_type": "carousel", "..." : "..." }
  ]
}
```

***

## `auto_publish` — configure and publish in one call

Both `PUT /bundles/:id/videos/:position` and `PUT /bundles/:id/videos/batch` accept an optional boolean `auto_publish` (default `false`). When `true`, the API will attempt `POST /bundles/:id/publish` immediately after a successful video configuration. A common integration mistake is to configure videos and then forget to publish — `auto_publish: true` removes that step.

For `POST /bundles/:id/videos/import-csv` (multipart form), pass `auto_publish=true` as a separate form field alongside `file`.

> **WARNING: Where the flag goes — one publish attempt per call, never one per video**
>
> | Endpoint                              | Where `auto_publish` belongs                                                                   |
> | ------------------------------------- | ---------------------------------------------------------------------------------------------- |
> | `PUT /bundles/:id/videos/:position`   | **On the body**, next to the video's own fields. It publishes that one slot's bundle.          |
> | `PUT /bundles/:id/videos/batch`       | **At the root of the body only**, as a sibling of `videos`. It covers every video in the call. |
> | `POST /bundles/:id/videos/import-csv` | As a multipart **form field**, alongside `file`. It covers the whole import.                   |
>
> Sending `videos[n].auto_publish` on the batch endpoint is rejected with `400 UNKNOWN_FIELD` and this message:
>
> > `auto_publish is a top-level field on this endpoint and applies to every video in the call — move it out of videos[]. It cannot be set per item: there is one publish attempt per call, not one per video.`
>
> Before 2026-08-26 the published schema advertised `auto_publish` inside each batch item — it was inherited from the single-slot schema — while the endpoint had never accepted it. The schema no longer declares it.

### Behaviour

- The configuration step is the source of truth: if it succeeds, the response is a success response. `auto_publish` failures **never** fail the request.
- If publish is not currently possible (account not yet configured, missing fields…), the response includes a `blockers` array — same codes as [`GET /bundles/:id/publish-readiness`](https://developers.tokportal.com/bundles#check-publish-readiness).
- If publish was attempted and succeeded, the bundle moves to `published` (or `published_priority`) and a notification is created, identical to a manual `POST /bundles/:id/publish`.

### `auto_publish` response object

Every video-configuration response now includes an `auto_publish` field:

```json
{
  "auto_publish": {
    "attempted": true,
    "published": true,
    "bundle_status": "published",
    "videos_published": 5
  }
}
```

| Field              | Type    | Description                                                                                                                                                                                                                                                     |
| ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `attempted`        | boolean | `true` only if `auto_publish: true` was sent **and** at least one video was successfully configured in this call.                                                                                                                                               |
| `published`        | boolean | `true` if publish succeeded.                                                                                                                                                                                                                                    |
| `bundle_status`    | string  | New bundle status (only present when `published: true`).                                                                                                                                                                                                        |
| `videos_published` | integer | Number of configured videos included in the publish (only when `published: true`).                                                                                                                                                                              |
| `blockers`         | array   | Present when `published: false` because publish-readiness checks failed. Each item: `{ code, message, details? }`. See [Publish-Readiness](https://developers.tokportal.com/bundles#check-publish-readiness) for the code list.                                 |
| `adjusted_videos`  | array   | Present only when publishing moved a stale target date forward. One entry per moved slot: `{ video_id, position, previous_date, new_date }`. See [Stale dates at publish time](https://developers.tokportal.com/configure-videos/#stale-dates-at-publish-time). |
| `error`            | object  | Present when publish was attempted but failed for a non-readiness reason (e.g., transient RPC failure). Shape: `{ code, message }`. Re-attempt by calling `POST /bundles/:id/publish` directly.                                                                 |

### Example — configure batch and publish

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bnd_abc123/videos/batch \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "auto_publish": true,
    "videos": [
      { "position": 1, "video_type": "video", "video_url": "https://...", "description": "...", "target_publish_date": "2026-05-01" }
    ]
  }'
```

### Example — `auto_publish` skipped because the account is not ready

```json
{
  "data": {
    "bundle_id": "bnd_abc123",
    "configured": 5,
    "results": [...],
    "auto_publish": {
      "attempted": true,
      "published": false,
      "blockers": [
        {
          "code": "ACCOUNT_MISSING_FIELDS",
          "message": "Account is missing required fields.",
          "details": { "missing_fields": ["profile_picture_url"] }
        }
      ]
    }
  }
}
```

## Unknown and Inapplicable Fields

Both `PUT /bundles/:id/videos/:position` and `PUT /bundles/:id/videos/batch` are **full-replace** writes, so the body you send becomes the slot's whole configuration. A misspelled field name (`descriptoin`) used to be stripped by validation and the value it was meant to set was wiped to `null`. That is now a hard error.

- **Unrecognised field** — `400 UNKNOWN_FIELD`. The error carries a did-you-mean for the closest valid field name. Batch bodies are walked item by item, so a typo inside a single entry of `videos[]` is caught too.
- **Platform-inapplicable field** — the request still succeeds and the field name comes back in `ignored_fields`. Sending `tiktok_sound_url` on an Instagram slot, or an `instagram_*` field on a TikTok slot, is reported rather than silently dropped, so read it back instead of assuming the value was stored.
- **Real field, wrong place** — `400 UNKNOWN_FIELD` with `details.hints`, whose message *is* the fix rather than a spelling guess. Two cases exist today: `target_publish_end_date` on a configure body (the window's end is derived and cannot be set — use `PATCH`), and `auto_publish` inside `videos[]` on the batch endpoint (it belongs at the root). Unlike the did-you-mean refusal, these fire for **every** caller regardless of `API_STRICT_UNKNOWN_FIELDS`: both fields used to be dropped in silence, so the request was answered `200` and the thing it asked for never happened.

Which callers get the 400 is decided by the server-side `API_STRICT_UNKNOWN_FIELDS` setting. The default is `mcp`: MCP callers are refused, while REST callers run normally and get the unrecognised names back in `meta.ignored_fields` on the response. `all` refuses every caller; `off` never refuses.

## Field Reference

### Common Fields

| Field                   | Type      | Required    | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ----------------------- | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `position`              | integer   | Batch only  | 1-indexed video position. Required in batch requests.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `video_type`            | string    | Yes         | `"video"`, `"carousel"`, or `"story"`. For Instagram: `carousel` with `instagram_content_type: "reel"` = Fixed Photos (creates a video from images), `carousel` with `instagram_content_type: "post"` = swipeable Carousel. `story` is TikTok/Instagram only — see [Story Fields](https://developers.tokportal.com/configure-videos/#story-fields).                                                                                                                                                                                        |
| `description`           | string    | Conditional | Caption or description for the post. **Required** for `video` and `carousel`. Stories have **no** description (omit it).                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `target_publish_date`   | string    | Yes         | **First day of a 2-day publishing window** (`YYYY-MM-DD`), not a fixed date — see [The publishing window](https://developers.tokportal.com/configure-videos/#the-publishing-window). The end day is derived as this day + 1 and cannot be chosen; sending `target_publish_end_date` here is rejected. The earliest allowed date is **today + 3 days** while the account is still being created, and **today + 1 day** once the account has been delivered or when the bundle runs on an existing account. Max 3 videos per day per bundle. |
| `video_url`             | string    | Conditional | Video file URL. Required when `video_type` is `"video"`; for a `story`, provide it **only** if the story is a video. You can pass a `public_url` from the upload endpoint **or any external URL** (Google Drive, Dropbox, direct link…) — the API will automatically download and re-upload the file to our storage.                                                                                                                                                                                                                       |
| `carousel_images`       | string\[] | Conditional | Array of image storage paths. Required when `video_type` is `"carousel"`. Use the `storage_path` from the image upload response.                                                                                                                                                                                                                                                                                                                                                                                                           |
| `tiktok_sound_url`      | string    | Conditional | TikTok sound URL. **Required for TikTok carousels**, optional for TikTok videos. TikTok bundles only — sent on any other platform it comes back in `ignored_fields`.                                                                                                                                                                                                                                                                                                                                                                       |
| `volume_original_sound` | integer   | No          | Volume of the original video sound, from `0` to `200` (percent). `100` = unchanged. Setting this for the first time on a video costs **1 credit** (see note below).                                                                                                                                                                                                                                                                                                                                                                        |
| `volume_added_sound`    | integer   | No          | Volume of the added sound/music track, from `0` to `200` (percent). `100` = unchanged. Setting this for the first time on a video costs **1 credit** (see note below).                                                                                                                                                                                                                                                                                                                                                                     |
| `editing_instructions`  | string    | No          | Free-text instructions for the editor (e.g., "Add logo at end").                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `external_ref`          | string    | No          | Your own reference ID for this video (e.g., campaign or CMS identifier).                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `profile_picture_url`   | string    | No          | Storage path for the account profile picture. Use the `storage_path` from the image upload response.                                                                                                                                                                                                                                                                                                                                                                                                                                       |

> **INFO: Sound Volume Control — 1 credit per video**
> Setting `volume_original_sound` or `volume_added_sound` on a video enables the sound-volume-control feature for that video and costs **1 credit**, debited automatically the first time either field is set.
>
> - Both fields accept integers from `0` (mute) to `200` (double volume). `100` means the sound is untouched.
> - The credit is charged **once per video**. Subsequent updates to the same fields on the same video are free.
> - If your balance is too low, the API returns `INSUFFICIENT_CREDITS` with `feature: "sound_volume"`.
> - Supported on TikTok and Instagram.

### Compliance & Repost Options

Optional posting flags. The two disclosure flags are **free** and simply tell the account manager to toggle the platform's native label when posting. `instant_repost_as_story` is a **paid** add-on.

| Field                     | Type    | Required | Description                                                                                                                                                                                                            |
| ------------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ai_content_disclaimer`   | boolean | No       | AI content notice (**free**). If `true`, the manager enables the platform's AI-generated content label when posting.                                                                                                   |
| `disclose_as_ads`         | boolean | No       | Disclose as ad (**free**). If `true`, the manager enables branded-content / paid-partnership disclosure (or adds `#ad`) when posting.                                                                                  |
| `instant_repost_as_story` | boolean | No       | Instant repost as story (**paid, +1 credit**). Right after posting, the manager reposts this video as a story and provides a screenshot proof. Only valid for `video_type: "video"` on **TikTok / Instagram** bundles. |

> **INFO: Instant Repost as Story — 1 credit per video**
> Enabling `instant_repost_as_story` costs **1 credit**, debited automatically the first time the flag is turned on for a video (charge-once, no refunds).
>
> - Only allowed on TikTok/Instagram bundles and only for `video_type: "video"` (not stories or carousels).
> - If your balance is too low, the API returns `INSUFFICIENT_CREDITS` with `feature: "instant_repost"`.
> - Omit the field to keep the current value — it is never silently cleared.

### Instagram-Specific Fields

| Field                     | Type      | Required        | Description                                                                                                                                                                      |
| ------------------------- | --------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `instagram_content_type`  | string    | Yes (Instagram) | `"reel"` or `"post"`. **Required on every Instagram slot** that is not a story.                                                                                                  |
| `instagram_location`      | string    | No              | Location tag for the Instagram post.                                                                                                                                             |
| `instagram_collaborators` | string\[] | No              | Array of Instagram usernames to tag as collaborators.                                                                                                                            |
| `instagram_audio_name`    | string    | No              | Audio track name for Instagram carousel background music, Reels, or Stories. Optional, max 200 characters. Example: `"Song title - Artist"`. Use a name, not `tiktok_sound_url`. |
| `instagram_add_to_story`  | boolean   | No              | If `true`, the creator also shares the post to their Instagram Story.                                                                                                            |

These fields apply to Instagram bundles only. Sent on a TikTok slot they do not fail the request — the field names come back in `ignored_fields`.

### Instagram Content Type Combinations

| `instagram_content_type` | `video_type` | Result                                                              |
| ------------------------ | ------------ | ------------------------------------------------------------------- |
| `reel`                   | `video`      | Reel Video                                                          |
| `reel`                   | `carousel`   | Reel Fixed Photos — creates a **video** from images (not swipeable) |
| `post`                   | `video`      | Post Video                                                          |
| `post`                   | `carousel`   | Post Carousel — swipeable photo carousel                            |

### Story Fields

A **Story** is a new content type (TikTok and Instagram only) that counts as one video slot, just like a regular video. A story is a single piece of media — **exactly one** video **or** one image (no multi-image) — and has **no description**.

Because a story can't be shared with a link or verified through the platform API, it is verified by the account manager, who uploads a **screenshot** as proof. That screenshot is returned on the video object as `evidence_screenshot` once the story is submitted.

| Field                  | Type   | Required    | Description                                                                                                                                                                                                                                                        |
| ---------------------- | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `video_type`           | string | Yes         | Set to `"story"`.                                                                                                                                                                                                                                                  |
| `video_url`            | string | Conditional | The story video. Provide **either** `video_url` **or** `story_image_url`, never both. Accepts an external URL — auto-downloaded like any other video.                                                                                                              |
| `story_image_url`      | string | Conditional | The story image. Provide **either** `story_image_url` **or** `video_url`, never both. Accepts an external URL — auto-downloaded and stored.                                                                                                                        |
| `story_repost_url`     | string | No          | Optional. Makes the story **repost an existing post** from the **same platform** (a TikTok story must link a `tiktok.com` post; an Instagram story must link an `instagram.com` post). Costs **+1 credit** (see note). A link from any other platform is rejected. |
| `tiktok_sound_url`     | string | No          | TikTok sound for the story (TikTok stories only).                                                                                                                                                                                                                  |
| `instagram_audio_name` | string | No          | Audio name for the story (Instagram stories only).                                                                                                                                                                                                                 |
| `editing_instructions` | string | No          | Free-text instructions for the editor.                                                                                                                                                                                                                             |
| `target_publish_date`  | string | Yes         | Same scheduling rules as regular videos.                                                                                                                                                                                                                           |

Stories do **not** accept `description`, `instagram_content_type`, `instagram_location`, `instagram_collaborators`, `carousel_images`, or `instagram_add_to_story`.

> **INFO: Story Repost Link — 1 credit**
> Setting `story_repost_url` enables the repost feature for that story and costs **1 credit**, debited automatically the first time the field is set. If your balance is too low, the API returns `INSUFFICIENT_CREDITS` with `feature: "repost_url"`. The media (video or image) is **always** required — the repost link is an additional instruction on top of it.

#### TikTok Story (image) with a repost link

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_abc123/videos/4 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "video_type": "story",
    "target_publish_date": "2026-03-22",
    "story_image_url": "https://pub-xxx.r2.dev/images/story-promo.jpg",
    "story_repost_url": "https://www.tiktok.com/@brand/video/7300000000000000000"
  }'
```

**Response:**

```json
{
  "data": {
    "position": 4,
    "video_type": "story",
    "status": "configured",
    "description": "",
    "target_publish_date": "2026-03-22",
    "story_image_url": "carousel-images/org_xxx/bundle_abc123/story-promo.jpg",
    "story_repost_url": "https://www.tiktok.com/@brand/video/7300000000000000000",
    "evidence_screenshot": null
  }
}
```

#### Instagram Story (video)

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_def456/videos/2 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "video_type": "story",
    "target_publish_date": "2026-03-22",
    "video_url": "https://pub-xxx.r2.dev/videos/story-clip.mp4",
    "instagram_audio_name": "Original Audio"
  }'
```

If your balance is below 1 credit when adding a repost link:

```json
{
  "error": {
    "code": "INSUFFICIENT_CREDITS",
    "message": "Not enough credits to complete this operation.",
    "details": {
      "reason": "Need 1 credit to add a story repost link.",
      "feature": "repost_url"
    }
  }
}
```

### YouTube Fields (Reserved)

YouTube accounts cannot be ordered through the API: bundle creation supports TikTok and Instagram only, and a YouTube bundle is rejected with `YOUTUBE_DELAYED` even though `GET /credit-costs` lists a YouTube price. The following fields stay declared on the video schema but are reserved — on a TikTok or Instagram slot they come back in `ignored_fields`:

| Field                | Type      | Description                               |
| -------------------- | --------- | ----------------------------------------- |
| `youtube_title`      | string    | Video title.                              |
| `youtube_tags`       | string\[] | Array of tags.                            |
| `youtube_category`   | string    | Video category.                           |
| `youtube_visibility` | string    | `"public"`, `"unlisted"`, or `"private"`. |

***

## Examples

### TikTok Video

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_abc123/videos/1 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "video_type": "video",
    "description": "Check out this new product! #ad #sponsored",
    "target_publish_date": "2026-03-15",
    "video_url": "https://pub-xxx.r2.dev/videos/bundle-id/promo.mp4",
    "tiktok_sound_url": "https://www.tiktok.com/music/trending-sound-789",
    "editing_instructions": "Add brand watermark in bottom-right corner",
    "external_ref": "campaign-42-v1"
  }'
```

**Response:**

```json
{
  "data": {
    "position": 1,
    "video_type": "video",
    "status": "configured",
    "description": "Check out this new product! #ad #sponsored",
    "target_publish_date": "2026-03-15",
    "video_url": "https://pub-xxx.r2.dev/videos/bundle-id/promo.mp4",
    "tiktok_sound_url": "https://www.tiktok.com/music/trending-sound-789",
    "editing_instructions": "Add brand watermark in bottom-right corner",
    "external_ref": "campaign-42-v1"
  }
}
```

### TikTok Carousel

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_abc123/videos/2 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "video_type": "carousel",
    "description": "Swipe through our latest collection",
    "target_publish_date": "2026-03-17",
    "carousel_images": [
      "carousel-images/org_xxx/bundle_abc123/slide1.jpg",
      "carousel-images/org_xxx/bundle_abc123/slide2.jpg",
      "carousel-images/org_xxx/bundle_abc123/slide3.jpg"
    ],
    "tiktok_sound_url": "https://www.tiktok.com/music/chill-vibes-456",
    "external_ref": "campaign-42-v2"
  }'
```

**Response:**

```json
{
  "data": {
    "position": 2,
    "video_type": "carousel",
    "status": "configured",
    "description": "Swipe through our latest collection",
    "target_publish_date": "2026-03-17",
    "carousel_images": [
      "carousel-images/org_xxx/bundle_abc123/slide1.jpg",
      "carousel-images/org_xxx/bundle_abc123/slide2.jpg",
      "carousel-images/org_xxx/bundle_abc123/slide3.jpg"
    ],
    "tiktok_sound_url": "https://www.tiktok.com/music/chill-vibes-456",
    "external_ref": "campaign-42-v2"
  }
}
```

### Instagram Reel (Video)

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_def456/videos/1 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "video_type": "video",
    "instagram_content_type": "reel",
    "description": "Morning routine with our skincare line",
    "target_publish_date": "2026-03-20",
    "video_url": "https://pub-xxx.r2.dev/videos/bundle-id/reel-skincare.mp4",
    "instagram_location": "Los Angeles, California",
    "instagram_collaborators": ["brandofficial"],
    "instagram_audio_name": "Original Audio",
    "instagram_add_to_story": true,
    "external_ref": "ig-reel-001"
  }'
```

### Instagram Reel Fixed Photos (creates a video from images, not swipeable)

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_def456/videos/2 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "video_type": "carousel",
    "instagram_content_type": "reel",
    "description": "Style inspiration for spring",
    "target_publish_date": "2026-03-20",
    "carousel_images": [
      "carousel-images/org_xxx/bundle_def456/slide1.jpg",
      "carousel-images/org_xxx/bundle_def456/slide2.jpg"
    ],
    "instagram_location": "Paris, France",
    "instagram_audio_name": "Song title - Artist",
    "instagram_collaborators": ["partner"],
    "instagram_add_to_story": true
  }'
```

### Instagram Post Carousel (swipeable photo carousel)

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_def456/videos/3 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "video_type": "carousel",
    "instagram_content_type": "post",
    "description": "Our latest collection",
    "target_publish_date": "2026-03-20",
    "carousel_images": [
      "carousel-images/org_xxx/bundle_def456/photo1.jpg",
      "carousel-images/org_xxx/bundle_def456/photo2.jpg"
    ],
    "instagram_location": "Paris, France",
    "instagram_audio_name": "Song title - Artist"
  }'
```

> **INFO: Instagram Carousel Behavior**
> For Instagram, `video_type: "carousel"` behaves differently depending on `instagram_content_type`:
>
> - With `"reel"`: creates a **video** from your images (Fixed Photos — not swipeable)
> - With `"post"`: creates a **swipeable photo carousel**

### TikTok Video with Sound Volume Control

Lower the original video sound and boost the added music. Costs 1 extra credit (charged once per video).

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_abc123/videos/1 \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "video_type": "video",
    "description": "New product launch #ad",
    "target_publish_date": "2026-03-15",
    "video_url": "https://pub-xxx.r2.dev/videos/bundle-id/promo.mp4",
    "tiktok_sound_url": "https://www.tiktok.com/music/trending-sound-789",
    "volume_original_sound": 20,
    "volume_added_sound": 150
  }'
```

If your balance is below 1 credit:

```json
{
  "error": {
    "code": "INSUFFICIENT_CREDITS",
    "message": "Not enough credits to complete this operation.",
    "details": {
      "reason": "Need 1 credit to enable sound volume control.",
      "feature": "sound_volume"
    }
  }
}
```

***

> **TIP: Automatic Video Download**
> You don't need to upload your video first. If you pass any **external URL** (Google Drive, Dropbox, any direct link) as `video_url`, the API will **automatically download the file and store it on our servers**. The returned `video_url` in the response will be the final hosted URL. This also works in batch configuration and CSV import.
>
> URLs already hosted on our storage (`pub-0d3f...r2.dev`) are kept as-is (no redundant re-upload).

***

## Batch Configuration

Configure multiple videos in a single request. The body must be wrapped in `{ "videos": [...] }`.

```bash
curl -X PUT https://app.tokportal.com/api/ext/bundles/bundle_abc123/videos/batch \
  -H "X-API-Key: sk_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "videos": [
      {
        "position": 1,
        "video_type": "video",
        "description": "Day 1 of the challenge #ad",
        "target_publish_date": "2026-03-15",
        "video_url": "https://pub-xxx.r2.dev/videos/bundle-id/day1.mp4"
      },
      {
        "position": 2,
        "video_type": "video",
        "description": "Day 2 — things are getting interesting #ad",
        "target_publish_date": "2026-03-18",
        "video_url": "https://pub-xxx.r2.dev/videos/bundle-id/day2.mp4",
        "tiktok_sound_url": "https://www.tiktok.com/music/hype-beat-101"
      },
      {
        "position": 3,
        "video_type": "carousel",
        "description": "Final results — swipe to see the transformation",
        "target_publish_date": "2026-03-21",
        "carousel_images": [
          "carousel-images/org_xxx/bundle_abc123/before.jpg",
          "carousel-images/org_xxx/bundle_abc123/after.jpg"
        ],
        "tiktok_sound_url": "https://www.tiktok.com/music/reveal-sound-202"
      }
    ]
  }'
```

**Response:**

```json
{
  "data": {
    "bundle_id": "bundle_abc123",
    "configured": 3,
    "errors": [],
    "results": [
      {
        "position": 1,
        "video_type": "video",
        "status": "configured"
      },
      {
        "position": 2,
        "video_type": "video",
        "status": "configured"
      },
      {
        "position": 3,
        "video_type": "carousel",
        "status": "configured"
      }
    ]
  }
}
```

> **WARNING: Partial success — never infer success from the status code**
> `PUT /bundles/:id/videos/batch` returns **200 even when some or all rows are rejected**. Read `configured` and `errors[]`: rows that break the 3-per-day cap or the minimum lead time fail individually while the rest succeed, and the paid options they carry are only charged for the rows that succeeded.
>
> Over MCP the call is marked `isError` **only when zero items succeeded while errors are present**. Partial success and full success are reported as success, exactly as before, and the REST status code is unchanged.

If some videos fail validation, the response includes partial results:

```json
{
  "data": {
    "bundle_id": "bundle_abc123",
    "configured": 2,
    "errors": [
      {
        "position": 3,
        "error": "carousel_images is required when video_type is carousel"
      }
    ],
    "results": [
      {
        "position": 1,
        "video_type": "video",
        "status": "configured"
      },
      {
        "position": 2,
        "video_type": "video",
        "status": "configured"
      }
    ]
  }
}
```

## The publishing window

`target_publish_date` names one day but stores **two**. The API writes `target_publish_start_date` = the day you sent and `target_publish_end_date` = that day **+ 1**, and the manager may post on either. It is the first day of a window, not a deadline and not a fixed date.

The end day is **derived**. You cannot choose it on `PUT /bundles/:id/videos/:position`, `PUT /bundles/:id/videos/batch` or the CSV import — sending `target_publish_end_date` (or a CSV column of that name) is rejected there, with a message pointing at `PATCH`. [`PATCH /bundles/:id/videos/:position`](https://developers.tokportal.com/configure-videos/#patch-video-metadata) is the only endpoint that accepts an explicit `target_publish_start_date` / `target_publish_end_date` pair.

Every response that returns a video exposes the window that was actually stored:

```json
{
  "target_publish_date": "2026-09-10T00:00:00.000Z",
  "target_publish_start_date": "2026-09-10T00:00:00.000Z",
  "target_publish_end_date": "2026-09-11T00:00:00.000Z"
}
```

`target_publish_date` on a **response** is an alias of the window's start. Read `target_publish_start_date` / `target_publish_end_date` when you need the window itself.

## Date Validation

The `target_publish_date` must meet the following minimum lead times, evaluated in **UTC**:

| Account Status                                        | Minimum Lead Time |
| ----------------------------------------------------- | ----------------- |
| Still being created (not yet delivered)               | 3 days from today |
| Delivered, or a bundle running on an existing account | 1 day from today  |

If the date is too soon, the API returns `INVALID_DATE`:

```json
{
  "error": {
    "code": "INVALID_DATE",
    "message": "Date is too early. Videos must be scheduled at least 3 day(s) in advance (new accounts require setup and warming).",
    "details": {
      "field": "target_publish_date",
      "min_days_ahead": 3,
      "earliest_allowed": "2026-08-29"
    }
  }
}
```

The same rule and the same error shape apply to single configure, batch configure, CSV import, and — since 2026-08-26 — `PATCH` reschedules.

## Stale dates at publish time

Dates are validated when they are **written**. A bundle can then sit in `pending_setup` long enough for its dates to reach today, so every publish path re-checks them:

- The check looks at the **earliest** target date in the batch. If it has reached **today or the past**, the whole batch **slides forward as a block**: every slot moves by the same number of days, so your pacing survives — a one-video-per-day plan stays one video per day, gaps stay gaps, and the order never changes.
- If the earliest date is still in the future, **nothing moves**.
- The 3-videos-per-day cap still applies against slots that are not moving; a full day spills to the next one, and a slot pushed that way never overtakes the slot before it.
- The adjustment never fails the publish. A publish that would have succeeded before cannot start failing because of it.

This runs on `POST /bundles/:id/publish`, every `auto_publish` path (single configure, batch, CSV, whether the bundle is still in setup or already active), `POST /bundles/:id/videos/:position/publish` and `POST /bundles/:id/videos/publish-all`.

What was moved is always reported. `POST /bundles/:id/publish` and the `auto_publish` object carry `adjusted_videos` (`{ video_id, position, previous_date, new_date }` per slot) plus `adjusted_videos_note`; the single-video and publish-all responses report it in their own shapes — see [Video Actions](https://developers.tokportal.com/videos/video-actions#publish-a-video).

```json
{
  "data": {
    "bundle_id": "uuid",
    "status": "published",
    "videos_published": 5,
    "adjusted_videos": [
      { "video_id": "uuid1", "position": 1, "previous_date": "2026-08-24", "new_date": "2026-08-27" }
    ],
    "adjusted_videos_note": "The earliest target date in this batch was already today or in the past at publish time, so the whole batch was shifted forward by the same number of days — original pacing, gaps and order are preserved. Use PATCH /bundles/{id}/videos/{position} to reschedule."
  }
}
```

### Daily Scheduling Cap

A bundle can have **at most 3 videos targeting the same day** (`target_publish_date`). Every non-cancelled video slot that already has a date on that day counts toward the cap, whatever its status. Re-configuring a video on its own current day does not count against itself.

This applies to single configure, batch configure, CSV import, and `PATCH` date updates. In a batch, videos are processed in order: the first 3 on a given day succeed and the rest fail with a per-item error.

A 4th video on the same day is rejected with `VIDEOS_PER_DAY_EXCEEDED`:

```json
{
  "error": {
    "code": "VIDEOS_PER_DAY_EXCEEDED",
    "message": "Too many videos scheduled on the same day for this bundle (max 3 per day). Check \"details\" for the date and current count.",
    "details": {
      "date": "2026-08-20",
      "limit": 3,
      "current_count": 3,
      "reason": "This bundle already has 3 video(s) scheduled on 2026-08-20. Maximum 3 videos per day per bundle — pick another day."
    }
  }
}
```

Spread your schedule across more days, or split the volume across multiple bundles (one bundle = one account, and 3+ posts per day on a single account is rarely healthy anyway).

> **INFO: After delivery: the 72-hour review window**
> Once the manager delivers a configured video it enters `in_review` and **auto-finalizes about 72 hours later**. This happens **regardless of `auto_finalize_videos`** — that flag does not switch automatic finalization off. If you need to approve early or push work back, act inside that window with [`POST /bundles/:id/videos/:position/finalize`](https://developers.tokportal.com/videos/video-actions#finalize-a-video) or [`POST /bundles/:id/videos/:position/corrections`](https://developers.tokportal.com/videos/video-actions#request-corrections).

## Important: Media URL Types

When referencing uploaded files in video configuration:

- **`video_url`** — Use the `public_url` from the video upload response.
- **`carousel_images`** — Use the `storage_path` from the image upload response (not `public_url`).
- **`profile_picture_url`** — Use the `storage_path` from the image upload response (not `public_url`).

See [Media Upload](https://developers.tokportal.com/media-upload) for details on uploading files and obtaining URLs.
