Creative analysis
Read an ad's AI creative analysis, or queue one on demand.
Creative analysis
Atribu describes what an ad's creative actually is — its angle, offer, hook and claim style, and for video a verbatim transcript and a beat-by-beat scene timeline. A background pass produces these; two endpoints expose them.
The two are deliberately different in kind. GET is a read of something already
stored and costs nothing. POST asks for the analysis to be produced, which
means a multimodal model call on the ad's video — so it is budgeted, it needs its
own scope, and it never runs on the request itself.
Get an ad's creative analysis
GET /api/v1/ads/{id}/creative-analysis{id} is the ad's platform (external) id — the provider's own id, the same
value GET /api/v1/top-performers returns as ad_external_id and every
GET /api/v1/campaigns?level=ad row carries as platform_id. Atribu's internal
uuid is never a public identifier.
Scope: campaigns:read
This issues no model call and consumes no AI credits. Two consequences follow:
- Coverage is not guaranteed. An ad the pass has not reached answers
404, with a message distinct from the one a wrong or foreign ad id gets. - Freshness is the pass's, not the request's. Read
analyzed_at.
method tells you which tier produced the row and therefore how much of video
is populated: llm_vision_v2 (the video was analysed), llm_thumbnail_v2 (still
image only), llm_copy_only_v2 (no visual). On the lower two tiers every
time-based field is null and scenes is empty — a null there means not
observed, never the ad does not do this.
Request
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
"https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis"const res = await fetch(
"https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis",
{ headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();import requests
res = requests.get(
"https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis",
headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
)
data = res.json()["data"]Response
{
"data": {
"ad_external_id": "120210000000000001",
"ad_name": "Test drive — 60s",
"analysis_version": 2,
"method": "llm_vision_v2",
"model_id": "gemini-2.5-flash",
"confidence": "high",
"video_source_resolved": true,
"analyzed_at": "2026-08-20T11:50:00.000Z",
"creative": {
"primary_angle": "Book a test drive in 60 seconds",
"offers": ["free test drive"],
"cta_type": "book_appointment",
"hook_type": "question",
"claim_style": "specific_number",
"language_detected": "en"
},
"video": {
"transcript": "It would take you 60 seconds to book your test drive.",
"n_scenes": 2,
"narrative_arc_pattern": "hook>cta",
"scenes": [
{ "start_s": 0, "end_s": 2.4, "role": "hook", "on_screen_text": "60 SECONDS" }
]
}
},
"meta": { "profile_id": "..." }
}Embeddings and the derived per-role duration scalars are not published — they are
ML ranker inputs, and video.scenes carries the same observations in the form
they were made.
Queue an on-demand analysis
POST /api/v1/ads/{id}/creative-analysisAsk for an ad's creative to be analysed now instead of waiting for the daily
pass. This queues a model call; it does not make one. The response is a
receipt, and the analysis itself appears at the GET above — which is what the
Location header points at.
Scope: creatives:write
There is no request body. The ad is in the path and the caller is in the credential.
It costs AI credits, so it is budgeted
Two ceilings apply, both per UTC day, both configurable per tenant:
| Ceiling | Default |
|---|---|
| Per profile | 20 analyses per UTC day |
| Per API key | 100 analyses per UTC day |
A key belongs to exactly one profile, so at the defaults the profile ceiling is
the one you will meet. Exceeding either answers 429 with Retry-After set to
the seconds remaining until the next UTC midnight; the message names which
ceiling fired, the counters, and the reset instant. Every response carries
data.budget so you can pace yourself rather than discovering the limit as an
error.
This is separate from the ordinary per-minute rate limit, and the endpoint also carries its own hourly burst cap (see Rate limits).
It is idempotent for 24 hours
The idempotency key is the ad plus its creative — the ad's name, headline, body, video and thumbnail. Inside 24 hours, with none of those changed:
202 Accepted— a new job was admitted.reused: false.200 OK— the existing job came back.reused: true, the sameid, and no budget consumed.
Edit the creative and the next POST is a new job, which is what you want: the
old analysis describes an ad that no longer exists.
How a job ends, and how you find out
status on the returned job is always live — queued, running or
succeeded. A run that ended badly is never handed back as the current job;
instead the next POST admits a fresh job and reports the last one in
previous_attempt:
previous_attempt.status | Meaning |
|---|---|
failed | The run was attempted and produced no analysis. It consumed a slot of your daily budget. |
blocked | The run was never attempted — the background analysis pass is switched off for this deployment. It consumed nothing. |
That is also the answer for a profile where the pass is off: you get a job, it
terminates blocked, and it costs you nothing.
Request
curl -X POST -H "Authorization: Bearer atb_live_YOUR_KEY" \
"https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis"const res = await fetch(
"https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis",
{ method: "POST", headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
// 202 = queued now, 200 = an existing job for this same creative.
const { data } = await res.json();
// Poll the GET on the same path (res.headers.get("Location")).import requests
res = requests.post(
"https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis",
headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
)
job = res.json()["data"]Response
{
"data": {
"id": "8f2b1c44-0a3d-4d1e-9c77-2f5b6a0c1d33",
"ad_external_id": "120210000000000001",
"status": "queued",
"reused": false,
"requested_at": "2026-09-02T12:00:00.000Z",
"analysis_version": 2,
"input_hash": "5942f10844840359b7c684eda0be84cf",
"previous_attempt": null,
"budget": {
"profile_used": 1,
"profile_cap": 20,
"api_key_used": 1,
"api_key_cap": 100
}
},
"meta": { "profile_id": "..." }
}{
"error": {
"code": "rate_limit_exceeded",
"message": "This profile has used 20 of 20 on-demand creative analyses today (the budget is per UTC day). The stored analysis is still readable with GET on the same path. Resets at 2026-09-03T00:00:00.000Z.",
"status": 429,
"request_id": "..."
}
}