Ads
The ad registry is the shared home for ad creatives. You register a creative once (file upload or URL / VAST), receive an ad_id, and pass that id to ad matching and brand-safety screening instead of re-uploading the file for every operation.
POST /api/v1/ads/upload — synchronous, the ad_id is usable immediately.
POST /api/v1/ads — asynchronous, poll until ready.
POST /api/v1/ads/match — stream the videos a creative fits into.
How registration works
Two ways in, one result: an AdRecord with an id (the ad_id) and a status.
| Path | Endpoint | Behaviour | status on response |
|---|---|---|---|
| File upload | POST /api/v1/ads/upload (multipart) | Stored synchronously | ready |
| URL / VAST | POST /api/v1/ads (JSON) | Returns 202 immediately; a background resolver downloads and validates the media | resolving, then ready or failed |
upload (file) -> ready
register (url/vast) -> resolving -> ready (storage_path, duration_ms filled)
-> failed (error carries the reason)
Rules that apply to every creative:
- Length cap: 120 seconds. Longer, unprobeable or empty media is refused.
- Stored as-is. The registry does not transcode; codec normalization happens in the analysis stage.
- Team-scoped. Listing and lookup are scoped to your organization. An unknown
ad_idand one owned by another team both return404.
Upload a file (synchronous)
POST /api/v1/ads/upload takes multipart/form-data. The response is already status: ready, so the ad_id can be used right away.
filefilebodyrequiredAd video file (mp4 / mov / ...), up to 120 seconds.
titlestringbodyOptional display title.
brand_hintsstringbodyComma- or newline-separated brand hints.
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/ads/upload \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@./ads/summer_campaign.mp4" \
-F "title=Summer campaign" \
-F "brand_hints=acme"
import os, requests
with open("./ads/summer_campaign.mp4", "rb") as f:
resp = requests.post(
"https://video.pyler.ai/api/v1/ads/upload",
headers={"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"},
files={"file": ("summer_campaign.mp4", f, "video/mp4")},
data={"title": "Summer campaign", "brand_hints": "acme"},
timeout=120,
)
resp.raise_for_status()
ad = resp.json()
print(ad["id"], ad["status"]) # ..., ready
Response — 202 Accepted:
{
"id": "11111111-1111-1111-1111-111111111111",
"title": "Summer campaign",
"storage_path": "s3://…",
"duration_ms": 15000,
"brand_hints": ["acme"],
"source_type": "file",
"status": "ready",
"error": null,
"uploaded_at": "2026-07-01T09:00:00Z"
}
The upload route answers 202 even though the record is already ready. Check status, not the HTTP code, when you want to know whether the creative is usable.
Register from a URL (asynchronous)
POST /api/v1/ads takes a JSON body. It returns 202 with status: resolving at once; a background resolver downloads and validates the media and then flips the record to ready (with storage_path and duration_ms) or failed (with error). Poll GET /api/v1/ads/{ad_id} for progress.
source_typestringbodyrequiredOne of url, vast_url, vast_xml. file is not accepted here — upload files via POST /api/v1/ads/upload.
source_urlstringbodyrequiredMedia URL, VAST tag URL, or (for vast_xml) the raw VAST XML.
titlestringbodyOptional display title.
brand_hintsstring[]bodyBrand hints as an array of strings.
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/ads \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_type": "url",
"source_url": "https://cdn.example.com/ad.mp4",
"title": "Summer campaign",
"brand_hints": ["acme"]
}'
import os, time, requests
BASE = "https://video.pyler.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"}
ad = requests.post(
f"{BASE}/ads",
headers=HEADERS,
json={"source_type": "url", "source_url": "https://cdn.example.com/ad.mp4",
"title": "Summer campaign", "brand_hints": ["acme"]},
timeout=60,
).json()
# Poll until the resolver finishes.
while ad["status"] == "resolving":
time.sleep(2)
ad = requests.get(f"{BASE}/ads/{ad['id']}", headers=HEADERS, timeout=30).json()
if ad["status"] == "failed":
raise RuntimeError(ad["error"])
print(ad["id"], ad["duration_ms"])
Response — 202 Accepted:
{
"id": "22222222-2222-2222-2222-222222222222",
"title": "Summer campaign",
"storage_path": null,
"duration_ms": null,
"brand_hints": ["acme"],
"source_type": "url",
"status": "resolving",
"error": null,
"uploaded_at": "2026-07-01T09:00:00Z"
}
VAST input
POST /api/v1/ads also accepts VAST: source_type: "vast_url" with a VAST tag URL, or source_type: "vast_xml" with the raw XML in source_url. The resolver unwraps Inline and Wrapper ads to the media file and then follows the same download → probe → store path as a plain URL registration. The response contract is identical: the same AdRecord, the same resolving → ready / failed polling.
- Supported: VAST 2.0 / 3.0 / 4.0, Inline and Wrapper (
VASTAdTagURIfollowed recursively, depth ≤ 5). - MediaFile choice: the highest-resolution
video/mp4MediaFile; VPAID candidates are skipped. - Submit-time gate: before accepting with
202, the VAST is resolved synchronously and the MediaFile's reachability is checked. Inputs that will definitely fail are rejected immediately with422and acodeofvast_invalid(VMAP, VPAID-only, no MediaFile, wrapper depth exceeded, invalid XML, oversized response),url_unreachable(MediaFile answers403/404/410) orblocked_target(internal or private address). - Transient errors are accepted. A
429,5xx, timeout or network error on the tag or MediaFile does not reject the request; it is accepted with202and left to the background resolver. If it still fails there, the record ends upfailedwitherror.
curl -X POST https://video.pyler.ai/api/v1/ads \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"source_type": "vast_url", "source_url": "https://ads.example.com/vast.xml"}'
# -> 202 resolving; poll GET /api/v1/ads/{ad_id} until ready or failed
List and inspect creatives
GET /api/v1/adslists your team's creatives, newest first, as{ "ads": [AdRecord, ...], "total": <int> }. Query parameters:limit(1–200, default 50) andoffset(default 0).GET /api/v1/ads/{ad_id}returns oneAdRecord. It is also the polling endpoint for asynchronous registrations.
curl "https://video.pyler.ai/api/v1/ads?limit=50&offset=0" \
-H "Authorization: Bearer $VUF_API_KEY"
curl https://video.pyler.ai/api/v1/ads/$AD_ID \
-H "Authorization: Bearer $VUF_API_KEY"
AdRecord fields
idstringThe ad_id. This is what matching and screening reference.
titlestring | nullDisplay title.
storage_pathstring | nullInternal object key of the stored file. null while resolving. Not needed for any API call; use GET /api/v1/ads/{ad_id}/stream to play the creative.
duration_msinteger | nullCreative length in milliseconds. null while resolving.
brand_hintsstring[]Brand hints supplied at registration.
source_typestringHow the creative entered the registry: file, url, vast_url or vast_xml.
statusstringresolving, ready or failed.
errorstring | nullFailure reason when status is failed.
uploaded_atstringISO 8601 timestamp.
org_idstring | nullOwning organization.
thumbnail_urlstring | nullThumbnail of the creative, when available.
Analysis results
GET /api/v1/ads/{ad_id}/analysis returns what the creative is selling, who it targets and how it reads on brand-safety axes, without running a match. The values are the same ones the ad_analysis event of POST /api/v1/ads/match carries.
Analysis normally runs at registration time, so a ready creative answers 200 straight away. If it has not run yet, the endpoint returns 202 with { "ad_id": "...", "status": "pending" } and starts it. Poll the same URL until it returns 200; a 30-second creative takes roughly 30 seconds, longer ones proportionally more. Polling does not start duplicate analyses.
curl https://video.pyler.ai/api/v1/ads/$AD_ID/analysis \
-H "Authorization: Bearer $VUF_API_KEY"
{
"ad_id": "11111111-1111-1111-1111-111111111111",
"status": "ready",
"summary": "Premium smartphone feature spot with a carrier-exclusive bundle.",
"primary_brand": "nimbus mobile",
"primary_product": "smartphone",
"brands": ["Nimbus Mobile", "Orbit"],
"products": ["smartphone", "poster", "water bottle"],
"secondary_brands": ["Orbit"],
"secondary_products": ["water bottle"],
"genre": [],
"iab_content_leaf": ["Smartphones"],
"iab_ad_product_leaf": ["Smartphones"],
"iab_audience_interest_leaf": ["Technology"],
"iab_audience_purchase_intent_leaf": ["smartphone"],
"garm_video": [],
"ad_profile": {
"ad_type": "brand_awareness",
"pacing": "medium",
"tone_register": ["aspirational", "informative"],
"cta_type": "brand_recall",
"regulatory_class": "none"
},
"transcript_excerpt": "...",
"audio_events": ["speech"],
"music_moods": ["upbeat"]
}
| Field | Type | Notes |
|---|---|---|
status | string | ready (analysis present) or pending (202, poll). |
primary_brand / primary_product | string | null | The single thing the ad actually advertises. |
brands / products | string[] | Everything that appears on screen. A coffee cup beside the advertised laptop lands here, not in primary_product. |
secondary_brands / secondary_products | string[] | Appear in the ad but are not what it sells. |
iab_content_leaf, iab_ad_product_leaf, iab_audience_interest_leaf, iab_audience_purchase_intent_leaf | string[] | The four IAB axes: content, ad product, audience interest, purchase intent. |
garm_video | GarmEntry[] | GARM brand-safety categories detected in the creative. Each entry has category, level (high / medium / low), reasoning and time_ranges. |
ad_profile | object | null | ad_type, pacing, tone_register, cta_type, regulatory_class. |
transcript_excerpt, audio_events, music_moods | Audio-derived signals, useful to confirm the audio track was consumed. |
Scene-level captions are not exposed on this surface. See Taxonomies and verdicts for the IAB and GARM vocabularies.
| HTTP | When |
|---|---|
200 | Analysis available. |
202 | Not yet available; just started. Poll the same URL. |
404 | Unknown ad_id, or owned by another team. |
409 | Creative is not ready, or has no analyzable media (unknown duration). Polling will never succeed. |
5xx | The analysis lookup itself failed. Not masked as 202; do not keep polling. |
Stream the creative
GET /api/v1/ads/{ad_id}/stream redirects (307) to a presigned URL for the stored file. It returns 409 while the creative is not ready, because there is no stored file to stream yet.
The first hop needs the Authorization header, so it is not a URL you can drop into an HTML <video> tag directly (the browser cannot attach the header). Resolve it server-side and hand the presigned Location to the player:
r = requests.get(
f"{BASE}/ads/{ad_id}/stream",
headers=HEADERS,
allow_redirects=False,
timeout=30,
)
if r.status_code == 307:
video_src = r.headers["Location"] # valid for about one hour
Clients that follow redirects (curl -L, requests with the default allow_redirects=True) land on the presigned URL automatically. The presigned URL itself needs no header and expires after about an hour; see Data handling.
Reusing an ad_id
A registered creative with status: ready can be referenced by:
| Feature | Endpoint | Takes ad_id? |
|---|---|---|
| Ad matching | POST /api/v1/ads/match | Yes, in the JSON body. |
| Ad blocking (brand safety) | POST /api/v1.1/ad-blocking | Yes — { "ad_id": "...", "rules": "..." }. The v1 multipart route POST /api/v1/ad-blocking is unchanged. |
Referencing an ad_id that is not ready is rejected with 409. An unknown id, or one owned by another team, returns 404. The routes that take a registered ad_id as input live under the /api/v1.1 version root; both roots accept the same API key.
# 1) Register by URL -> ad_id
AD_ID=$(curl -s -X POST https://video.pyler.ai/api/v1/ads \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d '{"source_type": "url", "source_url": "https://cdn.example.com/ad.mp4"}' | jq -r .id)
# 2) Poll GET /api/v1/ads/$AD_ID until status is ready
# 3) Screen the registered creative (v1.1)
curl -X POST https://video.pyler.ai/api/v1.1/ad-blocking \
-H "Authorization: Bearer $VUF_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"ad_id\": \"$AD_ID\"}"
See Brand safety for the verdict shape.
Recipe: Ad matching (NDJSON streaming)
POST /api/v1/ads/match takes an ad_id and streams back the best-fit videos from your indexed corpus while the multi-stage retrieve + rerank pipeline runs, so partial results are usable before it finishes.
The response is application/x-ndjson: one JSON object per line, not a single JSON document. Parse it line by line and do not buffer the whole body.
ad_idstringbodyrequiredUUID of a registered creative (status: ready).
brand_hintsstring[]bodyManual brand keywords folded into matching (case-insensitive). Use when the captioner missed a logo, for example a small end-card brand.
per_query_kintegerbodydefault: 100ANN budget per retrieval lane (2–400).
max_candidatesintegerbodydefault: 100Candidate pool size for the final rerank (5–400). Higher is more thorough but slower.
max_per_videointegerbodydefault: 10Maximum matches per video (1–50).
- Register the creative
Upload or register as above and wait for
status: ready. Keep thead_id. - Open the stream
Use a client that does not buffer the response (
curl -N,requestswithstream=True).- cURL
- Python
curl -N -X POST https://video.pyler.ai/api/v1/ads/match \-H "Authorization: Bearer $VUF_API_KEY" \-H "Content-Type: application/json" \-d "{\"ad_id\": \"$AD_ID\",\"brand_hints\": [\"Acme Snacks\", \"Crunchy Sticks\"],\"max_candidates\": 100,\"max_per_video\": 5}"import json, os, requestsresp = requests.post("https://video.pyler.ai/api/v1/ads/match",headers={"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"},json={"ad_id": os.environ["AD_ID"],"brand_hints": ["Acme Snacks", "Crunchy Sticks"],"max_candidates": 100,"max_per_video": 5,},stream=True,timeout=(10, 600),)resp.raise_for_status()events = []for line in resp.iter_lines():if not line:continue # keepalive / blank lineevent = json.loads(line)events.append(event)print(event["type"]) - Dispatch on the `type` field
Every line is a JSON object with a
typefield. They arrive in this order:typePayload When analyzingstageProgress markers while the creative is fetched and analysed. ad_analysisSame fields as GET /api/v1/ads/{ad_id}/analysisOnce, after the creative is analysed. agent_steptool,query,ok,noteEach query-planning step. metafacets,tags,brand_context,candidatesOnce, before the rerank — the candidate scenes. resultvideo_id,video_title,scene_id,scene_index,score(0–10),reason,timestamp_ms,timestamp_sec_in_clip,scene_start_ms,scene_end_ms,clip_urlOne per reranked scene, as the rerank finishes. Not sorted — order by scoreyourself.done— The stream is complete. errormessageA failure; the stream ends. timestamp_msis the absolute position in the video (integer milliseconds),timestamp_sec_in_clipthe offset inside that scene's clip. Handle the types you use and ignore any you do not recognise — the per-event payload is not frozen in the OpenAPI schema, so treat unknowntypevalues as forward-compatible.{"type": "analyzing", "stage": "fetch_ad"}{"type": "ad_analysis", "summary": "...", "iab": [...], "brands": [...], ...}{"type": "meta", "facets": {...}, "tags": [...], "candidates": [...]}{"type": "result", "video_id": "...", "scene_id": "...", "score": 8, "reason": "...", "timestamp_ms": 65000, ...}{"type": "done"}
A streaming response stays open as long as it keeps producing lines, so a long match can outlast the idle cut-off that ends a quiet request. Set a generous read timeout on the client side (timeout=(10, 600) above). See Usage and limits: timeouts and retries.
Errors
All errors use the shared { "detail": "...", "code": "..." } envelope. See Errors for the full list.
| HTTP | When |
|---|---|
202 | Registration or upload accepted. Async registrations need polling. |
401 / 403 | Missing or invalid key; key used outside the public API roots. |
404 | Unknown ad_id, or owned by another team. |
409 | Creative is not ready (analysis, stream, v1.1 ad-blocking). |
422 | Validation failure: a source_type the route does not accept, or a VAST that fails the submit-time gate (vast_invalid, url_unreachable, blocked_target). |
500 | Upstream error; usually safe to retry. |