AdZhi API

Your whole scoring engine, over plain REST.

Submit creatives, poll for results, pipe scores into your own reporting, then read the agent's decisions or hand them over. JSON in, JSON out, from a token to a webhook.

Available on the Enterprise plan
Section 01

Authentication

Every request is authenticated one of two ways. For a server or a scheduled pipeline, mint an API key and send it in the x-api-key header: keys are long-lived, scoped (read or read + write), optionally expiring, and revocable. For a short session, exchange email and password for a bearer token that lasts 60 minutes; call POST /api/auth/refresh with a still-valid token to roll it over before it lapses. API keys are on the Enterprise plan.

# Recommended for servers: mint a long-lived API key (Enterprise plan)
POST /api/keys
{"name": "reporting-pipeline", "scopes": ["read", "write"], "expires_in_days": 90}

# Response — the key is shown ONCE, store it now
{"key": "azk_live_...", "scopes": ["read", "write"], "expires_at": "2026-10-11T00:00:00Z"}

# Send it on every request
x-api-key: azk_live_...

# Alternatively, exchange email + password for a short-lived bearer token
POST /api/auth/login
{"email": "you@example.com", "password": "..."}

# Response — the token expires 60 minutes after issue
{"access_token": "eyJ...", "token_type": "bearer"}

# Use it the standard way
Authorization: Bearer <your_token>
POST/api/keys

Mint an API key. The key value is returned once and never stored — save it immediately.

GET/api/keys

List your active keys (label, scopes, expiry, prefix — never the key value).

DELETE/api/keys/{key_id}

Revoke a key immediately.

POST/api/auth/login

Exchange credentials for a 60-minute bearer token.

POST/api/auth/refresh

Exchange a valid token for a fresh 60-minute token.

GET/api/auth/me

Returns your user profile and current plan.

Section 02

Submit an analysis

Upload a video or audio file to start an analysis. The file is processed asynchronously: you'll get a job_id back immediately.

POST /api/jobs
Content-Type: multipart/form-data
ParameterRequiredDescription
filesrequiredVideo or audio file(s): mp4, mov, mp3, wav, m4a (max 100MB each)
batch_labeloptionalA name for this ad or batch, e.g. "Summer 2026 hero"
platformoptionalTarget platform: meta, tiktok, youtube
industryoptionalYour vertical, used for benchmarking
// Response
{
  "status": "queued",
  "job_ids": ["3f9a2b1c-..."],
  "batch_id": null
}

You can also submit a YouTube URL instead of uploading a file:

POST /api/jobs/url
Content-Type: application/json

{"url": "https://youtube.com/watch?v=...", "label": "Summer hero v2"}
Section 03

Poll for results

Analysis typically completes in under a minute; allow up to ~2 minutes for large files. Poll the status endpoint until status reaches finished or failed.

GET/api/jobs/status/{job_id}

Returns current status and progress (0 to 100)

GET/api/jobs/{job_id}

Returns full analysis result when status is finished

// Status response
{
  "job_id": "3f9a2b1c-...",
  "status": "started",   // queued | started | finished | failed
  "progress": 62,
  "stage": "acoustic_analysis"
}
Section 04

The result object

When status = "finished", the full analysis result is available at GET /api/jobs/{job_id}. Key fields:

{
  "id": "3f9a2b1c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
  "status": "finished",
  "filename": "summer-hero-v2.mp4",
  "result": {
    "adzhi_score": {
      "score": 74,
      "percentile": 67,
      "label": "Strong"
    },
    "hook_analysis": {
      "score": 7.1,
      "hook_type": "problem_statement",
      "hook_decay_rate": 0.18
    },
    "acoustics": {
      "voice_iq": 61,
      "pacing": "moderate",
      "energy_profile": [0.72, 0.68, 0.81, 0.95]
    },
    "persuasion_curve": {
      "cta_intensity": 71,
      "cta_momentum": 0.83
    },
    "emotional_trajectory": {
      "emotion_shape": "rising",
      "arc_score": 5.8
    },
    "act_structure": {
      "structure_score": 68,
      "structure_type": "hook_build_close",
      "missing_elements": []
    },
    "top_fix": {
      "signal": "hook",
      "finding": "No voice in the first 1.5s weakens hook capture.",
      "recommendation": "Re-cut to open on the voiceover."
    },
    "benchmarks": {
      "hook_score": {"value": 7.1, "percentile": 68, "fleet_avg": 5.8},
      "voice_iq":   {"value": 61,  "percentile": 43, "fleet_avg": 62}
    },
    "creative_tone": "urgent",
    "wpm": 148,
    "duration_sec": 32.4
  },
  "created_at": "2026-05-30T09:14:22Z",
  "updated_at": "2026-05-30T09:16:05Z"
}
Section 05

List your analyses

Retrieve a paginated list of all your submitted analyses. The path is literally /api/jobs/allall is the collection, not a placeholder. List responses return a summary projection: the full result blob is only returned on the single-job endpoint.

GET /api/jobs/all?limit=50&offset=0

// Optional filters
?status=finished        // queued | started | finished | failed
?search=summer          // searches filename
?sort=score_high        // newest | oldest | score_high | score_low

// Response
{
  "count": 142,
  "jobs": [
    {
      "id": "3f9a2b1c-...",
      "status": "finished",
      "filename": "summer-hero-v2.mp4",
      "result": { "adzhi_score": {"score": 74} /* + summary fields */ },
      "created_at": "2026-05-30T09:14:22Z"
    }
  ]
}
Section 06

Log a performance outcome

After your ad runs, record actual performance against the analysis. This builds your account's score-to-performance correlation model over time.

PUT /api/jobs/{job_id}/outcome
Content-Type: application/json

{
  // roas: a multiplier · cvr: conversion rate as a percentage
  "roas":  3.3,
  "cvr":   4.1,
  "notes": "revised hook, ran Meta only"
}

// Response
{"status": "ok", "roas": 3.3, "cvr": 4.1}
Section 07

Analyse a script, or a live ad

Two reads that don't need a fresh upload. /script scores raw text before anything is filmed; /inflight turns an existing analysis (or a script) plus live metrics into a Scale / Fix / Scrap call.

POST/api/insights/script

Hook, structure, rewrites and a brief from raw text. Instant, no queue.

POST /api/insights/script
Content-Type: application/json

{
  "script": "Tired of dull skin? Here's the 60-second glow ritual...",
  "context": "DTC skincare, women 25-40"   // optional
}

// Response - instant, no queue
{
  "hook_analysis": { "score": 7.4, "hook_type": "problem_statement" },
  "act_structure": { "structure_score": 71, "missing_elements": ["proof"] },
  "rewrites":      { "variants": [ /* ... */ ] },
  "brief":         { "one_liner": "...", "hook": "...", "cta": "..." },
  "audience":      { "segments": [ /* ... */ ], "objections": [ /* ... */ ] },
  "note": "Text-only - upload audio for acoustic Voice IQ + delivery scoring."
}
POST/api/insights/inflight

A Scale / Fix / Scrap verdict for a running ad: the creative signals read beside the live numbers.

POST /api/insights/inflight
Content-Type: application/json

{
  "job_id": "3f9a2b1c-...",        // or "script": "..." for text-only
  "roas": 1.9, "ctr": 0.7,
  "spend": 840, "days_running": 6,
  "target_roas": 3.0
}

// Response - a Scale / Fix / Scrap call on a running ad
{
  "source": "job",                 // job | script
  "recommendation": { /* scale | fix | scrap, with the rationale + evidence */ }
}
Section 08

The decision layer

Read the agent's open decisions, record a verdict, and set who decides per scope. Recording a verdict shapes the agent's per-kind learning; it never changes an ad. Delegation sets who decides, not whether spend moves.

GET/api/insights/action-queue

The highest-leverage open decisions, severity-ranked, each with its evidence and honest confidence.

GET /api/insights/action-queue?limit=20

// Response - the highest-leverage OPEN decisions, severity-ranked.
// Resolved (accepted / rejected / modified) cards drop out.
{
  "queue": [
    {
      "id": "d_8f2a...",
      "job_id": "3f9a2b1c-...",
      "action": "fix",                     // scale | fix | retire | ...
      "severity": "high",
      "confidence": 0.78,
      "confidence_label": "calibrated to your account",
      "trigger": "CTA delivered at 68% of your average energy",
      "expected_lift": { "metric": "CTR", "band": "+8-14%", "validated": false },
      "rollback": "Revert to the prior cut",
      "status": "open"
    }
  ],
  "stats": { "open": 6, "accepted": 12 },
  "learning": { /* per-kind acceptance shaping the order */ }
}
POST/api/insights/action-queue/disposition

Accept, reject or modify a decision. Records the verdict and tunes ranking. It does not touch the ad.

POST /api/insights/action-queue/disposition
Content-Type: application/json

{
  "job_id":      "3f9a2b1c-...",
  "decision_id": "d_8f2a...",
  // disposition: accepted | rejected | modified | feedback
  "disposition": "accepted",
  // kind is optional - feeds the agent's per-kind learning
  "kind":        "cta"
}

// Response - records your verdict + tunes ranking. It does NOT change the ad.
{"status": "ok", "disposition": "accepted", "decision_id": "d_8f2a...", "job_id": "3f9a2b1c-..."}
GET/api/insights/autonomy
PUT/api/insights/autonomy

Read or set who decides. Agent or human, for an account, campaign, ad set or ad.

# Who decides, per scope
GET /api/insights/autonomy
{
  "settings": [
    { "scope_level": "campaign", "scope_id": "c_123", "scope_name": "Spring Sale", "mode": "agent" }
  ],
  "account_mode": "human",
  "delegated": 1,
  // execution_live: decided + emailed, never spent (see note)
  "execution_live": false
}

# Delegate a scope to the agent, or take it back
PUT /api/insights/autonomy
{
  // scope_level: account | campaign | adset | ad
  "scope_level": "campaign",
  "scope_id":    "c_123",
  // mode: agent | human   ·   scope_name is optional
  "mode":        "agent",
  "scope_name":  "Spring Sale"
}
{"status": "ok", "mode": "agent", "execution_live": false}

execution_live is false by default. Delegating a scope to the agent records its decision and emails the account owner what it chose and why. It does not move spend or change the ad. Live execution against your ad account is a separate, credentialed rollout.

Section 09

Webhooks

Instead of polling, register a webhook and AdZhi POSTs to your endpoint the moment a job reaches a terminal state. Configure it once from your account or via the API, then verify each delivery with the signing secret.

PUT/api/webhook

Set your webhook URL and mint (or rotate) the signing secret. The secret is returned once.

GET/api/webhook

Read the current webhook URL and whether a secret is set.

POST/api/webhook/test

Fire a signed test ping to confirm your endpoint is reachable.

# Set your webhook URL (and mint a signing secret)
PUT /api/webhook
Content-Type: application/json

{"url": "https://your-app.com/hooks/adzhi", "rotate_secret": true}

// Response - the secret is shown ONCE, store it now
{
  "url": "https://your-app.com/hooks/adzhi",
  "secret": "a1b2c3...",
  "message": "Webhook configured. Store your secret - it won't be shown again."
}

# Send a test ping
POST /api/webhook/test

Each delivery is a signed POST. Verify the X-AdZhi-Signature header (HMAC-SHA256 of the raw body, keyed with your secret) before trusting the payload. On failure, AdZhi makes up to 3 delivery attempts in total — the initial send plus two retries, spaced a few seconds apart.

POST https://your-app.com/hooks/adzhi
X-AdZhi-Event:     job.completed        // job.completed | job.failed
X-AdZhi-Delivery:  3f9a2b1c-...         // the job id
X-AdZhi-Timestamp: 1717060565
X-AdZhi-Signature: sha256=<hmac>        // HMAC-SHA256 of the raw body, keyed with your secret

{
  "event":     "job.completed",
  "job_id":    "3f9a2b1c-...",
  "timestamp": 1717060565,
  "data":      { /* the full analysis result */ }
}

// Verify in your handler (Python):
// expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
// hmac.compare_digest(expected, signature.removeprefix("sha256="))
Section 10

Rate limits & errors

API access is on the Enterprise plan; every other plan uses the dashboard. While the API is in beta we run a fair-use policy rather than published per-request quotas. Requests are throttled per client on a sliding window, with tighter windows on the compute-heavy endpoints — uploads and the Claude-backed /insights/* reads — than on reads. When you cross a window you get a 429 Too Many Requests with a Retry-After header (and a retry_after_seconds field in the body); wait that long and retry.

// 429 Too Many Requests
Retry-After: 42
{
  "detail": "Rate limit exceeded. Please slow down.",
  "retry_after_seconds": 42
}

Firm per-plan limits land at general availability. If you need a specific sustained throughput now, tell us the volume and we'll set it on your account.

Status codes

Errors return a JSON body with a detail string. These are the statuses you'll actually see:

StatusMeaningWhat it tells you
200OKThe request succeeded.
400Bad RequestThe request was malformed — a missing field, or a value the endpoint could not accept.
401UnauthorizedMissing, invalid or expired token or API key. Mint a fresh one and retry.
403ForbiddenAuthenticated, but your plan or key scope doesn't allow this. API access is an Enterprise capability.
404Not FoundNo such job or resource, or it is not owned by your account.
409ConflictThe request conflicts with current state — e.g. an outcome already logged for that job.
413Payload Too LargeThe uploaded file exceeds the 100 MB limit.
422Unprocessable EntityValidation failed. The body parsed, but a field was out of range or the wrong type.
429Too Many RequestsRate limit hit. Back off and read the Retry-After header (seconds).
5xxServer ErrorA 500, 502 or 503 from us or a dependency. Transient — retry with backoff; if it persists, contact support.
Section 11

Code examples

Python

import requests
import time

API_URL = "https://adzhi.co.uk"
TOKEN   = "your_token_here"
headers = {"Authorization": f"Bearer {TOKEN}"}

# Submit an ad for analysis
with open("my_ad.mp4", "rb") as f:
    resp = requests.post(
        f"{API_URL}/api/jobs",
        headers=headers,
        files={"files": ("my_ad.mp4", f, "video/mp4")},
        data={"batch_label": "Summer hero v2"},
    )
resp.raise_for_status()
job_id = resp.json()["job_ids"][0]
print(f"Queued: {job_id}")

# Poll until complete
while True:
    status = requests.get(
        f"{API_URL}/api/jobs/status/{job_id}", headers=headers
    ).json()
    print(f"  {status['status']} - {status.get('progress', 0)}%")
    if status["status"] in ("finished", "failed"):
        break
    time.sleep(5)

# Fetch full result
result = requests.get(
    f"{API_URL}/api/jobs/{job_id}", headers=headers
).json()

score = result["result"]["adzhi_score"]["score"]
fix   = result["result"]["top_fix"]["recommendation"]
print(f"AdZhi score: {score}/100")
print(f"Top fix: {fix}")

curl

# Submit an ad
curl -X POST https://adzhi.co.uk/api/jobs \
  -H "Authorization: Bearer $ADZHI_TOKEN" \
  -F "files=@my_ad.mp4" \
  -F "batch_label=Summer hero v2"

# Poll status
curl https://adzhi.co.uk/api/jobs/status/$JOB_ID \
  -H "Authorization: Bearer $ADZHI_TOKEN"

# Fetch full result
curl https://adzhi.co.uk/api/jobs/$JOB_ID \
  -H "Authorization: Bearer $ADZHI_TOKEN"

# Log an outcome
curl -X PUT https://adzhi.co.uk/api/jobs/$JOB_ID/outcome \
  -H "Authorization: Bearer $ADZHI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"roas": 3.3, "cvr": 4.1, "notes": "revised hook"}'

Get API access

API access is included with Enterprise plans. Contact us to discuss volumes and rate limits.