Agency API (v1)
A small key-based JSON API for driving an Agency workspace from a script instead of the dashboard. Meant for the agency's own tooling (bulk scans, syncing rates into another system) — there is no self-serve API for the Check / Fix Pack / Subscription tiers.
Implemented in web/routes_api.py.
Auth
Every request needs the workspace's owner key (the same key used in the dashboard URL,
/agency/{workspace_id}?key=ok_...) as a bearer token:
Authorization: Bearer ok_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Missing or wrong key → 401 {"error": "unauthorized"}.
There is no OAuth, no per-endpoint scopes, and no key rotation endpoint — if a key leaks, ask us to reissue the workspace's key.
Rate limit
60 requests / minute per key, enforced in memory by the running server process. Over the limit →
429 {"error": "rate_limited", "detail": "60 requests/min per key"}.
Endpoints
POST /api/v1/scans
Start a scan for one site in your workspace — the API equivalent of the dashboard's "add a site" form.
Request body:
{
"site": "https://example.com",
"brand": "Example",
"category": "invoicing software for small construction companies",
"competitors": [{"name": "Acme", "domain": "acme.com"}]
}
site(required) — with or withouthttps://.brand(optional) — defaults to the domain name if omitted.category(required) — one line, as specific as a buyer would be; this decides which buyer questions are asked.competitors(optional) — up to 8;domainmay be omitted.
Responses:
201→{"job_id": "...", "status_url": "https://.../api/v1/scans/<job_id>"}400→{"error": "invalid_site"}or{"error": "category_required"}409→{"error": "site_limit_reached", "max_sites": 5}— your plan's site cap is reached (active sites + scans still queued/running count toward it, same rule the dashboard and bulk-add form use).
GET /api/v1/scans/{job_id}
Poll a scan's status. Only returns jobs that belong to your workspace (a job id from another workspace, or an
unknown id, is a 404 — never disclosed as "exists but not yours").
{
"job_id": "260905-example-ab12",
"status": "queued | running | done | error | cancelled",
"stage": "sampling",
"done": 24,
"total": 80,
"error": null,
"site": "https://example.com",
"summary": { ... }
}
summary is present once status is "done" and has the same shape as GET /r/{jid}/summary.json (schema
version "1.0": rates.{recommended,mentioned,cited}, per_engine, per_prompt, share_of_voice, sources,
shape, gaps — see that endpoint's own documentation block for field meanings).
GET /api/v1/sites
All sites in your workspace, each with its most recent report's headline rates.
{
"sites": [
{
"id": "example.com|invoicing software...|acme.com,rival.io",
"url": "https://example.com",
"brand": "Example",
"category": "invoicing software for small construction companies",
"active": true,
"last_job": "260905-example-ab12",
"next_retest_at": "2026-09-19 00:00:00",
"recommended_rate": 0.42,
"mentioned_rate": 0.61,
"cited_rate": 0.30,
"n_samples": 80
}
]
}
Rates are null until the site's first scan finishes.
POST /api/v1/webhooks
Register (or replace) a webhook URL for your workspace. One URL per workspace; posting again overwrites it.
Request body: {"url": "https://your-system.example.com/hooks/ai-visibility"}
Response: 200 {"ok": true, "webhook_url": "..."}, or 400 {"error": "invalid_url"}.
Delivery: whenever a scan belonging to your workspace finishes, we POST to your URL:
{
"job_id": "260905-example-ab12",
"site": "https://example.com",
"recommended": 34,
"n": 80,
"report_url": "https://.../r/260905-example-ab12"
}
Delivery is best-effort: one attempt, 10-second timeout, plus a single retry 30 seconds later for a timeout or a
5xx response — never for a 4xx, and never more than once. No signature. Failures are logged on our side but never
surfaced to you beyond that — if you need reliability, poll GET /api/v1/scans/{job_id} instead or in addition.