// API_REFERENCE · AGENCY_PLAN
The Ad Bench API
Programmatic access to the same analyzer that powers the theadbench.ai UI. Authenticate with a Bearer key, POST a creative input, get back the structured report.
// AUTHENTICATION
Bearer keys
Existing key holders can manage their keys at /account/api-keys. If you are requesting a new key, email us first for paid add-on access. Keys are shown once at creation; store them in your secret manager (1Password, Bitwarden, GitHub Actions secrets, etc.). Pass the key in the Authorization header on every request:
Authorization: Bearer sk_tab_<your_key>Each key is rate-limited independently (so a runaway CI loop can’t starve your interactive scripts). Tap Report a bug in the footer if you need a higher ceiling — drop your traffic estimate in the description.
// ENDPOINT
POST /api/analyze
The analyzer accepts four input formats. Pick one and POST it with mode set to quick (report only) or deep (full Deep Dive rubric).
Request body
// All formats share these top-level fields:
{
mode: "quick" | "deep", // default: "deep"
format: "script" | "url" | "image" | "video_frames",
// …format-specific fields below
}
// format: "script" — paste ad copy, up to 30,000 characters (~30 minutes)
{ format: "script", script: "Your ad or commercial script…", caption?: string, vertical?: string }
// format: "url" — public video URL (TikTok, YouTube Shorts,
// Instagram Reels, or Facebook).
// TikTok: full-video fetch + voiceover.
// Shorts, Reels + Facebook: cover frame + caption.
// Upload the file for a full read on those platforms.
{ format: "url", url: "https://www.tiktok.com/@handle/video/123…" }
// format: "image" — base64-encoded JPEG/PNG/WebP/GIF
{
format: "image",
imageBase64: "<base64>",
mediaType: "image/jpeg" | "image/png" | "image/gif" | "image/webp",
caption?: string,
}
// format: "video_frames" — frames already on Vercel Blob (3-10)
{
format: "video_frames",
frames: [{ timestamp: 0.0, url: "https://*.public.blob.vercel-storage.com/…" }],
durationSec: 60,
caption?: string,
transcript?: string, // Whisper transcript, ≤20k chars
}Response
// 200 OK
{
mode: "quick" | "deep",
report: { /* Quick Check / Deep Dive rubric */ },
preview?: { thumbnailUrl, caption?, author?, frames? },
savedId: string | null, // saved-report ID (always set for API calls)
quota: { limit, used, signedIn: true }
}
// 401 — Bearer header missing or sk_tab_… not recognized
{ error: "Invalid API key." }
// 403 — key's owner is no longer on the Agency plan
{ error: "API access requires an active Agency plan." }
// 429 — daily cap reached for this key
{
error: "Daily analysis limit reached. Try again in NhM.",
retryAfter: <seconds>,
limit: <per-key cap>, // separate per-key quota; contact for limits
visibleLimit: <per-key cap>,
signedIn: true
}
// 400 — input shape rejected (zod errors joined with "; ")
{ error: "<message>" }Rate-limit headers
Every response carries the current quota state, whether it succeeded or not:
X-RateLimit-Limit: 100 // your daily cap
X-RateLimit-Remaining: 37 // requests left in the current window
X-RateLimit-Reset: 1715000000 // unix seconds when the window resetsRead X-RateLimit-Remaining instead of counting your own calls — it accounts for any other traffic on the same key or team.
Safe retries with requestId
Pass an optional requestId (8-64 chars, any stable string you generate per submit — a UUID works) alongside your request body. If the connection drops or you get a 503 mid-analysis and retry with the same requestId, the server replays the original result once it’s ready instead of starting a second analysis — you’re never charged quota twice and never get two saved reports for one submit. Omit it and every POST is treated as a fresh, independent analysis.
{ mode: "quick", format: "url", url: "…", requestId: "a1b2c3d4-…" }degraded: true on a 503
Most 503s are ordinary — a cold start or a transient hiccup, safe to retry with backoff. A 503 body that also carries degraded: truemeans something different: the rate-limit store itself is unreachable, and the server chose to fail closed rather than let anonymous requests through unmetered. Retrying immediately won’t help — treat it like a real outage (surface it to the caller / trigger an alert) rather than looping your own retry logic against it.
// 503 — rate-limit store unreachable, request rejected on purpose
{
error: "We're having a moment — try again shortly, or sign in to continue.",
degraded: true
}// EXAMPLES
curl
curl -X POST https://theadbench.ai/api/analyze \
-H "Authorization: Bearer sk_tab_<your_key>" \
-H "Content-Type: application/json" \
-d '{
"mode": "deep",
"format": "url",
"url": "https://www.tiktok.com/@handle/video/1234567890"
}'// EXAMPLES
JavaScript / TypeScript
const res = await fetch("https://theadbench.ai/api/analyze", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ADBENCH_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
mode: "deep",
format: "url",
url: "https://www.tiktok.com/@handle/video/1234567890",
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`The Ad Bench ${res.status}: ${error}`);
}
const { report, preview, savedId } = await res.json();
console.log(report.hookScore, report.format);// EXAMPLES
Python
import os, requests
res = requests.post(
"https://theadbench.ai/api/analyze",
headers={
"Authorization": f"Bearer {os.environ['ADBENCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"mode": "deep",
"format": "url",
"url": "https://www.tiktok.com/@handle/video/1234567890",
},
timeout=120,
)
res.raise_for_status()
data = res.json()
print(data["report"]["hookScore"], data["report"]["format"])// CI / CD
GitHub Actions example
Run The Ad Bench against every TikTok URL in a YAML manifest, fail the workflow on hook scores under a threshold:
# .github/workflows/the-ad-bench-gate.yml
- name: Hook-score gate
env:
ADBENCH_API_KEY: ${{ secrets.ADBENCH_API_KEY }}
run: |
set -eo pipefail
for url in $(jq -r '.creatives[].url' creatives.json); do
score=$(curl -fsS -X POST https://theadbench.ai/api/analyze \
-H "Authorization: Bearer $ADBENCH_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"mode\":\"quick\",\"format\":\"url\",\"url\":\"$url\"}" \
| jq '.report.hookScore')
echo "$url → $score"
[ "$score" -ge 50 ] || { echo "Below threshold"; exit 1; }
done// LIMITS
Quotas + caps
- Separate per-key daily quota on the Agency plan — each key has its own bucket, isolated from the shared team quota (override per-account via
ANALYZE_LIMIT_AGENCY). - 10 active keys per account. Revoke unused ones to make room.
- Body cap: 4.5 MB (Vercel function limit). Video frames must be uploaded to Vercel Blob first via
/api/blob-upload; only the URLs go in the analyze body. - Function timeout: 300s. Deep Dive typically returns in 30-60s.
// DRAFT_ENDPOINTS
Agency workbench endpoints
All endpoints below are Agency-only and share the same Bearer auth and error envelope as /api/analyze. Default rate limit is 60 requests/day per team (override via DRAFT_LIMIT). All methods are POST with a JSON body.
POST /api/draft/compliance-preflight
Flags FTC/TikTok-Shop/brand-voice violations before a script goes to production.
// Request
{
script: string,
brandKit?: {
voiceDoc?: string,
bannedWords?: string[],
compliancePosture?: "standard" | "strict" | "ftc" | "tiktok-shop",
},
}
// Response
{
report: {
overallRisk: string,
issues: [{ rule: string, severity: string, excerpt: string, fix: string }],
passedChecks: string[],
},
}POST /api/draft/hook-test
Tests multiple hook variants side-by-side and returns them ranked by score.
// Request
{
hooks: string[],
brandKit?: BrandKit,
vertical?: string,
}
// Response
{
report: {
ranked: [{ hook: string, score: number, pattern: string, patternFit: string, notes: string }],
},
}POST /api/draft/voice-match
Compares a script to a brand voice doc and surfaces alignment gaps with suggested rewrites.
// Request
{
script: string,
brandKit: { voiceDoc: string },
}
// Response
{
report: {
score: number,
alignment: string,
gaps: string[],
suggestions: string[],
},
}POST /api/draft/compare-concepts
Scores and ranks creative concepts against each other; also accessible as /api/compare-concepts.
// Request
{
concepts: [{ title: string, brief: string }],
vertical?: string,
}
// Response
{
report: {
ranked: [{ title: string, score: number, strengths: string[], weaknesses: string[] }],
},
}POST /api/draft/thumbnail-prompts
Generates AI image prompts optimized for TikTok cover frames.
// Request
{
context: string,
style?: string,
count?: number,
}
// Response
{
report: {
prompts: [{ prompt: string, rationale: string }],
},
}POST /api/draft/swipe-file
Extracts hook patterns and creative signals from a creator’s content for use in your own swipe file.
// Request
{
handle: string,
vertical?: string,
}
// Response
{
report: {
hookPatterns: [...],
creativeSignals: string[],
styleNotes: string[],
topItems: [...],
},
}// ENDPOINT
POST /api/analyze/batch
Scores up to 50 script or URL inputs in one call — same Bearer auth as /api/analyze, Agency plan required (Bearer key or a signed-in Agency session). Deep Dive isn’t available in batch (50 × a Deep Dive’s runtime won’t fit inside the 300s function ceiling) — every item runs as a Quick Check.
// Request
{
items: [
{ format: "script", script: "Your TikTok script…", caption?: string, vertical?: string },
{ format: "url", url: "https://www.tiktok.com/@handle/video/123…" },
// …up to 50 items, script and url may be mixed
],
}
// Response — 200 OK
{
batch: {
id: string,
ownerEmail: string,
createdAt: number,
updatedAt: number,
mode: "quick",
status: "running" | "completed" | "completed_with_errors",
items: [
{
id: string,
label: string, // the URL, or the script's first ~60 chars
status: "pending" | "running" | "completed" | "failed",
savedId: string | null, // saved-report ID, set on success
hookScore: number | null,
format: string | null,
error?: string, // set only when status is "failed"
},
],
},
}The call blocks until every item finishes (or the function hits its own timeout) — there’s no separate polling endpoint. Items run 5 at a time; a single item failing (bad input, an unreachable URL) doesn’t stop the rest — it’s marked "failed" with an error string and the batch continues.
- Quota is reserved up front, one hit per item against the same daily cap
/api/analyzeuses — a 50-item batch that would exceed your remaining quota is rejected with429before any analysis starts (no partial charge). - Separate global hourly ceiling across all batch callers, independent of the per-key daily cap.
// WEBHOOKS
Outbound webhooks
The Ad Bench can POST events to URLs you configure on /account/team/webhooks. Up to 5 webhook subscriptions per team, each with its own HMAC-SHA256 signing secret + selectable event types.
Event types
analyze.completed— every successful/api/analyzecall (Quick or Deep)analyze.completed.deep— only Deep Dive completions, useful when you want a quieter feedbatch.completed— fires once per/api/analyze/batchrun, with the full item rosterdigest.weekly— Monday 09:00 UTC, the same payload the email digest is rendered from (so you can ship it to Slack / a dashboard without parsing markdown)digest.daily— daily 09:00 UTC, same payload asdigest.weeklybut covering the previous 24h; only fires when the team’s digest frequency is set to “daily”error.reported— fires when a server-side error occurs on a/api/analyzerequest from this team;dataincludes{ source, message, url }
Request shape
POST <your_url>
Content-Type: application/json
X-AdBench-Event: analyze.completed
X-AdBench-Signature: t=1715000000,v1=<sha256_hmac_hex>
User-Agent: AdBench-Webhook/1
{
"id": "evt_<random>", // unique event ID
"type": "analyze.completed", // matches X-AdBench-Event
"created": 1715000000, // unix seconds; matches the t= in the signature
"teamId": "tm_<random>",
"data": { /* event-specific — see /api/analyze + /api/analyze/batch response shapes */ }
}Verify the X-AdBench-Signature header against the raw request body (HMAC-SHA256 — examples below).
Verifying the signature
Compute HMAC-SHA256(secret, `${t}.${rawBody}`) and compare to v1. The rawBodymust be the exact bytes you received — don’t re-serialize the parsed JSON or whitespace differences will break the verify.
// Node.js
import crypto from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=")),
);
if (!parts.t || !parts.v1) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex"),
);
}
// Usage in a Next.js route handler:
export async function POST(req) {
const body = await req.text(); // RAW string
const sig = req.headers.get("x-adbench-signature") ?? "";
if (!verify(body, sig, process.env.ADBENCH_WEBHOOK_SECRET)) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(body);
// …handle event
return new Response("ok");
}Delivery + retry
- Single attempt per event (no auto-retry in v1). Look at the delivery log on
/account/team/webhooksfor the last 50 attempts + status codes. - A webhook that returns non-2xx 10 times in a row is auto-disabled. The team owner sees the
DISABLEDbadge on the listing and has to re-enable by deleting + re-creating. - Request timeout is 5 seconds. If your endpoint takes longer than that, queue the work and ack the request synchronously.
- Localhost / private-network URLs are blocked in production to mitigate SSRF.
// SECURITY
Key handling
- Treat keys like passwords. Never check them into version control or paste them in chat.
- Keys are stored as SHA-256 hashes, not raw values — if you lose one you must rotate (revoke + create).
- Plan downgrade auto-disables existing keys (request returns 403). Re-upgrading the account re-enables them.
- Revocation is immediate — the next request returns 401 within a few seconds of clicking
Revoke.