Videos

Configure Videos – Metadata & Scheduling API

Configure video content and metadata via the TokPortal API. Set descriptions, publish dates, sounds, hashtags, and platform-specific options.

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.

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. 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.

FieldTypeDescription
external_refstring | nullYour own reference ID for this video. Max 200 characters. Set to null to clear.
namestring | nullInternal name for the video. Max 200 characters. Set to null to clear.
descriptionstring | nullCaption or description. Max 2200 characters. Editable statuses only.
target_publish_datestring | nullYYYY-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_datestring | nullISO 8601 date-time. Start of an explicit window. Send it together with target_publish_end_date.
target_publish_end_datestring | nullISO 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:

{
  "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

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).

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

Error Responses

StatusCodeDescription
400VALIDATION_ERRORNo fields provided, or invalid values.
404BUNDLE_NOT_FOUNDBundle does not exist.
404VIDEO_NOT_FOUNDNo video exists at this position.
403BUNDLE_NOT_OWNEDBundle 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.

{
  "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

EndpointWhere auto_publish belongs
PUT /bundles/:id/videos/:positionOn the body, next to the video's own fields. It publishes that one slot's bundle.
PUT /bundles/:id/videos/batchAt the root of the body only, as a sibling of videos. It covers every video in the call.
POST /bundles/:id/videos/import-csvAs 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.
  • 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:

{
  "auto_publish": {
    "attempted": true,
    "published": true,
    "bundle_status": "published",
    "videos_published": 5
  }
}
FieldTypeDescription
attemptedbooleantrue only if auto_publish: true was sent and at least one video was successfully configured in this call.
publishedbooleantrue if publish succeeded.
bundle_statusstringNew bundle status (only present when published: true).
videos_publishedintegerNumber of configured videos included in the publish (only when published: true).
blockersarrayPresent when published: false because publish-readiness checks failed. Each item: { code, message, details? }. See Publish-Readiness for the code list.
adjusted_videosarrayPresent 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.
errorobjectPresent 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

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

{
  "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 field400 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 place400 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

FieldTypeRequiredDescription
positionintegerBatch only1-indexed video position. Required in batch requests.
video_typestringYes"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.
descriptionstringConditionalCaption or description for the post. Required for video and carousel. Stories have no description (omit it).
target_publish_datestringYesFirst day of a 2-day publishing window (YYYY-MM-DD), not a fixed date — see 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_urlstringConditionalVideo 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_imagesstring[]ConditionalArray of image storage paths. Required when video_type is "carousel". Use the storage_path from the image upload response.
tiktok_sound_urlstringConditionalTikTok 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_soundintegerNoVolume 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_soundintegerNoVolume 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_instructionsstringNoFree-text instructions for the editor (e.g., "Add logo at end").
external_refstringNoYour own reference ID for this video (e.g., campaign or CMS identifier).
profile_picture_urlstringNoStorage 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.

FieldTypeRequiredDescription
ai_content_disclaimerbooleanNoAI content notice (free). If true, the manager enables the platform's AI-generated content label when posting.
disclose_as_adsbooleanNoDisclose as ad (free). If true, the manager enables branded-content / paid-partnership disclosure (or adds #ad) when posting.
instant_repost_as_storybooleanNoInstant 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

FieldTypeRequiredDescription
instagram_content_typestringYes (Instagram)"reel" or "post". Required on every Instagram slot that is not a story.
instagram_locationstringNoLocation tag for the Instagram post.
instagram_collaboratorsstring[]NoArray of Instagram usernames to tag as collaborators.
instagram_audio_namestringNoName of the audio track for Instagram Reels.
instagram_add_to_storybooleanNoIf 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_typevideo_typeResult
reelvideoReel Video
reelcarouselReel Fixed Photos — creates a video from images (not swipeable)
postvideoPost Video
postcarouselPost 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.

FieldTypeRequiredDescription
video_typestringYesSet to "story".
video_urlstringConditionalThe story video. Provide either video_url or story_image_url, never both. Accepts an external URL — auto-downloaded like any other video.
story_image_urlstringConditionalThe story image. Provide either story_image_url or video_url, never both. Accepts an external URL — auto-downloaded and stored.
story_repost_urlstringNoOptional. 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_urlstringNoTikTok sound for the story (TikTok stories only).
instagram_audio_namestringNoAudio name for the story (Instagram stories only).
editing_instructionsstringNoFree-text instructions for the editor.
target_publish_datestringYesSame 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

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:

{
  "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)

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:

{
  "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:

FieldTypeDescription
youtube_titlestringVideo title.
youtube_tagsstring[]Array of tags.
youtube_categorystringVideo category.
youtube_visibilitystring"public", "unlisted", or "private".

Examples

TikTok Video

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:

{
  "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"
  }
}
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:

{
  "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)

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)

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_collaborators": ["partner"],
    "instagram_add_to_story": true
  }'

Instagram Post Carousel (swipeable photo carousel)

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"
  }'

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).

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:

{
  "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": [...] }.

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:

{
  "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:

{
  "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 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:

{
  "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 StatusMinimum Lead Time
Still being created (not yet delivered)3 days from today
Delivered, or a bundle running on an existing account1 day from today

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

{
  "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.

{
  "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:

{
  "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 or POST /bundles/:id/videos/:position/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 for details on uploading files and obtaining URLs.