Skip to main content

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.

How registration works​

Two ways in, one result: an AdRecord with an id (the ad_id) and a status.

PathEndpointBehaviourstatus on response
File uploadPOST /api/v1/ads/upload (multipart)Stored synchronouslyready
URL / VASTPOST /api/v1/ads (JSON)Returns 202 immediately; a background resolver downloads and validates the mediaresolving, 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_id and one owned by another team both return 404.

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.

filefilebodyrequired

Ad video file (mp4 / mov / ...), up to 120 seconds.

titlestringbody

Optional display title.

brand_hintsstringbody

Comma- or newline-separated brand hints.

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"

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

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_typestringbodyrequired

One of url, vast_url, vast_xml. file is not accepted here — upload files via POST /api/v1/ads/upload.

source_urlstringbodyrequired

Media URL, VAST tag URL, or (for vast_xml) the raw VAST XML.

titlestringbody

Optional display title.

brand_hintsstring[]body

Brand hints as an array of strings.

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

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 (VASTAdTagURI followed recursively, depth ≤ 5).
  • MediaFile choice: the highest-resolution video/mp4 MediaFile; 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 with 422 and a code of vast_invalid (VMAP, VPAID-only, no MediaFile, wrapper depth exceeded, invalid XML, oversized response), url_unreachable (MediaFile answers 403 / 404 / 410) or blocked_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 with 202 and left to the background resolver. If it still fails there, the record ends up failed with error.
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/ads lists your team's creatives, newest first, as { "ads": [AdRecord, ...], "total": <int> }. Query parameters: limit (1–200, default 50) and offset (default 0).
  • GET /api/v1/ads/{ad_id} returns one AdRecord. 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​

idstring

The ad_id. This is what matching and screening reference.

titlestring | null

Display title.

storage_pathstring | null

Internal 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 | null

Creative length in milliseconds. null while resolving.

brand_hintsstring[]

Brand hints supplied at registration.

source_typestring

How the creative entered the registry: file, url, vast_url or vast_xml.

statusstring

resolving, ready or failed.

errorstring | null

Failure reason when status is failed.

uploaded_atstring

ISO 8601 timestamp.

org_idstring | null

Owning organization.

thumbnail_urlstring | null

Thumbnail 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"]
}
FieldTypeNotes
statusstringready (analysis present) or pending (202, poll).
primary_brand / primary_productstring | nullThe single thing the ad actually advertises.
brands / productsstring[]Everything that appears on screen. A coffee cup beside the advertised laptop lands here, not in primary_product.
secondary_brands / secondary_productsstring[]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_leafstring[]The four IAB axes: content, ad product, audience interest, purchase intent.
garm_videoGarmEntry[]GARM brand-safety categories detected in the creative. Each entry has category, level (high / medium / low), reasoning and time_ranges.
ad_profileobject | nullad_type, pacing, tone_register, cta_type, regulatory_class.
transcript_excerpt, audio_events, music_moodsAudio-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.

HTTPWhen
200Analysis available.
202Not yet available; just started. Poll the same URL.
404Unknown ad_id, or owned by another team.
409Creative is not ready, or has no analyzable media (unknown duration). Polling will never succeed.
5xxThe 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
note

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:

FeatureEndpointTakes ad_id?
Ad matchingPOST /api/v1/ads/matchYes, in the JSON body.
Ad blocking (brand safety)POST /api/v1.1/ad-blockingYes — { "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.

warning

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_idstringbodyrequired

UUID of a registered creative (status: ready).

brand_hintsstring[]body

Manual brand keywords folded into matching (case-insensitive). Use when the captioner missed a logo, for example a small end-card brand.

per_query_kintegerbodydefault: 100

ANN budget per retrieval lane (2–400).

max_candidatesintegerbodydefault: 100

Candidate pool size for the final rerank (5–400). Higher is more thorough but slower.

max_per_videointegerbodydefault: 10

Maximum matches per video (1–50).

  1. Register the creative

    Upload or register as above and wait for status: ready. Keep the ad_id.

  2. Open the stream

    Use a client that does not buffer the response (curl -N, requests with stream=True).

    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
    }"
  3. Dispatch on the `type` field

    Every line is a JSON object with a type field. They arrive in this order:

    typePayloadWhen
    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 score yourself.
    done—The stream is complete.
    errormessageA failure; the stream ends.

    timestamp_ms is the absolute position in the video (integer milliseconds), timestamp_sec_in_clip the 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 unknown type values 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"}
tip

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.

HTTPWhen
202Registration or upload accepted. Async registrations need polling.
401 / 403Missing or invalid key; key used outside the public API roots.
404Unknown ad_id, or owned by another team.
409Creative is not ready (analysis, stream, v1.1 ad-blocking).
422Validation failure: a source_type the route does not accept, or a VAST that fails the submit-time gate (vast_invalid, url_unreachable, blocked_target).
500Upstream error; usually safe to retry.