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.
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 have | Call | Reference |
|---|---|---|
| One public URL | POST /api/v1/videos | Submit a video |
| One local file | POST /api/v1/videos/upload | Upload a file |
| A parsed list of URLs (up to 500) | POST /api/v1/videos/batch with kind: "url" | Create a batch |
| Several local files | POST /api/v1/videos/batch/upload | Batch upload |
A .csv or .xlsx of URLs | POST /api/v1/videos/batch/upload-sheet | Sheet upload |
| An MRSS feed | Register it in the dashboard, then manage it under /api/v1/mrss-feeds | MRSS 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
- Python
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"}'
import os, requests
BASE = "https://video.pyler.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"}
r = requests.post(f"{BASE}/videos", headers=HEADERS,
json={"url": "https://example.com/clip.mp4", "title": "demo clip"})
r.raise_for_status()
submit = r.json()
video_id, job_id = submit["video_id"], submit["job_id"]
{
"video_id": "3f6c1b2e-9a4d-4c7e-8b1f-2d5a6e7f8c90",
"job_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"created": true
}
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
- Python
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"
with open("clip.mp4", "rb") as f:
r = requests.post(f"{BASE}/videos/upload", headers=HEADERS,
files={"file": f}, data={"title": "demo clip"})
r.raise_for_status()
upload = r.json() # video_id, job_id, storage_path
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.
- cURL
- Python
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
import time
while True:
job = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS).json()
print(job["status"], job.get("done_scenes", 0), "/", job.get("total_scenes", 0))
if job["status"] in ("done", "failed"):
break
time.sleep(5)
if job["status"] == "failed":
raise RuntimeError(f"{job['reason_code']}: {job['reason']}")
{
"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.
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
- Submit the videocurl -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-2d5a6e7f8c90export JOB_ID=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
- Wait for the job
Poll
GET /api/v1/jobs/$JOB_IDuntilstatusisdone(loop above). Onfailed, readreason_code. - Read the indexing result
GET /api/v1/videos/{video_id}returns the whole-video result: AIsummary,genre, IAB categories (iab_content_leaf,iab_audience_interest_leaf,iab_audience_purchase_intent_leaf,iab_ad_product_leaf), the GARM assessment ingarm_video,transcript_coverage, the first page ofscenes, lightweightscene_markersfor every scene, andaggregations(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 }]}}scenesholds the firstscene_limitscenes (1–200, default 50, offset withscene_offset);scene_markersalways covers every scene.source_urlis only populated for http(s) origins. - Page through the remaining scenes
GET /api/v1/videos/{video_id}/scenesreturns fully hydrated scenes (same shape as above) withoffset/limit(1–200, default 50) andtotal.- cURL
- Python
curl -s "https://video.pyler.ai/api/v1/videos/$VIDEO_ID/scenes?offset=50&limit=50" \-H "Authorization: Bearer $VUF_API_KEY"scenes, offset, limit = [], 0, 200while True:page = requests.get(f"{BASE}/videos/{video_id}/scenes", headers=HEADERS,params={"offset": offset, "limit": limit}).json()scenes.extend(page["items"])offset += limitif offset >= page["total"]:breakwarningAn unknown
video_idreturns an empty page (total: 0), not a404. Check the video exists withGET /api/v1/videos/{video_id}if you need to tell "no such video" from "a video with no scenes". - Optional: stream scenes while indexing runs
GET /api/v1/videos/{video_id}/scenes/streamis 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/scenesitems, never repeated){"event": "keepalive"}— sent when idle; ignore{"event": "done", "job_status": "done"}— indexing finished;job_statusmay also befailed, ornullfor 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
- Python
curl -N "https://video.pyler.ai/api/v1/videos/$VIDEO_ID/scenes/stream" \-H "Authorization: Bearer $VUF_API_KEY"import jsonwith requests.get(f"{BASE}/videos/{video_id}/scenes/stream", headers=HEADERS, stream=True) as r:for line in r.iter_lines():if not line:continueevt = json.loads(line)if evt["event"] == "scene":print(evt["scene"]["scene_index"], evt["scene"]["caption"])elif evt["event"] in ("done", "timeout"):breakScene order follows analysis completion, not
scene_index. Connecting to an already indexed video replays all scenes immediately and closes withdone. If the connection ends without a terminal line, reconnect; already analyzed scenes replay first. An unknownvideo_idreturns404here.
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
- Python
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"}
]
}'
rows = [{"url": "https://example.com/a.mp4", "title": "A"},
{"url": "https://example.com/b.mp4"}]
r = requests.post(f"{BASE}/videos/batch", headers=HEADERS,
json={"kind": "url", "items": rows})
r.raise_for_status()
batch = r.json()
by_url = {it["source_url"]: it["video_id"] for it in batch["items"]}
{
"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:
statusissubmitted,skipped(rejected on validation, never submitted) orfailed(passed validation but was not submitted).erroris a stable reason onskipped/failedrows (for exampleduplicate_url,blocked_target,dispatch_failed) andnullonsubmitted.createdistruewhen this row created the video. Asubmittedrow withcreated: falsewas linked to a video already being indexed for you.- A URL that appears twice in one request is
skippedwitherror: "duplicate_url"but repeats the first row'svideo_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
- Python
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"
paths = ["a.mp4", "b.mp4", "c.mp4"]
files = [("file", (p, open(p, "rb"))) for p in paths]
r = requests.post(f"{BASE}/videos/batch/upload", headers=HEADERS, files=files)
r.raise_for_status()
rollup = r.json() # batch_id, accepted, skipped, failed
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}.
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):
- 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. - Resend only the files still missing. Drop any rollup item whose
errorisdispatch_failedfrom that set: its video row is already committed, and resending indexes the file twice. - If the upload call did return, trust its
accepted/skipped/failedcounts over the batch rollup. A rollup listing fewer items thanacceptedmeans 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
- Python
curl -X POST https://video.pyler.ai/api/v1/videos/batch/upload-sheet \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@catalogue.csv"
with open("catalogue.csv", "rb") as f:
r = requests.post(f"{BASE}/videos/batch/upload-sheet", headers=HEADERS, files={"file": f})
r.raise_for_status()
sheet = r.json()
for row in sheet["rejected_rows"]:
print("dropped row", row["row"], row["reason"], row["value"])
{
"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:
| Status | When | Body |
|---|---|---|
413 | File 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 |
422 | Not .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).
{ "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.
{
"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 }
]
}
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).
| Call | What it does |
|---|---|
GET /api/v1/mrss-feeds | Feeds 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}/items | Items collected from the feed, filtered by status (submission state) and/or index_status (indexing state), paged with limit (1–200, default 50) / offset. |
- cURL
- Python
# 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"
r = requests.patch(f"{BASE}/mrss-feeds/{feed_id}", headers=HEADERS,
json={"poll_interval_s": 86400, "label": "Partner catalogue"})
r.raise_for_status()
items = requests.get(f"{BASE}/mrss-feeds/{feed_id}/items", headers=HEADERS,
params={"index_status": "failed", "limit": 100}).json()
for it in items["items"]:
print(it["guid"], it["video_id"], it["index_reason_code"])
Rules worth knowing:
poll_interval_smust be3600,21600or86400(hourly / every 6 hours / daily); anything else is422withcode: mrss.bad_interval. Changing it immediately recomputes the next poll into a fresh UTC slot.labelis 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 itsfeed_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
422withcode: mrss.disabled; disabling is always allowed.plan_blockedon the feed exposes that state. - Unknown ids and feeds owned by another team both return
404. Invalidstatus/index_statusfilter values return422withmrss.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
- Python
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"
}'
r = requests.post(f"{BASE}/videos/{video_id}/custom-summaries", headers=HEADERS, json={
"instructions": "One-line logline, tone and pacing, setting, themes. Neutral tone.",
"cast": [{"name": "Alex Rivera", "aliases": ["A. Rivera"]}],
"language": "en",
})
r.raise_for_status()
cs_id = r.json()["custom_summary_id"]
while True:
cs = requests.get(f"{BASE}/videos/{video_id}/custom-summaries/{cs_id}", headers=HEADERS).json()
if cs["status"] != "queued":
break
time.sleep(5)
{
"custom_summary_id": "cs_0f3c5a1e9b7d4c2a8e6f1b3d5a7c9e24",
"instruction_id": "ins_7f3a2b1c9d8e4f5a6b7c8d9e0f1a2b3c",
"status": "queued",
"base_version": "std-v3"
}
Request fields:
instructionsstringbodyrequiredFree text, up to 2000 characters. Length of the summary is fixed by the service.
castCastMember[]bodyUp 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: enLanguage 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).
{
"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
POSTalready answers withstatus: rejectedand nothing is generated. 422withcode: custom_summary_no_base_summarymeans the video has no standard summary yet;custom_summary_no_orgmeans the key belongs to no organization;quota_exceeded_summariesmeans the summary quota is exhausted and nothing was created.503means 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 (
summariesinGET /api/v1/usage), not against the video quota, including one that ends asrejected. A request that ends asfailed, or is stillqueuedwhen 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
- Python
curl -s "https://video.pyler.ai/api/v1/videos?limit=20&status=done&length_bucket=short" \
-H "Authorization: Bearer $VUF_API_KEY"
cursor, videos = None, []
while True:
page = requests.get(f"{BASE}/videos", headers=HEADERS,
params={"limit": 100, "status": "done", "cursor": cursor}).json()
videos.extend(page["items"])
cursor = page.get("next_cursor")
if not cursor:
break
{
"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.
| Filter | Type | Notes |
|---|---|---|
status | string | Video status. |
genre | string[] | Repeat the parameter for several values. |
iab_t1 | string[] | IAB Tier-1 content categories. |
garm_min_level | string | Brand-safety floor. |
duration_min_sec, duration_max_sec | integer | Duration bounds in seconds. |
length_bucket | string | short, medium or long. |
search | string | Free-text match. |
content_id | string | Exact match on the MRSS external content id; an unknown id returns an empty list. |
reindexed_since | date-time | Videos 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_id | uuid | Every 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.