Atribu
API Reference

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

Endpoint
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
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis"
JavaScript
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();
Python
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

Success response (200 OK)
{
  "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

Endpoint
POST /api/v1/ads/{id}/creative-analysis

Ask 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:

CeilingDefault
Per profile20 analyses per UTC day
Per API key100 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 same id, 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.statusMeaning
failedThe run was attempted and produced no analysis. It consumed a slot of your daily budget.
blockedThe 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
curl -X POST -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/ads/120210000000000001/creative-analysis"
JavaScript
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")).
Python
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

Accepted (202)
{
  "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": "..." }
}
Budget exhausted (429)
{
  "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": "..."
  }
}

On this page