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/:positionandPUT /bundles/:id/videos/batchreplace the slot's entire configuration: a field you omit is cleared, not preserved. Because of that, an unrecognised field is now refused with400 UNKNOWN_FIELDinstead of being silently stripped — see Unknown and Inapplicable Fields. UsePATCHbelow when you only want to touchexternal_ref,name,descriptionor 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
PATCHcould 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, carryingdetails.min_days_aheadanddetails.earliest_allowed— the same error shape the configurePUTreturns:{ "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
400on 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
| 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.
{
"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_publishbelongsPUT /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_publishon the batch endpoint is rejected with400 UNKNOWN_FIELDand 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_publishinside 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_publishfailures never fail the request. - If publish is not currently possible (account not yet configured, missing fields…), the response includes a
blockersarray — same codes asGET /bundles/:id/publish-readiness. - If publish was attempted and succeeded, the bundle moves to
published(orpublished_priority) and a notification is created, identical to a manualPOST /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
}
}
| 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 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. |
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
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 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 ofvideos[]is caught too. - Platform-inapplicable field — the request still succeeds and the field name comes back in
ignored_fields. Sendingtiktok_sound_urlon an Instagram slot, or aninstagram_*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_FIELDwithdetails.hints, whose message is the fix rather than a spelling guess. Two cases exist today:target_publish_end_dateon a configure body (the window's end is derived and cannot be set — usePATCH), andauto_publishinsidevideos[]on the batch endpoint (it belongs at the root). Unlike the did-you-mean refusal, these fire for every caller regardless ofAPI_STRICT_UNKNOWN_FIELDS: both fields used to be dropped in silence, so the request was answered200and 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. |
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. 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_soundorvolume_added_soundon 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) to200(double volume).100means 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_CREDITSwithfeature: "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_storycosts 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_CREDITSwithfeature: "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 | Name of the audio track for Instagram Reels. |
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_urlenables 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 returnsINSUFFICIENT_CREDITSwithfeature: "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:
| 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
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"
}
}
TikTok Carousel
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 oninstagram_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 returnedvideo_urlin 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/batchreturns 200 even when some or all rows are rejected. Readconfiguredanderrors[]: 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
isErroronly 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 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:
{
"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_reviewand auto-finalizes about 72 hours later. This happens regardless ofauto_finalize_videos— that flag does not switch automatic finalization off. If you need to approve early or push work back, act inside that window withPOST /bundles/:id/videos/:position/finalizeorPOST /bundles/:id/videos/:position/corrections.
Important: Media URL Types
When referencing uploaded files in video configuration:
video_url— Use thepublic_urlfrom the video upload response.carousel_images— Use thestorage_pathfrom the image upload response (notpublic_url).profile_picture_url— Use thestorage_pathfrom the image upload response (notpublic_url).
See Media Upload for details on uploading files and obtaining URLs.