Skip to main content

Indexing videos

Indexing turns a video into scenes with captions, transcripts, embeddings and a brand-safety assessment. Every submission path answers quickly with a video_id and a job_id; the pipeline itself (scene detection → captioning → embedding → moderation) runs asynchronously. Poll the job, then read the result.

note

Every example uses https://video.pyler.ai and an API key in $VUF_API_KEY. See Authentication for the key format.

Pick a submission path​

You haveCallReference
One public URLPOST /api/v1/videosSubmit a video
One local filePOST /api/v1/videos/uploadUpload a file
A parsed list of URLs (up to 500)POST /api/v1/videos/batch with kind: "url"Create a batch
Several local filesPOST /api/v1/videos/batch/uploadBatch upload
A .csv or .xlsx of URLsPOST /api/v1/videos/batch/upload-sheetSheet upload
An MRSS feedRegister it in the dashboard, then manage it under /api/v1/mrss-feedsMRSS feeds

Whatever the path, keep the video_id. It is stable for the lifetime of the video, while job_id is replaced by every re-index.

Submit a single URL​

POST /api/v1/videos takes a publicly fetchable http(s) URL and an optional title (falls back to the URL's last path segment). It returns 202 Accepted.

curl -X POST https://video.pyler.ai/api/v1/videos \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/clip.mp4", "title": "demo clip"}'
Response (202)
{
"video_id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"job_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"created": true
}
tip

Resubmitting a URL that is still being indexed for you does not start a second pipeline. You get the existing video_id and job_id back with created: false, still as 202. Resubmitting a catalogue is therefore safe; count the true values to see how many video rows it actually created.

Upload a file​

POST /api/v1/videos/upload is the same async flow for a local file (mp4, mov, and similar). Send multipart/form-data with a file field and an optional title.

curl -X POST https://video.pyler.ai/api/v1/videos/upload \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@clip.mp4" \
-F "title=demo clip"

The 202 body carries video_id, job_id and storage_path (the internal object key the upload was persisted to). For many files, either repeat this call per file or use a batch upload.

Poll the job​

GET /api/v1/jobs/{job_id} returns the current pipeline state. status moves through pending → running → done or failed; done_scenes / total_scenes give progress while running.

while true; do
status=$(curl -s https://video.pyler.ai/api/v1/jobs/$JOB_ID \
-H "Authorization: Bearer $VUF_API_KEY" | jq -r .status)
echo " $status"
[ "$status" = "done" ] && break
[ "$status" = "failed" ] && { echo "job failed"; exit 1; }
sleep 5
done
Response (200)
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"video_id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"status": "running",
"total_scenes": 42,
"done_scenes": 18,
"error": null,
"reason_code": null,
"reason": null,
"started_at": "2026-10-01T10:23:01Z",
"completed_at": null
}

On failed, branch on reason_code for a stable cause (for example source_forbidden, url_unreachable, media_unreadable, processing_stalled, internal_error) rather than parsing error: a finished job can still carry an older attempt's error text. The same reason_code appears on GET /api/v1/videos/{video_id} when the video's status is failed.

info

Prefer not to poll? GET /api/v1/videos/{video_id}/scenes/stream pushes each scene as soon as its caption is ready and ends with a done line. See the walkthrough below.

Walkthrough: index a video and read its scenes​

  1. Submit the video
    curl -s -X POST https://video.pyler.ai/api/v1/videos \
    -H "Authorization: Bearer $VUF_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url": "https://example.com/clip.mp4", "title": "demo clip"}'
    # {"video_id": "3f6c...", "job_id": "a1b2...", "created": true}
    export VIDEO_ID=3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90
    export JOB_ID=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
  2. Wait for the job

    Poll GET /api/v1/jobs/$JOB_ID until status is done (loop above). On failed, read reason_code.

  3. Read the indexing result

    GET /api/v1/videos/{video_id} returns the whole-video result: AI summary, genre, IAB categories (iab_content_leaf, iab_audience_interest_leaf, iab_audience_purchase_intent_leaf, iab_ad_product_leaf), the GARM assessment in garm_video, transcript_coverage, the first page of scenes, lightweight scene_markers for every scene, and aggregations (brands, products, people, locations, audio events, music moods, on-screen text, mood timeline, actions, human activities, weather).

    curl -s "https://video.pyler.ai/api/v1/videos/$VIDEO_ID?scene_limit=100" \
    -H "Authorization: Bearer $VUF_API_KEY"
    Response (200, truncated)
    {
    "id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
    "title": "demo clip",
    "duration_ms": 61234,
    "source_url": "https://example.com/clip.mp4",
    "created_at": "2026-10-01T10:00:00Z",
    "status": "done",
    "summary": "A family builds a snowman in a suburban backyard...",
    "genre": ["family"],
    "iab_content_leaf": ["Family and Relationships"],
    "garm_video": [],
    "external_content_id": null,
    "reason_code": null,
    "indexed_at": "2026-10-01T10:05:12Z",
    "index_trigger": "initial",
    "job_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "reindex_run_id": null,
    "transcript_coverage": 0.82,
    "scene_count": 42,
    "scenes": [
    {
    "id": "9c0d1e2f-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
    "video_id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
    "scene_index": 0,
    "start_ms": 0,
    "end_ms": 4200,
    "caption": "A child in a red coat packs snow into a ball in a backyard.",
    "transcript": "Okay, let's make the bottom one really big.",
    "text_on_screen": [],
    "valence": 4,
    "arousal": 3,
    "music_mood": null,
    "audio_events": ["laughter"],
    "garm_categories": [],
    "brands": [],
    "product_categories_visible": [],
    "people": [],
    "locations": ["backyard"],
    "action": ["packing snow"],
    "human_activity": ["playing"],
    "weather": "snow",
    "keyframe_url": "https://...",
    "clip_url": "https://..."
    }
    ],
    "scene_markers": [
    { "id": "9c0d1e2f-...", "scene_index": 0, "start_ms": 0, "end_ms": 4200,
    "garm_level": "none", "garm_categories": [] }
    ],
    "aggregations": {
    "brands": [],
    "products": [],
    "locations": [{ "name": "backyard", "count": 12 }],
    "people": [],
    "audio_events": [{ "name": "laughter", "count": 5 }],
    "music_moods": [],
    "text_on_screen": [],
    "mood_timeline": [{ "scene_index": 0, "start_ms": 0, "valence": 4, "arousal": 3 }],
    "actions": [{ "name": "packing snow", "count": 3 }],
    "human_activities": [{ "name": "playing", "count": 20 }],
    "weather_counts": [{ "name": "snow", "count": 42 }]
    }
    }

    scenes holds the first scene_limit scenes (1–200, default 50, offset with scene_offset); scene_markers always covers every scene. source_url is only populated for http(s) origins.

  4. Page through the remaining scenes

    GET /api/v1/videos/{video_id}/scenes returns fully hydrated scenes (same shape as above) with offset / limit (1–200, default 50) and total.

    curl -s "https://video.pyler.ai/api/v1/videos/$VIDEO_ID/scenes?offset=50&limit=50" \
    -H "Authorization: Bearer $VUF_API_KEY"
    warning

    An unknown video_id returns an empty page (total: 0), not a 404. Check the video exists with GET /api/v1/videos/{video_id} if you need to tell "no such video" from "a video with no scenes".

  5. Optional: stream scenes while indexing runs

    GET /api/v1/videos/{video_id}/scenes/stream is NDJSON, one object per line, and does not wait for the whole video to finish:

    • {"event": "scene", "scene": {...}} — a newly analyzed scene (same shape as the /scenes items, never repeated)
    • {"event": "keepalive"} — sent when idle; ignore
    • {"event": "done", "job_status": "done"} — indexing finished; job_status may also be failed, or null for a video without an indexing job. The connection closes after this line
    • {"event": "timeout"} — server-side maximum stream duration reached; reconnect for a fresh snapshot
    curl -N "https://video.pyler.ai/api/v1/videos/$VIDEO_ID/scenes/stream" \
    -H "Authorization: Bearer $VUF_API_KEY"

    Scene order follows analysis completion, not scene_index. Connecting to an already indexed video replays all scenes immediately and closes with done. If the connection ends without a terminal line, reconnect; already analyzed scenes replay first. An unknown video_id returns 404 here.

Batches​

A list of URLs​

POST /api/v1/videos/batch with kind: "url" submits up to 500 rows in one call. Each row has a url (max 2048 characters) and optional title (max 500). The 201 response's items array lines up 1:1 with your request, in request order, so you can map source_url → video_id without a follow-up call.

curl -X POST https://video.pyler.ai/api/v1/videos/batch \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "url",
"items": [
{"url": "https://example.com/a.mp4", "title": "A"},
{"url": "https://example.com/b.mp4"}
]
}'
Response (201)
{
"batch_id": "7d2e4f60-1a2b-4c3d-9e8f-5a6b7c8d9e0f",
"declared_total": null,
"accepted": 2,
"skipped": 0,
"failed": 0,
"items": [
{ "source_url": "https://example.com/a.mp4", "video_id": "3f6c...", "job_id": "a1b2...",
"status": "submitted", "error": null, "created": true },
{ "source_url": "https://example.com/b.mp4", "video_id": "5e7d...", "job_id": "c3d4...",
"status": "submitted", "error": null, "created": false }
]
}

Per row:

  • status is submitted, skipped (rejected on validation, never submitted) or failed (passed validation but was not submitted).
  • error is a stable reason on skipped / failed rows (for example duplicate_url, blocked_target, dispatch_failed) and null on submitted.
  • created is true when this row created the video. A submitted row with created: false was linked to a video already being indexed for you.
  • A URL that appears twice in one request is skipped with error: "duplicate_url" but repeats the first row's video_id / job_id.

accepted counts rows handed to the pipeline, including rows linked to an existing job; count items[].created to know how many were new. submitted means "handed to the pipeline", not "indexed": GET /api/v1/videos/{video_id} stays authoritative. failed rows are meant to be resubmitted; a dispatch_failed URL has its job marked failed (best-effort), so nothing was indexed.

Sending more than 500 items fails schema validation with a 422 whose detail is an array of field errors. A kind other than file / url, or non-empty items with kind: "file", returns 400.

Several files in one request​

POST /api/v1/videos/batch/upload multipart-uploads several file fields at once; each file is indexed independently, and a failure in one does not abort the rest.

curl -X POST https://video.pyler.ai/api/v1/videos/batch/upload \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@a.mp4" -F "file=@b.mp4" -F "file=@c.mp4"

The 202 body carries only counts: batch_id, accepted, skipped (rejected up front: bad extension, empty file) and failed. Per-file video_id and job_id come from GET /api/v1/videos/batch/{batch_id}.

warning

Recommended: about 10 files or fewer per request; split larger uploads across requests. There is no hard cap on file count, only a safety backstop that returns 413 above 500 files, counted per request and across a reused batch_id.

Recovering from a request that failed partway (for example a dropped connection):

  1. Poll GET /api/v1/videos/batch/{batch_id} first. Uploads have no natural dedup key, so files already recorded will not be deduplicated if resent.
  2. Resend only the files still missing. Drop any rollup item whose error is dispatch_failed from that set: its video row is already committed, and resending indexes the file twice.
  3. If the upload call did return, trust its accepted / skipped / failed counts over the batch rollup. A rollup listing fewer items than accepted means a bookkeeping write was lost, not that a file went unsubmitted.

To be able to poll under a known id even if the upload call itself never returns, pre-create an empty batch with POST /api/v1/videos/batch (kind: "file", optional informational total 0–500) and pass its batch_id as a form field on the upload.

A csv or xlsx sheet of URLs​

POST /api/v1/videos/batch/upload-sheet parses a .csv or .xlsx with a url column (and an optional title column) and submits every valid row in one call.

curl -X POST https://video.pyler.ai/api/v1/videos/batch/upload-sheet \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@catalogue.csv"
Response (201)
{
"batch_id": "7d2e4f60-1a2b-4c3d-9e8f-5a6b7c8d9e0f",
"accepted": 2,
"skipped": 1,
"failed": 0,
"rejected_before_submit": 1,
"rejected_rows": [
{ "row": 4, "value": "https://example.com/a.mp4", "reason": "duplicate_url" }
],
"items": [
{ "source_url": "https://example.com/a.mp4", "video_id": "3f6c...", "job_id": "a1b2...",
"status": "submitted", "error": null, "created": true },
{ "source_url": "https://example.com/b.mp4", "video_id": "5e7d...", "job_id": "c3d4...",
"status": "submitted", "error": null, "created": true }
]
}

Rows that fail URL-format validation, duplicate an earlier row, or have an over-long url/title are dropped while parsing. They are counted in rejected_before_submit, folded into skipped, and listed (up to 100) in rejected_rows with a 1-based row number (header is row 1), the raw value and a stable reason such as duplicate_url or url_too_long. These rows are never recorded in the batch, so GET /api/v1/videos/batch/{batch_id} counts submitted rows only. Every row that was submitted comes back in items, in sheet order, with the same per-row fields as a URL batch.

Sheet-specific errors:

StatusWhenBody
413File exceeds the 10 MB sheet size cap, or the sheet has more valid URL rows than one batch accepts (currently 500). Split into multiple sheets.detail, code
422Not .csv / .xlsx, no url column, malformed or corrupted file, no valid URL rows, more than 10000 data rows, too many columns, or a sheet that expands past the uncompressed-size cap.detail, code

When code is no_valid_urls, the error body carries one extra sibling key, rejected: the number of rows dropped while parsing (never recorded in any batch).

422 with code no_valid_urls
{ "detail": "no valid url rows in sheet", "code": "no_valid_urls", "rejected": 17 }

Reading the batch receipt​

GET /api/v1/videos/batch/{batch_id} is the submission receipt for a batch created by POST /api/v1/videos/batch or POST /api/v1/videos/batch/upload: counts rolls up per-status totals and items lists each row with its status, error, video_id and job_id.

Response (200, truncated)
{
"batch_id": "7d2e4f60-1a2b-4c3d-9e8f-5a6b7c8d9e0f",
"kind": "file",
"total": 3,
"counts": { "pending": 0, "submitted": 2, "skipped": 0, "failed": 1 },
"items": [
{ "id": "...", "source_url": null, "storage_path": "s3://...", "title": "a.mp4",
"status": "submitted", "error": null, "video_id": "3f6c...", "job_id": "a1b2...",
"indexed_at": null, "index_trigger": null, "reindex_run_id": null }
]
}
warning

This is not indexing progress. Each item's status is written once, at submission time, and never updated: submitted means "accepted", never "finished indexing". Polling this endpoint and waiting for completion waits forever. counts.pending is synthetic (total minus recorded items, i.e. slots declared at creation that no upload has filled) and never drains on its own; send the missing files instead.

For the indexing outcome, follow the per-item ids: GET /api/v1/videos/{video_id} for the video's status and result, or GET /api/v1/jobs/{job_id} for the latest attempt's progress and failure reason. job_id here is the video's latest job, resolved when you read it (including one still in progress), so it changes after a re-index; indexed_at, index_trigger and reindex_run_id describe the most recent finished attempt.

MRSS feeds​

Feeds are registered from the dashboard; the API lists and manages them. Each feed is polled on its poll_interval_s and every item it yields is submitted for indexing. Videos ingested from a feed carry the feed item's id back as external_content_id, and GET /api/v1/videos?content_id=... finds them by that id (exact match).

CallWhat it does
GET /api/v1/mrss-feedsFeeds registered to your team, newest first: id, feed_url, enabled, plan_blocked, poll_interval_s, last_polled_at, next_poll_at, label.
GET /api/v1/mrss-feeds/{feed_id}One feed plus created_at and counts: submission counts (pending / submitted / failed) and indexing counts (indexed_done / indexed_running / indexed_failed).
PATCH /api/v1/mrss-feeds/{feed_id}Partially update enabled, poll_interval_s and/or label. Send at least one; an empty body is 422.
DELETE /api/v1/mrss-feeds/{feed_id}Stop syncing and remove the feed. 204, no body.
GET /api/v1/mrss-feeds/{feed_id}/itemsItems collected from the feed, filtered by status (submission state) and/or index_status (indexing state), paged with limit (1–200, default 50) / offset.
# Switch a feed to daily polling and give it a label
curl -X PATCH https://video.pyler.ai/api/v1/mrss-feeds/$FEED_ID \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"poll_interval_s": 86400, "label": "Partner catalogue"}'

# Items whose indexing failed
curl -s "https://video.pyler.ai/api/v1/mrss-feeds/$FEED_ID/items?index_status=failed&limit=100" \
-H "Authorization: Bearer $VUF_API_KEY"

Rules worth knowing:

  • poll_interval_s must be 3600, 21600 or 86400 (hourly / every 6 hours / daily); anything else is 422 with code: mrss.bad_interval. Changing it immediately recomputes the next poll into a fresh UTC slot.
  • label is at most 200 characters with no control characters (mrss.bad_label). An empty or whitespace-only label clears it and the feed falls back to showing its feed_url.
  • The feed URL cannot be changed: a different URL is a different feed, so register that one instead.
  • Re-enabling a feed whose plan no longer permits MRSS returns 422 with code: mrss.disabled; disabling is always allowed. plan_blocked on the feed exposes that state.
  • Unknown ids and feeds owned by another team both return 404. Invalid status / index_status filter values return 422 with mrss.bad_status / mrss.bad_index_status.
  • Deleting is permanent. Videos already indexed from the feed stay in your library, remain searchable and keep their external_content_id; items mid-indexing at deletion may still finish. Re-registering the same URL later creates a fresh feed and indexes every item again.

Each feed item (FeedItemOut) carries guid, title, title_en, source_url, status, error, video_id, delivery_status, delivered_at, delivery_error, index_status, index_error, index_reason_code, first_seen_at, pub_date and duration_s.

Custom summaries​

Once a video has its standard summary, POST /api/v1/videos/{video_id}/custom-summaries asks for a second summary written to your own instructions. The standard result is never changed.

curl -X POST https://video.pyler.ai/api/v1/videos/$VIDEO_ID/custom-summaries \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"instructions": "One-line logline, tone and pacing, setting, themes. Neutral tone.",
"cast": [{"name": "Alex Rivera", "aliases": ["A. Rivera"]}],
"language": "en"
}'
Response (202)
{
"custom_summary_id": "cs_0f3c5a1e9b7d4c2a8e6f1b3d5a7c9e24",
"instruction_id": "ins_7f3a2b1c9d8e4f5a6b7c8d9e0f1a2b3c",
"status": "queued",
"base_version": "std-v3"
}

Request fields:

instructionsstringbodyrequired

Free text, up to 2000 characters. Length of the summary is fixed by the service.

castCastMember[]body

Up to 100 entries of name (1–200 characters) and optional aliases (up to 20). These are the only person names the summary may use; with no cast, the summary is instructed to write no person names. Sections in which a name outside cast is detected come back as null. The check is best-effort (it can miss names, and can blank a section that merely contains a place or brand name), so review the result where names matter.

languagestringbodydefault: en

Language tag (up to 35 characters). Takes precedence over any language mentioned in instructions.

Poll GET /api/v1/videos/{video_id}/custom-summaries/{custom_summary_id}. status is queued while it is prepared, then ready, rejected (instructions out of scope; see message) or failed (request again). When ready, summary holds the sections logline, tone_pacing, setting and themes, each a string or null; notes says why a section is null. base_summary is the standard summary the request was based on and base_version its version (null once that version is deleted).

Response (200)
{
"status": "ready",
"custom_summary_id": "cs_0f3c5a1e9b7d4c2a8e6f1b3d5a7c9e24",
"instruction_id": "ins_7f3a2b1c9d8e4f5a6b7c8d9e0f1a2b3c",
"base_version": "std-v3",
"summary": {
"logline": "A family turns a snow day into a backyard competition.",
"tone_pacing": "Warm and unhurried.",
"setting": null,
"themes": "Family, play, winter."
},
"notes": ["setting: no place is identifiable"],
"message": null,
"base_summary": "A family builds a snowman in a suburban backyard..."
}

GET /api/v1/videos/{video_id}/custom-summaries lists your organization's requests for the video, newest first, paged with limit (1–100, default 50) and cursor from next_cursor.

Behaviour to plan for:

  • When the same instructions were already judged out of scope, the POST already answers with status: rejected and nothing is generated.
  • 422 with code: custom_summary_no_base_summary means the video has no standard summary yet; custom_summary_no_org means the key belongs to no organization; quota_exceeded_summaries means the summary quota is exhausted and nothing was created.
  • 503 means the request was not queued; send it again. Each retry creates a new request.
  • Each request counts as one summary against your organization's summary quota (summaries in GET /api/v1/usage), not against the video quota, including one that ends as rejected. A request that ends as failed, or is still queued when its video is deleted, is returned to the quota.

List your videos​

GET /api/v1/videos lists videos owned by your account, newest first. It is scoped to your account only; the shared sample corpus is excluded.

curl -s "https://video.pyler.ai/api/v1/videos?limit=20&status=done&length_bucket=short" \
-H "Authorization: Bearer $VUF_API_KEY"
Response (200, truncated)
{
"items": [
{
"id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"title": "demo clip",
"duration_ms": 61234,
"status": "done",
"is_user_upload": true,
"org_id": "org_...",
"created_at": "2026-10-01T10:00:00Z",
"summary": "A family builds a snowman...",
"genre": ["family"],
"iab_content_leaf": ["Family and Relationships"],
"garm_video": [],
"keyframe_url": "https://...",
"external_content_id": null,
"indexed_at": "2026-10-01T10:05:12Z",
"index_trigger": "initial",
"job_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"reindex_run_id": null
}
],
"next_cursor": null
}

Pagination is cursor-based: limit is 1–100 (default 50); pass the previous response's next_cursor as cursor. A null next_cursor means the last page.

FilterTypeNotes
statusstringVideo status.
genrestring[]Repeat the parameter for several values.
iab_t1string[]IAB Tier-1 content categories.
garm_min_levelstringBrand-safety floor.
duration_min_sec, duration_max_secintegerDuration bounds in seconds.
length_bucketstringshort, medium or long.
searchstringFree-text match.
content_idstringExact match on the MRSS external content id; an unknown id returns an empty list.
reindexed_sincedate-timeVideos whose most recent finished indexing attempt is a re-index that ended at or after this time, failed re-indexes included. Needs a UTC offset (2026-10-01T00:00:00Z; write + as %2B).
reindex_run_iduuidEvery video with a job in that re-index run, including one still running.

Videos indexed only once match neither re-index filter. A malformed UUID, or a timestamp without a UTC offset, returns 422.

Each item carries the indexing-history fields indexed_at, index_trigger (initial or reindex), job_id and reindex_run_id, all describing the most recent finished attempt and all null while the first indexing is still running. A video that was already done keeps that status even if a later attempt fails, so compare indexed_at with the value you last saw and check the outcome through job_id.

Next steps​