Brand safety
Ad blocking is programmatic brand-safety screening for a single ad creative. You send the creative and an optional list of natural-language policy rules; the service returns a deterministic block / allow verdict, the GARM categories that fired and the rules that matched. One synchronous call, no polling, no callbacks.
POST /api/v1/ad-blocking — multipart upload, verdict in the response.
POST /api/v1.1/ad-blocking — JSON body with an ad_id from the ad registry.
GET /api/v1.1/ad-blocking — recover a result lost to a timeout.
AI-generated evidence, deterministic verdict, and where your own review comes in.
How screening works
A screening request does the following inside one synchronous call:
- Stores the creative and, if needed, transcodes it for the analyzer.
- Extracts audio and a keyframe.
- Runs a single multimodal pass that evaluates both the 11 GARM brand-safety categories and every policy rule: your team's saved blocklist plus any ad-hoc
rulesin the request. - Computes the verdict in code from the evidence:
block_decisionisblockif and only if at least one GARM category fired or at least one policy rule matched. Otherwise it isallow. - Persists the result and returns it.
Properties to design around:
- Synchronous. Typical latency is a few seconds, dominated by the multimodal pass.
- Deterministic verdict. The reasoning is the model's; the verdict is computed in code from
garmandblocklist.hits, so it always agrees with the evidence it ships with. The evidence is AI output and can be wrong in either direction, so treatblockas "hold for review" and keep a human in the loop for anything that matters. See Data handling. - Fail-closed. If the analysis itself errors, you still get
200withblock_decision: "block"andscreening_failed: true, so an integration that branches onblock_decisionalone holds the creative instead of serving it. - GARM is always evaluated, even with no rules. Rules are additional policy on top.
- Length cap. Creatives are capped at about 120 seconds.
Screen an uploaded file
POST /api/v1/ad-blocking takes multipart/form-data.
filefilebodyrequiredAd video file (mp4 / mov / ...), up to ~120 seconds.
titlestringbodyDisplay title for audit and listing.
rulesstringbodyAd-hoc policy rules in natural language, comma- or newline-separated. Evaluated in addition to your team's saved blocklist (managed in the dashboard's Ad Blocking tab).
- cURL
- Python
curl -X POST https://video.pyler.ai/api/v1/ad-blocking \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@./ads/crunchy-sticks-q4.mp4" \
-F "title=Crunchy Sticks Q4 brand campaign" \
-F "rules=no on-screen consumption of alcohol, no minors holding weapons"
import os, requests
with open("./ads/crunchy-sticks-q4.mp4", "rb") as f:
resp = requests.post(
"https://video.pyler.ai/api/v1/ad-blocking",
headers={"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"},
files={"file": ("crunchy-sticks-q4.mp4", f, "video/mp4")},
data={
"title": "Crunchy Sticks Q4 brand campaign",
"rules": "no on-screen consumption of alcohol, no minors holding weapons",
},
timeout=120,
)
resp.raise_for_status()
verdict = resp.json()
Response — 200 OK:
{
"id": "9f3c2a4d-0000-0000-0000-000000000000",
"ad_id": null,
"title": "Crunchy Sticks Q4 brand campaign",
"duration_ms": 18000,
"one_line": "Chocolate-stick snack; friends share boxes at a party.",
"garm": [
{
"category": "illegal_drugs_alcohol_tobacco_vaping",
"reasoning": "Bar scene at 0:12 shows a beer pour and a toast."
}
],
"blocklist": {
"rules": [
"no on-screen consumption of alcohol",
"no minors holding weapons"
],
"hits": [
{
"rule": "no on-screen consumption of alcohol",
"matched": true,
"evidence": "Characters drink beer at 0:14."
}
]
},
"block_decision": "block",
"explanation": "BLOCKED — GARM illegal_drugs_alcohol_tobacco_vaping; blocklist rule 'no on-screen consumption of alcohol'. Bar scene shows pour and toast.",
"screening_failed": false,
"created_at": "2026-06-16T10:23:01Z"
}
Screenings run on an uploaded file carry ad_id: null; only the v1.1 route below fills it.
Screen a registered creative (v1.1)
If the creative already lives in the ad registry, skip the re-upload. POST /api/v1.1/ad-blocking takes a JSON body with the ad_id (which must be status: ready) and optional rules. The screening pipeline, including codec normalization and audio-track analysis, is the same as the file path, so the same creative gets the same verdict either way. The verdict shape is identical, with ad_id filled in.
ad_idstringbodyrequiredRegistered ad creative UUID with status: ready.
rulesstring | nullbodyOptional ad-hoc policy rules, comma- or newline-separated.
- cURL
- Python
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\", \"rules\": \"no on-screen consumption of alcohol\"}"
import os, requests
resp = requests.post(
"https://video.pyler.ai/api/v1.1/ad-blocking",
headers={"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"},
json={"ad_id": os.environ["AD_ID"], "rules": "no on-screen consumption of alcohol"},
timeout=120,
)
resp.raise_for_status()
verdict = resp.json()
| HTTP | When |
|---|---|
200 | Verdict returned. |
404 | Unknown ad_id, owned by another team, or not a valid UUID (malformed ids are normalized to 404 so existence is not leaked). |
409 | The creative is not status: ready. |
/api/v1.1 is a separate version root alongside /api/v1 for the routes that take a registered ad_id as input. Both roots accept the same API key. The v1 multipart route is unchanged.
Look up past screenings
Every screening result is stored, so a response lost to a client timeout is not lost work. GET /api/v1.1/ad-blocking lists your team's results, newest first; filter by ad_id to retrieve the one you missed instead of re-submitting.
| Query | Type | Notes |
|---|---|---|
ad_id | UUID | Optional. Unknown id returns an empty list; a value that is not a UUID returns 422. |
limit | integer | 1–200, default 50. |
offset | integer | Default 0. |
curl "https://video.pyler.ai/api/v1.1/ad-blocking?ad_id=$AD_ID" \
-H "Authorization: Bearer $VUF_API_KEY"
{
"items": [
{
"id": "1a2b3c4d-0000-0000-0000-000000000000",
"ad_id": "c54eff39-0000-0000-0000-000000000000",
"block_decision": "allow",
"screening_failed": false,
"created_at": "2026-06-16T10:25:14Z"
}
],
"total": 1
}
Items carry the full verdict shape below. Screenings run on an uploaded file (the v1 multipart route) have ad_id: null and cannot be looked up this way; pass ad_id only for creatives registered through the ad registry. Past screenings are also listed in the dashboard's Ad Blocking tab.
The verdict shape
Both screening routes and the lookup return the same AdBlockingResult.
idstringrequiredServer-issued id of this screening.
ad_idstring | nullThe registered creative that was screened. null for file uploads. Use it with GET /api/v1.1/ad-blocking?ad_id=... to find the result again.
titlestring | nullDisplay title: the request title, else the filename.
duration_msinteger | nullCreative length in milliseconds.
one_linestring | nullOne-line summary of what the ad shows.
garmobject[]GARM categories that fired; an empty array when clean. Each item is { category, reasoning }. category is a stable slug from the table below and safe to switch on; reasoning cites the deciding cue, including the time it occurs (for example "Bar scene at 0:12").
blocklistobject{ rules, hits }. rules is the full set evaluated (team blocklist + ad-hoc). hits is [{ rule, matched, evidence }]; items with matched: true are the rules that fired.
block_decisionstringdefault: allowThe verdict: block if and only if garm is non-empty or any blocklist.hits[i].matched is true, else allow.
explanationstring | nullHuman-readable reasoning prefixed with the triggering categories and rules. For display, not parsing: switch on garm and blocklist.hits instead.
screening_failedbooleandefault: falsetrue when screening itself failed and the block is precautionary rather than a content verdict (fail-closed). Retry the call; never surface it to end users as a real block.
created_atstringrequiredISO 8601 UTC timestamp.
Branch on screening_failed, not on the explanation text. A real block and a failed screening both open with BLOCKED —.
GARM categories
The screener evaluates the 11 GARM Brand Safety Floor categories. Slugs are stable; the titles are the GARM names.
| Slug | GARM title |
|---|---|
adult_and_explicit_sexual_content | Adult & Explicit Sexual Content |
arms_and_ammunition | Arms & Ammunition |
crime_human_rights_violations | Crime & Harmful Acts, Human Rights Violations |
death_injury_or_military_conflict | Death, Injury, or Military Conflict |
online_piracy | Online Piracy |
hate_speech_and_acts_of_aggression | Hate Speech & Acts of Aggression |
obscenity_and_profanity | Obscenity & Profanity |
illegal_drugs_alcohol_tobacco_vaping | Illegal Drugs / Tobacco / E-cigarettes / Vaping / Alcohol |
spam_or_harmful_content | Spam or Harmful Content |
terrorism | Terrorism |
debated_sensitive_social_issue | Debated Sensitive Social Issue |
Ad-blocking garm entries carry a category and reasoning only; there is no severity level or scene range on the screening verdict. Severity (level: high / medium / low) and time_ranges appear on the video-side garm_video entries returned by indexing, search and GET /api/v1/ads/{ad_id}/analysis. The indexing-side taxonomy has 12 categories (it adds misinformation). See Taxonomies and verdicts.
Recipe: Submit and act on the verdict
A typical integration: screen, branch on block_decision, and hand the explanation to a human if blocked.
- Screen the creative
Upload the file (v1) or pass a registered
ad_id(v1.1), with your ad-hocrules. - Check `screening_failed` first
If it is
true, theblockis precautionary. Retry once under your deadline; do not show it as a content block. - Branch on `block_decision`
allow→ clear for delivery.block→ hold for review, attachingexplanation, thegarm[].categoryslugs and the matched rules.
- cURL
- Python
response=$(curl -s -X POST https://video.pyler.ai/api/v1/ad-blocking \
-H "Authorization: Bearer $VUF_API_KEY" \
-F "file=@./ads/new-creative.mp4" \
-F "title=spring-2026-launch" \
-F "rules=no lingerie or swimwear modeling, no on-screen consumption of alcohol")
if [ "$(echo "$response" | jq -r .screening_failed)" = "true" ]; then
echo "Screening failed — retry"; exit 1
fi
if [ "$(echo "$response" | jq -r .block_decision)" = "block" ]; then
echo "BLOCKED — hold for human review"
echo "$response" | jq -r .explanation
echo "GARM:"; echo "$response" | jq -r '.garm[].category'
echo "Matched rules:"; echo "$response" | jq -r '.blocklist.hits[] | select(.matched) | .rule'
else
echo "Cleared for delivery"
fi
import os, requests
def screen(path: str, rules: str) -> dict:
with open(path, "rb") as f:
resp = requests.post(
"https://video.pyler.ai/api/v1/ad-blocking",
headers={"Authorization": f"Bearer {os.environ['VUF_API_KEY']}"},
files={"file": (os.path.basename(path), f, "video/mp4")},
data={"title": "spring-2026-launch", "rules": rules},
timeout=120,
)
resp.raise_for_status()
return resp.json()
rules = "no lingerie or swimwear modeling, no on-screen consumption of alcohol"
verdict = screen("./ads/new-creative.mp4", rules)
if verdict["screening_failed"]:
verdict = screen("./ads/new-creative.mp4", rules) # one deliberate retry
if verdict["screening_failed"]:
raise RuntimeError("screening unavailable; do not auto-serve")
if verdict["block_decision"] == "block":
matched = [h["rule"] for h in verdict["blocklist"]["hits"] if h["matched"]]
hold_for_review(
verdict["id"],
verdict["explanation"],
garm=[g["category"] for g in verdict["garm"]],
matched_rules=matched,
)
else:
enqueue_for_delivery(verdict["id"])
An allow response looks like this:
{
"id": "1a2b3c4d-0000-0000-0000-000000000000",
"ad_id": null,
"title": "spring-2026-launch",
"duration_ms": 15000,
"one_line": "Unbranded running-shoe ad; runner on a coastal road at sunrise.",
"garm": [],
"blocklist": { "rules": ["no on-screen consumption of alcohol"], "hits": [] },
"block_decision": "allow",
"explanation": "ALLOWED — no GARM or blocklist violation. Standard product showcase with voiceover.",
"screening_failed": false,
"created_at": "2026-06-16T10:25:14Z"
}
Timeouts and retries
A timeout is an unknown outcome, not a failure. Once the request is accepted, screening runs to completion and the result is stored whether or not your connection is still open. Only the response is lost, so do not assume the ad was not screened.
-
Recommended client timeout: 120 seconds. Screening normally returns in seconds; the ceiling is for the tail.
-
Raising it past 5 minutes has no effect. A request that has produced no data for 5 minutes is closed with
504. Streaming endpoints (/api/v1/ads/match,/api/v1/videos/{video_id}/scenes/stream) keep producing data and can run longer; a quiet request ends at the 5-minute mark. -
Recover, do not re-submit. For a v1.1 screening, look the result up by
ad_idinstead of screening again. It is cheaper and returns the original verdict:curl "https://video.pyler.ai/api/v1.1/ad-blocking?ad_id=$AD_ID" \-H "Authorization: Bearer $VUF_API_KEY"Re-screening the same
ad_idis allowed but not idempotent: it runs again and writes a second result. -
Backoff. One or two retries under an overall deadline is enough. A retry occupies screening capacity that would otherwise serve your other requests, and request-rate limits may apply; see Usage and limits.
Which errors to retry:
| HTTP | Meaning | Retry? |
|---|---|---|
400 | Invalid upload: missing file, unprobeable, or too long (v1 route). | No |
401 / 403 | Missing or invalid key; key used outside the public API roots. | No |
404 | Unknown or malformed ad_id, or owned by another team. | No |
409 | Creative is not ready. | No |
422 | Body or query failed validation (for example a non-UUID ad_id on the lookup). | No |
500 / 502 / 503 / 504 | Upstream or edge error. | Yes |
| Network error, client timeout | No response reached you. | Yes, after a lookup |
The "no" rows are deterministic: the same request fails the same way, so fix the input instead. 200 with block_decision: "block" and screening_failed: true is also an error path, surfaced as a verdict so your system fails closed; retry it once. All error bodies share the { "detail", "code" } envelope described in Errors.
Content-side brand safety
Ad screening judges the creative. For the content an ad runs next to, use the indexing output: the video-level garm_video entries on GET /api/v1/videos/{video_id}, the scene-level garm_categories from GET /api/v1/videos/{video_id}/scenes, and the garm_min_level filter on search and the video list.