the api is the product. base path /v1, bearer-authenticated, json in and out. every endpoint the console uses is yours to call directly.
all requests go to the versioned base url below. bodies are json; responses are json. the free surface (the capability matrix, news, tool detail, and per-site detection profile) needs no token budget. the metered surface (the per-site winning config and on-demand benchmarks) spends tokens. the gated intelligence consoles unlock by plan entitlement or a key scope.
Base URL https://automationbenchmark.com/v1 Content-Type: application/json Authorization: Bearer <your-api-key>
every example below runs as written against the hosted api. point them at your own instance with BASE=http://localhost:3000 instead.
mint an api key from the keys page (or POST /v1/keys). the plaintext key is shown exactly once, so store it immediately. send it as a bearer token on every request. the browser console uses a session cookie instead, but programmatic access is key-based.
# 1. sign in. minting a key is SESSION-authenticated, not key-authenticated
curl -X POST https://automationbenchmark.com/v1/auth/login -c cookies.txt \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"..."}'
# 2. mint a key with that session (the plaintext is returned exactly once)
curl -X POST https://automationbenchmark.com/v1/keys -b cookies.txt \
-H 'content-type: application/json' \
-d '{"label":"ci","scopes":["evidence"]}'
# => { "id": "...", "label": "ci", "key": "ab_live_...", "scopes": ["evidence"], ... }
# 3. call any endpoint with the key
curl https://automationbenchmark.com/v1/matrix \
-H 'authorization: Bearer ab_live_...'the body field is label, not name: an unknown field is dropped silently, so a key minted with name comes back unlabelled.
keys carry optional scopes for finer access (for example evidence unlocks per-artifact run evidence). only evidence is self-grantable; any other scope returns 400 invalid_scope. on a scoped route a missing scope redacts the response: 200 with the gated fields withheld, not a 403.
every non-2xx response carries the same envelope: an error object with a stable code, a human message, and optional details. branch on the code, not the message.
{
"error": {
"code": "insufficient_tokens",
"message": "insufficient tokens: need 25, have 3",
"details": { }
}
}| status | code | meaning |
|---|---|---|
| 400 | bad_request | request validation failed (details carries the field errors) |
| 401 | unauthorized | missing or invalid bearer key |
| 402 | insufficient_tokens | not enough token balance for this metered call |
| 402 | upgrade_required | a session caller hit a plan-gated console |
| 403 | forbidden | the key lacks the required scope or role |
| 404 | not_found | unknown resource (site, job, webhook, key) |
| 429 | rate_limited | per-key rate limit exceeded (see rate limits) |
metered calls spend tokens from your balance. reading a fresh current-best config from cache costs one token. when no fresh answer exists, an on-demand benchmark holds a larger amount until it resolves, then settles to the real cost. the balance is always checked before any debit, so a call either succeeds or returns 402 insufficient_tokens with nothing charged. never a partial debit.
# not enough balance. nothing is debited, top up and retry
HTTP/1.1 402 Payment Required
{ "error": { "code": "insufficient_tokens",
"message": "insufficient tokens: need 25, have 3" } }check your balance and ledger any time with GET /v1/tokens, and top up with POST /v1/tokens/purchase. your plan also grants a monthly token allotment on subscribe.
each api key is rate limited by a token bucket: a sustained refill with a burst ceiling (default 60 requests per minute sustained, burst 120). an exhausted bucket returns 429. the response echoes the ceiling in X-RateLimit-Limit and how long to wait in Retry-After. back off and retry after the window.
two calls worth seeing whole. everything else is in the reference below, and the machine-readable version of all of it is at GET /v1/openapi.json.
# enqueue an on-demand job. `params` are validated against what the job can
# actually run, so an unexecutable request is rejected 400 BEFORE it costs a token.
curl -X POST https://automationbenchmark.com/v1/jobs \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"type":"verify","params":{"hostname":"example.com"}}'
# => { "jobId": "job_...", "status": "queued" }# register a webhook endpoint. the signingSecret is returned exactly once.
curl -X POST https://automationbenchmark.com/v1/webhooks \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"url":"https://your-app.example.com/hooks/ab"}'
# => { "id": "...", "url": "...", "active": true, "signingSecret": "whsec_..." }to search for a config from a hostname alone use GET /v1/sites/:hostname/config rather than a job. tool ids are namespaced (browser.camoufox, not camoufox); the bare display slug in our catalog urls returns 404.
three whole flows, each stitched from the calls in the reference below. every request runs as written against the hosted api; swap the bearer key and the sample values for your own.
ask for a site's current best. a fresh answer returns 200 immediately; a miss reserves a benchmark and returns 202 with a jobId. poll the job, then ask again once it is done.
# 1. ask for the current best config for a hostname
curl https://automationbenchmark.com/v1/sites/example.com/config \
-H 'authorization: Bearer ab_live_...'
# a fresh answer: 200 { "hostname": "example.com", "configuration": { ... },
# "confidence": 0.92, "source": "cache" }
# a miss: 202 { "jobId": "job_...", "status": "pending" }
# 2. on a 202, poll the job until it settles
curl https://automationbenchmark.com/v1/jobs/job_... \
-H 'authorization: Bearer ab_live_...'
# => { "jobId": "job_...", "status": "done" }
# 3. ask again. the answer is now fresh and served from the db
curl https://automationbenchmark.com/v1/sites/example.com/config \
-H 'authorization: Bearer ab_live_...'
# => 200 { "configuration": { "browser": "browser.camoufox", ... }, "source": "db" }register an endpoint, store the signingSecret it returns once, then verify the X-AB-Signature on every delivery (the node snippet is in the webhook section below). a benchmark you trigger afterward delivers its result to every active endpoint you have registered.
# 1. register the receiver (signingSecret is returned exactly once)
curl -X POST https://automationbenchmark.com/v1/webhooks \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"url":"https://your-app.example.com/hooks/ab"}'
# => { "id": "...", "active": true, "signable": true, "signingSecret": "whsec_..." }
# 2. confirm it can actually be signed for (signable:true), and see owed deliveries
curl https://automationbenchmark.com/v1/webhooks \
-H 'authorization: Bearer ab_live_...'
# 3. trigger work; the result is POSTed to your receiver, signed. verify before trusting it.mint a key carrying the evidence scope, then read a run's derived evidence for a site you track. the scope unlocks the redacted evidence view (per-kind counts, digests, the screenshot index); raw captures stay behind a paid plan.
# 1. mint a key with the evidence scope (session-authenticated; see authentication)
curl -X POST https://automationbenchmark.com/v1/keys -b cookies.txt \
-H 'content-type: application/json' \
-d '{"label":"evidence-reader","scopes":["evidence"]}'
# => { "key": "ab_live_...", "scopes": ["evidence"], ... }
# 2. read a run's evidence for a site in your working set
curl https://automationbenchmark.com/v1/sites/example.com/runs/run_.../evidence \
-H 'authorization: Bearer ab_live_...'
# a run outside your working set answers 404 identically to one that does not exist.list endpoints return newest first and are bounded by a limit query parameter. each has its own default and maximum (for example GET /v1/runs defaults to 50). there is no offset and no opaque cursor token. to page back through a long, time-ordered list, pass since, an iso timestamp, set to the oldest item you have already seen, and read the next window.
# first page: the newest 50 runs curl "https://automationbenchmark.com/v1/runs?limit=50" \ -H 'authorization: Bearer ab_live_...' # next page: everything older than the last item you saw curl "https://automationbenchmark.com/v1/runs?limit=50&since=2026-01-15T08:00:00.000Z" \ -H 'authorization: Bearer ab_live_...'
the same limit (and where offered since) shape applies to the other list endpoints; a page may return fewer rows than limit when the feed collapses duplicates. each endpoint's exact parameters are in the reference below.
this list is generated from the api's own routing table and the zod schemas its handlers validate against, not written by hand. it therefore cannot describe an endpoint that does not exist, and no endpoint can exist without appearing here. the same document in machine-readable form, including request and response schemas, is served at GET /v1/openapi.json and committed at apps/control-plane/openapi.gen.json.
the badge on each endpoint is its real requirement, folded out of the middleware chain that runs on the request: which credential it accepts, the scope or plan that unlocks it, and what it charges. the internal operator surface (/v1/admin/*) is in the spec but deliberately not here.
`name` only — `email` is changed through the verification flow and `role` through the admin surface, and folding either in here would route a guarded change through an unguarded door. An empty string clears the name.
400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 not_found · 500 internal error
| field | type | required | note |
|---|---|---|---|
| name | string | required |
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — not_found — no such account
500 — internal error
ErrorEnvelope
curl -X PATCH https://automationbenchmark.com/v1/account \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"name":"<string>"}'No row is created on read, and none is required to exist: an account that has never touched this page reports the same defaults as one that switched everything off, because the two receive identical mail. `emailDelivery` is non-null when the address is on the suppression list (WP E6) — a bounce or a complaint took it off the send path, and this is the only way the account can learn that.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — read the caller's deliverable notification preferences
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/account/notifications \ -H 'authorization: Bearer ab_live_...'
The body is strict: keys with no send path anywhere in the tree are rejected with a 400 rather than accepted and ignored, so a client can never believe it saved a preference that does nothing. Switching the digest on requires a confirmed email address, the same gate `POST /v1/intel/subscriptions` enforces.
400 · 401 · 403 · 429 · 500
errors: 400 request validation failed · 401 authentication required · 403 email_not_verified · 429 rate_limited · 500 internal error
| field | type | required | note |
|---|---|---|---|
| intelDigest | boolean | required | |
| intelDigestCadence | "daily" | "weekly" | optional |
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
403 — email_not_verified — confirm the address before enabling email digests
429 — rate_limited
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X PUT https://automationbenchmark.com/v1/account/notifications \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"intelDigest":false,"intelDigestCadence":"daily"}'The discovery layer for the console FacetBar — what a caller can filter or group the metric cube by, folded from the same `tool_run_facts` window.
parameters: windowDays (query, integer)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — list every analytics dimension`s distinct values and counts
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/analytics/facets \ -H 'authorization: Bearer ab_live_...'
The sibling of `/v1/analytics/uptime` on a different axis: detector availability there, OUR tooling's reachability here.
parameters: windowDays (query, integer), componentSlug (query, string)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — provider-infrastructure liveness ribbons from the liveness probe history
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/analytics/provider-liveness \ -H 'authorization: Bearer ab_live_...'
Filter-by and group-by over `tool_run_facts`, bucketed into honest time-series (Wilson-lower for `success`, rollup-aggregated `latency`, `count`). Every point carries its denominator `n`; a bucket with no honest value is omitted (a gap, never a 0). Read-through cached.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
| field | type | required | note |
|---|---|---|---|
| metric | "success" | "latency" | "count" | required | |
| filter | object | optional | |
| groupBy | "tool" | "category" | "vendor" | "site" | "layer" | "outcome"[] | optional | |
| windowDays | integer | optional | |
| granularity | "day" | "week" | optional | |
| rollup | "avg" | "min" | "max" | "sum" | "p50" | "p90" | "p95" | "p99" | optional | |
| compare | object | optional |
200 — resolve the metric cube into grouped, bucketed time-series
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/analytics/query \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"metric":"success","filter":"<object>","groupBy":["tool"],"windowDays":0,"granularity":"day","rollup":"avg","compare":{"shiftDays":0}}'Shaped from the detection-vendor canary history so a client can draw per-detector uptime and outage ribbons. `vendor` is a detection FAMILY (the challenge signature the wire matched), not a catalog product slug; each row therefore also carries `products` — the catalog vendors declaring that family, resolved here so a client never has to guess a link from a name. `products: []` means the catalog declares none; `products: null` means the catalog could not be read (unknown, never "none").
parameters: windowDays (query, integer), vendor (query, string)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — detector uptime and outage ribbons from the config-canary history
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/analytics/uptime \ -H 'authorization: Bearer ab_live_...'
Saved views are PRIVATE to the account that created them. There is no shared or public view: the list is owner-scoped in the store query, not filtered here.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — list this account`s saved analytics views
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/analytics/views \ -H 'authorization: Bearer ab_live_...'
`state` is the console`s own URL query string, stored verbatim so opening the view restores it exactly. A name is unique PER ACCOUNT: a clash answers 409, and two accounts may each hold a view of the same name.
201 · 400 · 401 · 402 · 409 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 409 view_name_taken · 500 internal error
| field | type | required | note |
|---|---|---|---|
| name | string | required | |
| state | string | required |
201 — created
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
409 — view_name_taken
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/analytics/views \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"name":"<string>","state":"<string>"}'Owner-scoped: another account's view id answers 404, the same answer an unknown id gets.
parameters: id (path, string, required)
204 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 view_not_found · 500 internal error
204 — deleted
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
404 — view_not_found
500 — internal error
ErrorEnvelope
curl -X DELETE https://automationbenchmark.com/v1/analytics/views/{id} \
-H 'authorization: Bearer ab_live_...'Owner-scoped: another account's view id answers 404, the same answer an unknown id gets, so no response confirms that a view exists.
parameters: id (path, string, required)
200 · 400 · 401 · 402 · 404 · 409 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 view_not_found · 409 view_name_taken · 500 internal error
| field | type | required | note |
|---|---|---|---|
| name | string | optional | |
| state | string | optional |
200 — updated
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
404 — view_not_found
409 — view_name_taken
500 — internal error
ErrorEnvelope
curl -X PATCH https://automationbenchmark.com/v1/analytics/views/{id} \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"name":"<string>","state":"<string>"}'`entitlements.source` says whether the plan came from the account or from a team that elevates it. `emailVerified` and `name` mirror `/v1/auth/me` so the console can render from whichever payload it already holds. `subscription` carries the lifecycle a plan id cannot express — status, a pending cancellation and the date it takes effect, and the dunning rung — and is null for an account that has never had a subscription row.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — read the caller's plan, effective entitlements and usage summary
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/account \ -H 'authorization: Bearer ab_live_...'
Returns what a scope would cost, computed by the same function the metering path bills with, so the quoted number and the charged number cannot drift. `axes` says which parts of the stack to sweep and how wide: 0 leaves an axis out of the configuration, 1 holds it at the base config, and more than 1 sweeps it. `design` is `isolating` (one factor at a time, `1 + sum(n-1)`, the default) or `factorial` (every combination, `product(n)`), which at the same widths can differ by more than an order of magnitude. Authentication is optional: the token figure needs only the scope, while `balance`, `affordable` and the plan ceiling need a session. A scope above the per-job attempt cap is refused with its own numbers rather than truncated.
200 · 400 · 422 · 500
errors: 400 request validation failed · 422 scope_invalid · 500 internal error
| field | type | required | note |
|---|---|---|---|
| hostname | string | optional | |
| scope | object | required |
200 — the quote: candidates, attempts, tokens, price, and per-axis detail
400 — request validation failed
ErrorEnvelope
422 — scope_invalid — an unpriceable scope (bad tool count, mismatched ids, no axes) or attempts_exceeded
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/benchmarks/quote \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"hostname":"<string>","scope":{"axes":{"proxy":{"tools":0,"toolIds":["<string>"]},"browser":{"tools":0,"toolIds":["<string>"]},"fingerprint":{"tools":0,"toolIds":["<string>"]},"behavioral":{"tools":0,"toolIds":["<string>"]},"solver":{"tools":0,"toolIds":["<string>"]}},"design":"isolating","trials":0}}'Unauthenticated — the pricing page and every plan gate read this. Each plan carries its id, price, monthly token allotment and unlocked consoles; `consoles` lists every gated console so a page can render the ones a plan does not include. `metering` carries the per-token price, the per-site config `lookup`/`benchmark` costs (what `GET /v1/sites/{hostname}/config` charges on a hit and settles to on a miss), and the `verify`/`detect` costs of a `POST /v1/jobs` sent with no `trials` or `scope` — the shape a console button sends. Every figure is computed by the same code the billing path reserves with, so a published price cannot drift from what is charged. A `verify` is priced on the trials it will actually run, so quoting a job with an explicit `trials` or `scope` needs `POST /v1/benchmarks/quote`.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — list the public plan catalog, the consoles each plan unlocks, and the metered rates
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/billing/plans \ -H 'authorization: Bearer ab_live_...'
Returns `{effectiveness, ideal, gaps}` — the per-tool leaderboard with `byVendor` cells (the matrix), the ideal tool per detection vendor, and the vendors no tool beats. Per-vendor cells come from the running record of benchmark outcomes; the per-tool top-line comes from the same measured fold the free matrix uses, so the two surfaces cannot disagree about a browser's headline. Every `score` here is a READ-TIME statistic: the Wilson lower bound of the gate-pass rate scaled by recency decay off that group's freshest observation (7-day half-life) — never the stored undecayed column. `byVendor[].score` is the same number `/v1/browsers/{browser}` serves as `measured[].confidence`, from the same function, so the list and the detail page cannot disagree. A browser with no measured attempt in scope publishes `passRate: null` and `score: null` — NOT `0`, which would be indistinguishable from a browser that really did go 0-for-n. `trials`/`passes` stay `0`, because zero attempts is genuinely how many attempts there were. Unmeasured browsers sort last rather than tying a real zero.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — score every anti-detect browser and expose the tool by vendor success matrix
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/browsers \ -H 'authorization: Bearer ab_live_...'
The path parameter `browser` is the browser slug. A slug we do not have 404s — known is the catalog browser roster UNION anything actually measured or captured, so a browser the fleet has run resolves even if the catalog drifts, and an invented slug is never served as an empty-but-real page. Joins two independent sources and keeps them labelled as such: MEASURED (`browser_outcomes` and its history — what the fleet observed, sparse and real) and MODELLED (the LIVE fold over `tool_fingerprint_facts` + `detection_surfaces` — leaks, fingerprint quality, per-vendor inferred verdicts). They are never merged into one number, because a model and a measurement answer different questions; `measuredAsOf` and `modelledAsOf` date each lane separately. `profile` is `null` when the fleet has never fingerprinted it AND the catalog does not describe it, which is an absence rather than a clean bill of health. Its describing fields (`displayName`, `family`, `attachProtocol`, `maintenance`, `status`) are catalog facts; every verdict-bearing field is the fleet`s own measurement. `measured[].confidence` is a READ-TIME statistic — the Wilson lower bound of the gate-pass rate scaled by recency decay off that cell`s freshest observation, NOT the stored undecayed column — and is the same number `/v1/browsers` publishes as that browser`s `byVendor[].score`, resolved by the same function so the two surfaces cannot disagree. `confidenceBasis` states that definition on the surface, including the half-life and the instant the decay was evaluated at.
parameters: browser (path, string, required)
200 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 browser_not_found · 500 internal error
200 — the browser`s measured detail, modelled profile, and series
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
404 — browser_not_found — the slug is in neither the roster nor the facts
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/browsers/{browser} \
-H 'authorization: Bearer ab_live_...'The catalog of datasets is public metadata; the freshness gate is per-dataset on `/v1/datasets/:slug`.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — list the published datasets with their newest capture date and row count
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/datasets \ -H 'authorization: Bearer ab_live_...'
Signed in ⇒ `current`: the newest snapshot, or the snapshot as-of `asOf`. Anonymous ⇒ `delayed`: the newest snapshot at least 30 days old, falling back to the earliest snapshot we hold when no snapshot is yet that old, so a public caller always gets real data. `asOf` is CLAMPED to the tier ceiling, so anonymous cannot reach past the 30-day wall by asking for `asOf=now`. Never rejects — the tier downgrades instead.
parameters: slug (path, "capability-matrix" | "detection-vendors" | "proxy-roster" | "solver-roster" | "network-fingerprints", required), asOf (query, string (date-time))
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — the tier, the resolved `asOf`/`capturedAt`, and the payload (`dataset: null` when no snapshot sits under the ceiling yet)
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/datasets/{slug} \
-H 'authorization: Bearer ab_live_...'Payload-free — capture date, row count, content hash and catalog version per snapshot, for building "how this dataset moved" charts.
parameters: slug (path, "capability-matrix" | "detection-vendors" | "proxy-roster" | "solver-roster" | "network-fingerprints", required), limit (query, integer)
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — get the dated history series for a dataset, newest first
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/datasets/{slug}/history \
-H 'authorization: Bearer ab_live_...'The neutral "is this bot-detection vendor actually blocking bots, and how does it compare?" leaderboard. Scoring runs over measured benchmark outcomes alone, so the numbers cannot be influenced by who pays.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — rank bot-detection vendors by measured effectiveness
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/defense/vendors \ -H 'authorization: Bearer ab_live_...'
parameters: a (path, string, required), b (path, string, required)
400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 not_found · 500 internal error
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
404 — not_found — one or both vendors have no benchmark data
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/defense/vendors/{a}/vs/{b} \
-H 'authorization: Bearer ab_live_...'The vendor's measured facts (SQL) joined with cited prose from the research corpus.
parameters: slug (path, string, required)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — grounded per-vendor insight: measured facts plus cited corpus prose
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/defense/vendors/{slug}/insight \
-H 'authorization: Bearer ab_live_...'What our in-page instrumentation observes each detection vendor doing per target: the per-vendor profiles (trapped APIs, bot-tells, exfiltration sinks, categories) plus unknown or newly seen detectors, the surface hash, and whether a change is pending review. Captured by analysing the detection scripts each target serves.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — list the captured detection surfaces observed per target
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/detection-surfaces \ -H 'authorization: Bearer ab_live_...'
A DIFFERENT axis from the vendor roster: fingerprintjs / browserscan / xray are test targets, not defense vendors. The roster is the catalog site-scripts and every number is MEASURED — trials and gate-passes the fleet actually recorded (`basis: measured`), never the imported blob, which is no longer read here at all. A target the fleet has not run yet lists with zero trials and a null rate rather than being hidden or filled in. A site-script with no `expectedVendor` is an OWN-PROBE — a target fronted by no bot defense. A measured target with no catalog site-script is surfaced as an orphan rather than dropped.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — the targets grid with the modelled `asOf`
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/detection-targets \ -H 'authorization: Bearer ab_live_...'
The inverse axis of `/v1/detectors`: those are the detection vendors we test AGAINST, these are the analyzers we detect WITH (creepjs, the JA4 probe, iphey, the DataDome probe, the detection-script loader analyzer). **`reads` is the layers an instrument INSPECTS, never the layers a tool survives** — the opposite sense of the word `capabilities` carries on `/v1/matrix`, which is why this is its own endpoint rather than a filter over that one: the matrix population excludes the detector axis on purpose, so a filter over the matrix would return nothing. Every field is DECLARED — read off the instrument registry, which is why there is no `asOf` to publish: this is the code we ship, not an observation of it. `liveWired` separates "we run this" from "we have code for this": `false` means the analyzer is tested but its live path is not wired, and `liveNote` says what it waits on.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — the registered detection instruments, by display name
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/detection-tools \ -H 'authorization: Bearer ab_live_...'
Vendors and surface come from the neutral detection profile; `surface` is the layers the site actively enforces, strongest first. The cited answer comes from the vector KB seam — when it is not wired or the corpus is empty the route replies `answer: null, citations: []` and the console renders the "knowledge base not yet populated" state. An unknown host is a 200 with empty vendors, never a 404, so this is not a host oracle.
parameters: host (query, string, required)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — look up what detects a host: the vendors, the enforced surface, and a cited answer
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/detector \ -H 'authorization: Bearer ab_live_...'
Each row carries the catalog `category` plus a derived `role`, so the UI stops lumping identifiers in with gates: a `fingerprint-lib` returns a visitorId, it does not block. `tells` is LIVE — folded from the `detection_surfaces` scan of the vendor`s own loader, the same source `/v1/detectors/{slug}` reads, so the index and the page it links to cannot disagree about how many surfaces a vendor probes. **`tells` counts SURFACES PROBED, which is broader than its name**: it is the union of the loader`s explicit bot tells and every other cataloged api it touched, so ordinary javascript (`console.log`, `Date`) is inside the count. The console labels it "surfaces probed" for that reason; do not present this number as a count of bot-detection tells. `tells: null` means no scan has reached that vendor — an absence, not "it probes nothing", and never a stale count from the imported blob. `asOf` dates the freshest live scan behind the counts.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — the vendor roster, most-profiled first, with the modelled `asOf`
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/detectors \ -H 'authorization: Bearer ab_live_...'
Everything we hold about one detection vendor, keyed by the `slug` path parameter: what it probes (`tells`, live from the loader scan), its clearance cookies and gate token, the full captcha readout, MEASURED per-browser fleet outcomes, and the MODELLED tell-overlap verdicts — the two are never merged. **`tells` is every cataloged surface the loader was seen to read, not only bot-detection tells** — the union of its explicit bot tells and its other trapped apis, so ordinary javascript is inside it. The console labels it "surfaces probed"; do not present it as a count of tells. Every field is live or catalog; no imported snapshot is read, so `tells: null` means no scan has reached this vendor rather than "it probes nothing". `script` is the detection-script scan, whose unattributed clusters are what `pendingProposals` exists to fix. `modelledAsOf` dates the modelled half.
parameters: slug (path, string, required)
200 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 unknown detector · 500 internal error
200 — the detector readout
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
404 — unknown detector — not a catalog vendor, not scanned, and never measured by the fleet
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/detectors/{slug} \
-H 'authorization: Bearer ab_live_...'Any authenticated user can submit; the note is stamped with their account id. The admin inbox (list + status change) lives on the gated `/v1/admin/feedback` surface.
201 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
| field | type | required | note |
|---|---|---|---|
| message | string | required | |
| url | string | optional | |
| context | object | optional |
201 — the created feedback note (id, status, createdAt)
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/feedback \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"message":"<string>","url":"<string>","context":"<object>"}'The "what should we build or acquire next" surface: unaddressed detection layers, hard-ceiling targets, `gapToLeader` vendors, unsolved challenges, and vendors unbeaten by any proxy class. Composed neutrally from the capability matrix plus measured vendor, proxy and solver outcome facts.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — compose the cross-axis gap surface across all five gap dimensions
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the gaps console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/gaps \ -H 'authorization: Bearer ab_live_...'
The cross-axis gap surface (SQL) joined with cited prose from the research corpus.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — grounded gap-surface insight: cross-axis gaps plus cited corpus prose
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the gaps console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/gaps/insight \ -H 'authorization: Bearer ab_live_...'
Every cell is a live pass/block rate the fleet actually observed, folded from the authoritative per-attempt run ledger over a fixed window — not a lossy summary and not an imported blob, so this grid and the calibration tab can never disagree. Columns are EVERY detector the fleet measured on the browser axis, so the grid matches the detector pass rates the insights pages show; each column carries a `scored` flag marking the calibrated detectors (controlled test pages that yield a per-tool score) apart from the measured-but-uncalibrated ones — a per-column label only. The per-tool row headline is full-stack survival: the tool recency-decayed pass rate over ALL of its targets, the same one number the leaderboard and tool page publish, not a re-sum of these cells. Roster-complete over the catalog browser tools, so an unmeasured tool is honest NO-DATA. Each cell carries a delta against the immediately preceding window of equal length, computed only where that prior window had enough trials. `scope` states the population every number was computed over: `measured` normally, or `unavailable` with a `reason` when the catalog could not be loaded — in which case the grid is served EMPTY rather than served with the calibration flags missing.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — get the live browser by real-world-detector grid folded from the run ledger
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/harness/detectors-live \ -H 'authorization: Bearer ab_live_...'
The MODEL, not an outcome: the tool half (leaks / fingerprint quality / stealth) is the fleet`s own live measurement from captured fingerprint facts, while the vendor tell-sets come live from the captured detection surfaces, roster-completed against the catalog. A tracked detector with no live surface is honest NO-DATA, never blob-filled. `asOf` tracks the live half. Free gets the verdict; paid gets the breakdown, under the SAME projection `/v1/harness/matrix` applies: an unentitled caller gets every verdict and the whole tool axis, and the per-cell explanation — `overlap`, `riskScore`, `reason`, `staticVerdict` — is ABSENT rather than nulled. `detailed` says which projection was served. The response body is projected server-side by caller. This endpoint previously served the paid breakdown to anonymous callers while the paywall sat on the flagship, which the console does not call.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — get the live inferred tool by vendor matrix folded from measured fingerprint facts
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/harness/inferred-live \ -H 'authorization: Bearer ab_live_...'
Columns are EVERY detector the fleet measured on the proxy axis, so the grid matches the per-detector pass rates the proxy insights pages show; the IP-reputation scorers that yield a controlled egress score are marked `scored` on their column — a per-column label only — while the rest are shown with their measured rate. The per-tool row headline is full-stack survival: the provider recency-decayed pass rate over ALL of its targets, the whole stack it rode included, the same one number the leaderboard and tool page publish. Roster-complete over the catalog proxy tools and folded from the same run ledger as the detectors grid. `scope` states the population behind every number; when the catalog cannot be loaded the grid is served EMPTY with `scope.basis: 'unavailable'` rather than served with the calibration flags missing.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — get the live proxy grid, scored against the ip-reputation targets
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/harness/proxies-live \ -H 'authorization: Bearer ab_live_...'
Columns are EVERY detector the fleet measured on the solver axis, so the grid matches the per-challenge rates the solver insights pages show; the real reCAPTCHA/hCaptcha challenges that yield a controlled solve score are marked `scored` on their column — a per-column label only — while the rest are shown with their measured rate. The per-tool row headline is full-stack survival: the solver recency-decayed pass rate over ALL of its targets, the same one number the leaderboard and tool page publish. Roster-complete over the catalog solver tools and folded from the same run ledger as the detectors grid. `scope` states the population behind every number; when the catalog cannot be loaded the grid is served EMPTY with `scope.basis: 'unavailable'` rather than served with the calibration flags missing.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — get the live solver grid, scored against the real challenge targets
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/harness/solvers-live \ -H 'authorization: Bearer ab_live_...'
Counts benchmark runs, intel items, detection changes, and signals per bucket, shaped for a calendar heatmap (`calendar`) and a release-cadence chart (`buckets`).
parameters: windowDays (query, integer), granularity (query, "day" | "week")
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — per-day or per-week event counts for a calendar heatmap and cadence chart
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/history/cadence \ -H 'authorization: Bearer ab_live_...'
The "why did my scraper break" feed, merged across intel items, change-stream signals, detection-surface shifts, benchmark verdict flips, and detection-profile shifts. A signal with no in-window intel item degrades rather than dropping its attribution.
parameters: windowDays (query, integer), limit (query, integer), subjectType (query, "vendor" | "site" | "tool"), subject (query, string)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — attributed timeline of what changed, when, and why
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/history/changes \ -H 'authorization: Bearer ab_live_...'
A bar per detector: that detector's share of the fleet's blocking over the window, weighted so a detector that stops many tools outweighs one that stops a single outlier at equal raw counts (the prototype's "where the pressure comes from, not a per-signal pass rate"). Keyed on the detector each run was measured against. Definitive fails only — ambiguous attempts are an absence, not a loss — and quarantined attempts are excluded, so these numbers agree with every other rate the platform publishes. `pt` is a share in points summing to ~100; each bar also carries its raw block count and the distinct tools it caught.
parameters: windowDays (query, integer)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — the fleet blocking decomposed by detector, weighted by co-fall breadth
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/history/detector-pressure \ -H 'authorization: Bearer ab_live_...'
The movement board: vendors by block rate, tools by pass rate, layers by share of blocks, each ranked by a magnitude that shrinks toward zero on the sample of the smaller window, so a tiny-n swing cannot lead. Every rate travels with the denominator it was computed from and with both Wilson edges, in BOTH windows, because the significance question is whether a value crossed its own prior interval and an interval needs two edges. A window with no definitive trial carries `rate: null` rather than 0, and its direction is `insufficient` rather than `flat` — never measured and measured-as-unchanged are different findings. Quarantined attempts are excluded, so these rates agree with every other rate the platform publishes. This is the same fold the issue generator resolves its numeric claims against; it carries no synthesized narrative, which is admin-only and internal-tier.
parameters: windowDays (query, integer)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — rank what moved between this window and the one before it
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/history/movers \ -H 'authorization: Bearer ab_live_...'
A bucketed pass-rate / Wilson-score series with a summary, composed from existing benchmark run facts so a customer can see how a tool, vendor, or site trends over time.
parameters: subject (query, "site" | "vendor" | "tool", required), value (query, string, required), windowDays (query, integer), granularity (query, "day" | "week")
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — bucketed pass-rate time-series for one tool, detection vendor, or site
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/history/scores \ -H 'authorization: Bearer ab_live_...'
Same series as `/v1/history/scores`, keyed by each `values` entry (comma-separated, deduped, capped at 50). Built so a page needing N sparklines makes one request and one fact read instead of N.
parameters: subject (query, "site" | "vendor" | "tool", required), values (query, string, required), windowDays (query, integer), granularity (query, "day" | "week")
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — bucketed pass-rate series for many subjects at once, from one fact read
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/history/scores/batch \ -H 'authorization: Bearer ab_live_...'
A thin alias of `/v1/news` over the same `news_items` corpus, kept until the `/intel` console is retired. `teaser` marks the narrowed projection. Served through the one visibility boundary in `services/newsFeed.ts`, shared with `/v1/news`: the projection NARROWS for an anonymous caller relative to any signed-in account, and `teaser: true` marks it. The check is soft — it downgrades the projection rather than rejecting, because the feed keeps a public face. The boundary is forced server-side, so a query parameter cannot opt back in.
parameters: entityType (query, "vendor" | "tool" | "proxy" | "solver" | "challenge" | "site"), entityId (query, string), eventType (query, "config-degraded" | "config-recovered" | "new-detection-check" | "change-impact-measured" | "vendor-dropped-check" | "new-detector-discovered" | "vendor-profile-refined" | "vendor-adopted" | "vendor-removed" | "network-drift" | "technique-surfaced" | "release-published" | "service-incident" | "coverage-reported"[]), sourceId (query, string[]), provenance (query, "observed-by-fleet" | "reported"[]), minRelevance (query, number), since (query, string (date-time)), sort (query, "ranked" | "recent"), authorship (query, "any" | "third-party"), group (query, "top" | "industry" | "outages" | "changelogs" | "fleet"), limit (query, integer)
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — get the filtered change stream over the first-party and third-party corpus
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/intel \ -H 'authorization: Bearer ab_live_...'
Prefers the DURABLE precomputed insight, then a 1h cache, and only then grounds a fresh answer — a cache hit never touches the model. When the KB is not wired, the corpus is still empty, or the model is unavailable, answers `{ insight: null, reason }` and does NOT cache, so a later view regenerates once the corpus fills.
parameters: hash (path, string, required)
200 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 item_not_found · 500 internal error
200 — the cited insight, or `{ insight: null, reason }`
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — item_not_found
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/intel/{hash}/insight \
-H 'authorization: Bearer ab_live_...'`:id` is an entity reference of the form `type:slug` (e.g. `vendor:datadome`); anything else answers 400 `bad_entity_ref`. Otherwise identical to `/v1/intel`, scoped to that entity. Served through the same boundary in `services/newsFeed.ts` as `/v1/intel`: the projection NARROWS for an anonymous caller relative to any signed-in account, and `teaser: true` marks it.
parameters: id (path, string, required), entityType (query, "vendor" | "tool" | "proxy" | "solver" | "challenge" | "site"), entityId (query, string), eventType (query, "config-degraded" | "config-recovered" | "new-detection-check" | "change-impact-measured" | "vendor-dropped-check" | "new-detector-discovered" | "vendor-profile-refined" | "vendor-adopted" | "vendor-removed" | "network-drift" | "technique-surfaced" | "release-published" | "service-incident" | "coverage-reported"[]), sourceId (query, string[]), provenance (query, "observed-by-fleet" | "reported"[]), minRelevance (query, number), since (query, string (date-time)), sort (query, "ranked" | "recent"), authorship (query, "any" | "third-party"), group (query, "top" | "industry" | "outages" | "changelogs" | "fleet"), limit (query, integer)
400 · 500
errors: 400 bad_entity_ref · 500 internal error
400 — bad_entity_ref — `:id` was not of the form type:slug
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/intel/entities/{id} \
-H 'authorization: Bearer ab_live_...'Responds `application/rss+xml`, not JSON. `:id` is an entity reference of the form `type:slug`; anything else answers 400 `bad_entity_ref`. Public and unauthenticated, so the narrower anonymous projection of `services/newsFeed.ts` always applies here regardless of caller — this feed is never widened by presenting a credential.
parameters: id (path, string, required)
200 · 400 · 500
errors: 400 bad_entity_ref · 500 internal error
200 — an RSS 2.0 document (`application/rss+xml; charset=utf-8`)
400 — bad_entity_ref — `:id` was not of the form type:slug
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/intel/entities/{id}/rss \
-H 'authorization: Bearer ab_live_...'200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — list this account`s watches
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/intel/subscriptions \ -H 'authorization: Bearer ab_live_...'
The account owns its watch-list. An `email` channel makes the row a DELIVERY TARGET, so that channel — and only that channel — requires a confirmed address; every other channel is pulled by the subscriber and stays open to an unverified account. `cadence` defaults to `instant` when omitted, so an existing client is unchanged.
201 · 400 · 401 · 403 · 429 · 500
errors: 400 request validation failed · 401 authentication required · 403 email_not_verified · 429 rate_limited · 500 internal error
| field | type | required | note |
|---|---|---|---|
| entityType | "vendor" | "tool" | "proxy" | "solver" | "challenge" | "site" | null | optional | |
| entityId | string | null | optional | |
| eventTypes | "config-degraded" | "config-recovered" | "new-detection-check" | "change-impact-measured" | "vendor-dropped-check" | "new-detector-discovered" | "vendor-profile-refined" | "vendor-adopted" | "vendor-removed" | "network-drift" | "technique-surfaced" | "release-published" | "service-incident" | "coverage-reported"[] | optional | |
| minRelevance | number | optional | |
| provenanceFilter | "observed-by-fleet" | "reported" | null | optional | |
| channels | "feed" | "webhook" | "email" | "rss"[] | required | |
| cadence | "instant" | "daily" | "weekly" | optional |
201 — the created subscription
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
403 — email_not_verified — confirm the address before subscribing that channel
429 — rate_limited
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/intel/subscriptions \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"entityType":"<\"vendor\" | \"tool\" | \"proxy\" | \"solver\" | \"challenge\" | \"site\" | null>","entityId":"<string | null>","eventTypes":["config-degraded"],"minRelevance":0,"provenanceFilter":"<\"observed-by-fleet\" | \"reported\" | null>","channels":["feed"],"cadence":"instant"}'Owner-scoped: another account's subscription id answers 404, the same answer an unknown id gets.
parameters: id (path, string, required)
204 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 subscription_not_found · 500 internal error
204 — deleted
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — subscription_not_found
500 — internal error
ErrorEnvelope
curl -X DELETE https://automationbenchmark.com/v1/intel/subscriptions/{id} \
-H 'authorization: Bearer ab_live_...'POST, never GET, and unauthenticated. The reader may be on a device that has never signed in, so the token IS the authorization: an HMAC over one subscription id that grants nothing but switching that one off. It is not a GET because mail scanners and link pre-fetchers issue a GET against every URL in an inbound message, which would unsubscribe readers who never clicked. The token may arrive as the `token` query parameter (what an RFC 8058 one-click mailbox provider POSTs) or in a JSON body with the same field (what the console page sends). Guessing is bounded by the 256-bit MAC rather than by a rate-limit bucket.
parameters: token (query, string, required)
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — always 200 with `unsubscribed`, whether or not the token resolved — a 404 would confirm which subscription ids exist, and a non-2xx may get us reported as a sender that ignores unsubscribes
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/intel/unsubscribe \ -H 'authorization: Bearer ab_live_...'
Admission validates EXECUTABILITY, not enum membership: the job class must be one a live consumer drains, `params` must satisfy what that class's handler actually reads, a `verify` needs a current-best config to re-verify, and `callbackUrl` runs the same SSRF guard `/v1/webhooks` does. All of it runs BEFORE metering, so a request that cannot succeed never opens a reservation. A `benchmark` runs ONE named configuration and therefore needs `params.configId` or an inline `params.configuration` — for "find me a config for this hostname" use `GET /v1/sites/{hostname}/config` instead. The token cost depends on the job type.
parameters: Idempotency-Key (header, string)
202 · 400 · 401 · 402 · 409 · 422 · 429 · 500
errors: 400 invalid_job_params or invalid_callback_url · 401 authentication required · 402 insufficient_tokens · 409 no_current_best_config · 422 job_type_unavailable · 429 rate_limited · 500 internal error
| field | type | required | note |
|---|---|---|---|
| type | "benchmark" | "detect" | "verify" | required | |
| params | object | optional | |
| callbackUrl | string (uri) | optional | |
| idempotencyKey | string | optional |
202 — the job was queued: `{jobId, status: "queued"}`
400 — invalid_job_params or invalid_callback_url — nothing was charged
401 — authentication required
ErrorEnvelope
402 — insufficient_tokens
ErrorEnvelope
409 — no_current_best_config — a verify has nothing to re-verify
422 — job_type_unavailable — no live consumer drains that job class
429 — rate_limited
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/jobs \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"type":"verify","params":{"hostname":"example.com"}}'request body
{
"type": "verify",
"params": {
"hostname": "example.com"
}
}response
{
"jobId": "job_9f3a2b1c",
"status": "queued"
}The poll fallback for a missed webhook delivery. A job is only visible to the account that created it (`params.requestedBy`); a request for someone else's job answers 404 rather than 403, so the existence of another customer's job is never disclosed.
parameters: id (path, string, required)
400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 job_not_found · 500 internal error
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — job_not_found — unknown job, or not yours
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/jobs/{id} \
-H 'authorization: Bearer ab_live_...'Public. Each row carries BOTH halves: `capabilities` (declared, from the catalog) and `measured` (what the fleet actually observed, `null` where nothing definitive has run). Publishing the measured half is the deliberate boundary — an anonymous reader can check a claim against a result. `measured` also states what it CANNOT separate: a configuration is a stack, so one attempt credits every component in it. `confoundedWith` names the stack-mates that rode at least 95% of this tool`s attempts — directional, so B appearing in A`s list does not put A in B`s — and `neverVaried` marks a component present in effectively every attempt in the corpus, whose rate is therefore the corpus rate rather than a measurement of that component. `attemptShare` is the evidence behind the flag. It also states WHICH exam the rate answers: `targetMix` is this tool`s definitive attempts per target, descending, and `mixImbalanced` marks a tool measured on a materially different set of targets from the rest of the board, so its headline is not directly comparable to the rows beside it. `mixDivergence` is the evidence behind that flag. WITHHELD FOR AN UNAUTHENTICATED CALLER: the `id` and `displayName` of the three highest-ranked measured tools are replaced with an opaque `withheld-N` placeholder. Every measurement, trial count and declared capability is still returned, for those rows and every other; only the identity is withheld. Sign in to resolve them. `basis` states where the measured half came from and how far back it looks: `windowDays` is the rolling window the per-layer capability fold is computed over, and `computedAt` is when this body was folded. It was an unpublished constant until a surface needed to print the window beside the figures it produced, which is a number no page may type for itself.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — get the capability matrix: every tool declared capability beside what was measured
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/matrix \ -H 'authorization: Bearer ab_live_...'
response
{
"layers": [
"fingerprint",
"behavioral",
"ip-reputation",
"captcha"
],
"tools": [
{
"id": "browser.camoufox",
"category": "browser",
"displayName": "Camoufox",
"version": "0.4.11",
"capabilities": {
"fingerprint": 0.9,
"behavioral": 0.6
},
"providerId": null,
"confidence": "confirmed",
"status": "testing",
"family": "stealth-toolchain",
"measured": {
"passRate": 0.81,
"trials": 214
},
"latencyMs": 2400,
"measuredCapability": {
"fingerprint": 0.83
}
}
],
"basis": {
"source": "live-fold",
"computedAt": "2026-01-15T09:24:00.000Z",
"windowDays": 90
}
}`build.stale: true` means the process is running older code than the last build. Never fails the probe: staleness is a report, not a liveness verdict.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — liveness probe, plus the build stamp of the code this process is running
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/health \ -H 'authorization: Bearer ab_live_...'
Generated at request time from the routes this instance registered and the zod schemas its handlers validate with. Never hand-edited.
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — an openapi 3.1 document
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/openapi.json \ -H 'authorization: Bearer ab_live_...'
The merged door onto the `news_items` corpus (C13). Facets are folded over the corpus WITHOUT the caller`s active filters, so the option list stays put while it is being used — the trade-off is that a count describes the whole visible feed rather than the current result set. `authorship` is the exception and is carried into the fold, because it narrows which corpus the response is about rather than which slice of it is selected: `authorship=third-party` excludes the rows we emitted ourselves from the items AND from every facet count. Repeated readings (same source, entity, event, headline and day) are collapsed to one in both, so a page may return fewer rows than `limit`. An account`s own watch-list orders the facet options; anonymous callers fall back to pure count order. `teaser` tells the reader which feed they are holding. Both the items and the facets are served through the one visibility boundary in `services/newsFeed.ts`: the projection NARROWS for an anonymous caller relative to any signed-in account, and `teaser: true` marks the narrowed response. The two reads derive from the same bounded filter in the same request, so they cannot disagree about what a caller may see. The boundary is forced server-side — a query parameter cannot opt back in.
parameters: entityType (query, "vendor" | "tool" | "proxy" | "solver" | "challenge" | "site"), entityId (query, string), eventType (query, "config-degraded" | "config-recovered" | "new-detection-check" | "change-impact-measured" | "vendor-dropped-check" | "new-detector-discovered" | "vendor-profile-refined" | "vendor-adopted" | "vendor-removed" | "network-drift" | "technique-surfaced" | "release-published" | "service-incident" | "coverage-reported"[]), sourceId (query, string[]), provenance (query, "observed-by-fleet" | "reported"[]), minRelevance (query, number), since (query, string (date-time)), sort (query, "ranked" | "recent"), authorship (query, "any" | "third-party"), group (query, "top" | "industry" | "outages" | "changelogs" | "fleet"), limit (query, integer)
200 · 400 · 500
errors: 400 request validation failed · 500 internal error
200 — get the newest-first change feed with the facets to filter it by
400 — request validation failed
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/news \ -H 'authorization: Bearer ab_live_...'
The issue archive index. One row per published window: its citable number, the window it covers, and what it published — `shown` of `total`, with `heldBack` naming what the cap dropped. Those three are as they stood AT PUBLICATION and are never recounted against a corpus that has since grown, so an issue that held nine back in March still says nine in June. `quiet: true` marks a window in which nothing cleared the bar; such an issue is published rather than skipped, because on a product whose claim is freshness a quiet week and a dead pipeline must not look the same in the archive. Free to any account, and deliberately not anonymous: an issue cannot narrow its projection per reader without ceasing to be citable.
parameters: limit (query, integer)
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — the archive index, newest issue first
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/news/issues \ -H 'authorization: Bearer ab_live_...'
One issue of the change record. `records` are the issue's frozen selection resolved against `news_items`, in the order the publication fold placed them — never re-ranked, because the desk's ranking weights by the reader's own exposure and by recency against the render clock, and an artifact that changes per reader or per hour cannot be cited. The rows themselves are NOT copied into the archive, so a re-read picks up a corrected summary while the selection stays put. `unresolved` counts hashes that no longer resolve to a row (null when none did), so a list shorter than `shown` says why instead of just being short.
parameters: number (path, integer, required)
200 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 the archive holds no issue with that number · 500 internal error
200 — the issue, in its frozen order
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — the archive holds no issue with that number
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/news/issues/{number} \
-H 'authorization: Bearer ab_live_...'Returns `{effectiveness, ideal, gaps}` — the per-proxy leaderboard, the ideal proxy per detection vendor (the recommendation), and the vendors no proxy beats. Scored over measured proxy outcome facts alone, with recency decay applied.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — score every proxy provider, the ideal proxy per vendor, and the unbeaten vendors
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the proxies console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/proxies \ -H 'authorization: Bearer ab_live_...'
The path parameter `provider` is the proxy provider slug. A slug we do not have 404s — known is the catalog proxy roster UNION anything actually measured, so a provider with a measured row resolves whatever the catalog says, and an invented slug is never served as a real-looking empty page. For a provider that DOES exist but was never measured the response is honest by construction: `measured: []` and `series: []` rather than a zeroed row, so the page says "never measured" instead of implying we tried and it failed. `/insight` is declared first, so the literal segment always wins the route match and `provider` can never capture "insight".
parameters: provider (path, string, required)
200 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 proxy_not_found · 500 internal error
200 — the provider`s measured rows and success-over-time series
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the proxies console needs the pro plan
ErrorEnvelope
404 — proxy_not_found — the slug is in neither the roster nor the facts
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/proxies/{provider} \
-H 'authorization: Bearer ab_live_...'Structured proxy metrics (SQL) joined with cited prose from the research corpus.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — grounded proxy-axis insight: structured metrics plus cited corpus prose
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the proxies console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/proxies/insight \ -H 'authorization: Bearer ab_live_...'
What the vendor's detection script probes (static analysis of `detection_surfaces`), what the server-side network data says (`proxy_outcomes`: JA4 fidelity, gate-pass rate, ASN/geo), and what the benchmark evidence says (`benchmark_runs` verdicts folded to a Wilson lower bound). Each lane labels itself measured or declared-fallback so the console can explain "why this score" honestly.
parameters: tool (query, string, required), vendor (query, string)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — explain the provenance behind one tool and provider score
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the gaps console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/recommend/score-basis \ -H 'authorization: Bearer ab_live_...'
The best browser + proxy + solver + fingerprint combination for each detection provider it beats, plus a general overall best. The `use` filters (sticky | dynamic | fast | firsttry) map to real proxy attributes — session-type class, latency, gate-pass success — and re-rank the toolchains. Every pick is tagged measured vs declared-capability.
parameters: use (query, string | string[])
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — recommend the best toolchain per detection provider and overall
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the gaps console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/recommend/toolchains \ -H 'authorization: Bearer ab_live_...'
`providerId` is a catalog provider slug (`bright-data`), not a uuid. `componentSlug` records which catalog component the click came from; the redirect always uses the provider's canonical URL, so it is attribution only. Auth is optional — an anonymous click is recorded without an account id.
201 · 400 · 404 · 500
errors: 400 request validation failed · 404 provider_not_found · 500 internal error
| field | type | required | note |
|---|---|---|---|
| providerId | string | required | |
| componentSlug | string | optional | |
| attribution | object | optional |
201 — the recorded event id and the referral url, which is null when the provider has none
400 — request validation failed
ErrorEnvelope
404 — provider_not_found — unknown provider slug
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/referral/click \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"providerId":"<string>","componentSlug":"<string>","attribution":"<object>"}'The fleet's own benchmark runs across every target, the inverse isolation of `/v1/runs`: the store clause is `requested_by IS NULL` and nothing account-shaped, so a run stamped for any account can never appear and there is no `userId` to scope by. These are the shared, neutral benchmark facts the product sells. Newest first, capped by `limit`; filter by `outcome`, `blockingLayer`, `hostname` or `configId`, and page with `since`. `artifactKeys` are never exposed, the same redaction boundary `/v1/runs` keeps. Gated on `trends`, the same gate the scoped `/v1/runs` and `/v1/history/*` carry: the reader is a paying customer, not the world.
parameters: outcome (query, "pass" | "fail" | "ambiguous"), blockingLayer (query, string), hostname (query, string), configId (query, string), since (query, string (date-time)), limit (query, integer)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — list the fleet-wide benchmark runs (the ledger system tab)
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/benchmark/runs \ -H 'authorization: Bearer ab_live_...'
The visible set is defined in the store query, not by filtering a broader read: visible = runs the caller requested, plus FLEET runs (`requestedBy IS NULL`) on sites they track, where the tracked set is derived from their own job history. A run stamped for another account is never returned, even for a site they share. A `hostname` filter narrows WITHIN that working set and can never widen it, and a run whose site the caller cannot name reads as `hostname: null` rather than disclosing it through the join. `summary` is folded over exactly the rows the list returns. `artifactKeys` are never exposed at any tier — `GET /v1/runs/{id}/evidence` is the only door to raw evidence. Gated on the `trends` console, the same gate `/v1/history/*` carries, because it is the same history.
parameters: outcome (query, "pass" | "fail" | "ambiguous"), blockingLayer (query, string), runReason (query, string), hostname (query, string), since (query, string (date-time)), limit (query, integer)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — search visible run history and fold it into a summary
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the trends console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/runs \ -H 'authorization: Bearer ab_live_...'
The drill-through the Runs table, the matrix cells and the site/tool cells all point at: this run's `tool_run_facts` folded by attempt — what the whole stack got on each attempt, the vendor and blocking layer, and the oracle stages plus probe verdicts that decided it. The path parameter `runId` is read directly and is not schema-validated here; an id that matches no visible run answers 404. `artifactKeys` are never exposed. Ownership and existence resolve BEFORE payment. Visible = the caller's own run, or a run the fleet took unprompted (`requestedBy IS NULL`) on a site in their working set; a run stamped for another account is never visible, even on a shared target. An id the caller cannot see answers 404 byte-identically to an id that resolves to nothing at all, so the two are indistinguishable from outside and no response confirms that a run exists. The `trends` paywall applies only AFTER that: a run the caller CAN see but has not paid for answers 402 with the `details` envelope naming the plan — where 402 is a true statement, because paying really does unlock that exact run.
parameters: runId (path, string, required)
200 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 run_not_found · 500 internal error
200 — the run, its attempts, and its per-tool facts
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — a visible run on an unentitled plan; never sent for a run the caller cannot see
404 — run_not_found — unknown run OR a run the caller may not see; the two are identical
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/runs/{runId}/facts \
-H 'authorization: Bearer ab_live_...'The recipe library. One row per configuration: the caller's own sites it runs on, how many of those it is the standing answer for, and how far the newest verification has decayed on the same 7-day half-life the served confidence uses. `replication` counts every site on the roster and names none of them, because *single-site or replicated* is a fact about the config rather than about the reader: answering it from the caller's rows alone would report a config live on forty sites as a single-site result. A config that has never been verified carries `decay: null` and sorts after every dated one, because never-checked is not the same as long-unchecked. **No bundle and no legs are served here at any tier** — the library is an index, and the metering decision is made per config on `/v1/configs/:id`.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 the trends console is not on this plan · 500 internal error
200 — the library, load-bearing and most-decayed first
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — the trends console is not on this plan
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/configs \ -H 'authorization: Bearer ab_live_...'
The config axis of `site_config_scores`, read the other way round from `GET /v1/sites/:hostname/configs`: where this config has been tried, where it still holds, and every run of it. `createdFrom` (`search`, `manual` or `transfer`) is served here and nowhere else. **Only sites in the caller's working set are NAMED**; the rest are a count, and the replication block denominates on the full set so a "current best on 2 sites" can never be read against the wrong total. The five legs are served as plugin ids per slot, with no parameters, and only when this config is nobody's current best: a config that is any site's current best is that site's paid answer, so serving it would make this a free replacement for the metered `/v1/sites/:hostname/config`. Runs are capped at `runLimit` and the cap is on the wire. Two-layer. Row visibility is the caller's working set applied to every hostname the answer would print, which is the ownership resolution the rest of this file already uses rather than a second path; withheld rows survive as counts (`withheldSites`, `withheldRuns`, and every field of `replication`), because a replication claim without its denominator is worse than none. Payload visibility is: the five legs render for a historical, eliminated or quarantined config and are withheld with `legsWithheld: 'current-best'` when the config is current best anywhere, tracked or not. A config with no visible row answers 404, never 403, and never an empty 200.
parameters: id (path, string (uuid), required)
200 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 config_not_found · 500 internal error
200 — the config's record, its replication, and its runs
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — config_not_found — no such config, or none of its sites are yours
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/configs/{id} \
-H 'authorization: Bearer ab_live_...'The counts and as-ofs the dashboard's exception generators read, folded once over the caller's whole working set instead of one request per tracked site. Carries, per target: when the standing answer was last verified, whether the last definitive verdict flipped, when a detector defending it last changed its loader, its quarantine and why, whether the search is stuck on tied candidates, and the caller's own schedule. **Life-cycle is deliberately NOT computed here** — the client already has one definition of it and a second would be able to disagree, so this serves its inputs. `consoles` is the caller's entitlement set, which is what lets a locked tile render its shape without its depth. Two halves with different cache semantics. Everything about the TARGET is caller-invariant and cached on the sorted site-id set, so two accounts tracking one roster read the same values and share one entry. The caller's cadence rows and the token-balance-derived `paused` are read per request and never cached, because a schedule is one row per (site, account) and a stale `paused: false` would tell a caller with an empty balance that their monitoring is still running.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — every tracked target, folded
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/console/index \ -H 'authorization: Bearer ab_live_...'
The caller's working set: sites they explicitly WATCH (`POST /v1/sites`, free), unioned with sites derived from their own job history (the distinct hostnames across jobs whose `params.requestedBy` is the caller). `watched` says which. `tracking` is null for a site watched but never run, which is an absence and is stated rather than zeroed. Non-metered. Each entry carries the last verdict and the SCALAR confidence only; the winning configuration bundle stays behind the metered `GET /v1/sites/{hostname}/config`.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — list the sites this account tracks
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites \ -H 'authorization: Bearer ab_live_...'
Adds the site to the caller's working set WITHOUT running anything and WITHOUT spending a token, which is the point: it records intent, so a cadence can be set before any money is. Idempotent — watching a watched site returns 200 with the existing watch, never 409. Creates the target row if the hostname is new. Bounded by a per-plan ceiling; over it, 422 `watch_limit_reached` naming the limit and the plan.
200 · 400 · 401 · 422 · 500
errors: 400 request validation failed · 401 authentication required · 422 watch_limit_reached · 500 internal error
| field | type | required | note |
|---|---|---|---|
| hostname | string | required | |
| note | string | optional | |
| url | string (uri) | optional |
200 — the watch, new or existing
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
422 — watch_limit_reached — the plan's watch ceiling is already used
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/sites \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"hostname":"<string>","note":"<string>","url":"<string (uri)>"}'Removes the watch. Leaves job history, runs and every site-level fact alone: this un-watches, it does not erase. Answers 204 whether or not a watch existed, so it cannot be used to probe what an account watches or whether a hostname exists.
parameters: hostname (path, string, required)
204 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
204 — no longer watching
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X DELETE https://automationbenchmark.com/v1/sites/{hostname} \
-H 'authorization: Bearer ab_live_...'Non-metered, and distinct from the metered `/config` recipe: the winning configuration bundle is NEVER served here, only the scalar confidence. Carries the latest detection profile (whose `vendors` list is often legitimately empty — the layers below it ARE measured), the re-verification cadence, the config scoreboard including quarantined entries, and the per-tool fact fold for this site. `cadence.metering` reports what the SCHEDULED runs cost and whether they are currently paused for an empty balance — derived from the live balance, and only for the account the schedule bills. A site the caller does not track answers 404, never 403, so this never leaks whether a site exists. `recentRuns` is owner-scoped through the same store query `GET /v1/runs` uses: the caller's own runs, plus runs the fleet took unprompted (`requestedBy IS NULL`) on this site. A run stamped for another account is never listed, even though both accounts track the same hostname. The SITE-LEVEL facts around it are shared on purpose and carry no attribution — `lastVerdict`, `lastBenchmarkedAt`, `confidence`, `bestConfig`, `scoreboard` and `toolFacts` describe the target, not a customer, and are the neutral benchmark knowledge the product sells.
parameters: hostname (path, string, required)
400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 site_not_found · 500 internal error
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — site_not_found — the site is not in your working set
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/{hostname} \
-H 'authorization: Bearer ab_live_...'`intervalHours: null` is an explicit "manual only", deliberately distinct from never having configured a cadence (which falls back to the fleet default). `nextDueAt` is derived server-side and never accepted from the caller: a client that could set its own due time could park a site permanently in the future, silently stopping re-verification while the UI kept claiming a cadence. The interval is bounded to 1..744 hours — under an hour is a self-DoS on a real target, over a month is indistinguishable from `enabled: false`. **Setting a cadence is a spending decision**: each scheduled run is metered to the account that set it, at `metering.costTokens` — the same cost as an on-demand benchmark. The response carries the live `metering` block so a caller who pins an interval they cannot fund is told immediately; `paused: true` means the next due run will be SKIPPED, never run on credit and never silently slowed.
parameters: hostname (path, string, required)
200 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 site_not_found · 500 internal error
| field | type | required | note |
|---|---|---|---|
| intervalHours | integer | null | optional | |
| enabled | boolean | optional | |
| scope | object | null | optional |
200 — the stored cadence, including the derived `nextDueAt`
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — site_not_found — the site is not in your working set
500 — internal error
ErrorEnvelope
curl -X PUT https://automationbenchmark.com/v1/sites/{hostname}/cadence \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"intervalHours":"<integer | null>","enabled":false,"scope":"<object | null>"}'A cache hit (fresh) or a fresh current-best in the DB charges the flat lookup cost and answers 200 with `source: "cache" | "db"`. A miss/stale answer instead RESERVES the (bigger) benchmark cost against a new `config-search` job and answers 202 `{jobId, status: "pending"}` — the worker settles or refunds that reservation on completion, and the result arrives by webhook or poll. "Fresh" means `lastVerified` within `stalenessSeconds` AND `confidence` ≥ `minConfidence`. Balance is checked BEFORE any debit or enqueue. Served configurations are watermarked per account so a leaked corpus is traceable.
parameters: hostname (path, string, required), callbackUrl (query, string (uri)), Idempotency-Key (header, string)
200 · 202 · 400 · 401 · 402 · 429 · 500
errors: 400 request validation failed · 401 authentication required · 402 insufficient_tokens · 429 rate_limited · 500 internal error
200 — a served config, with `source: "cache" | "db"`
202 — no fresh answer — a benchmark was enqueued: `{jobId, status: "pending"}`
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — insufficient_tokens — checked before any debit or enqueue
429 — rate_limited
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/example.com/config \ -H 'authorization: Bearer ab_live_...'
response
{
"hostname": "example.com",
"configuration": {
"browser": "browser.camoufox",
"proxy": "proxy.decodo",
"solver": null
},
"confidence": 0.92,
"lastVerified": "2026-01-15T08:00:00.000Z",
"stalenessSeconds": 5040,
"source": "cache"
}Band 3, the search: the candidate configs behind the one answer `GET /v1/sites/:hostname` publishes. Each carries its definitive counts, a TWO-SIDED Wilson interval and the other candidates it cannot be separated from — a tie is never rendered as a ranking, because the stored lower bound supports only the claim "this is at least X" and two configs at 4/5 and 5/5 do not separate. Zero-trial candidates are unmeasured rather than tied (their interval is [0,1] and would tie with everything) and quarantined ones are listed but held out of both, because a fleet-burn result says nothing about the config. No configuration bundle is served here at any tier: the recipe is the metered `/config` endpoint. A site the caller does not track answers 404, never 403. Gated by the caller's working set at the hostname, then unfiltered within it — the same shape `scoreboard` already has, and for the same reason: these rows describe the TARGET rather than a customer, so two accounts tracking one hostname read the same candidate list. No bundle, no `createdFrom`, and no quarantine reason: the first is the metered product, the second has its home on the config page, and the third is a SITE-level fact whose home is the per-site report, because fleet burn is every proven config failing together and stamping one site-level reason onto each candidate row would read as a per-config finding.
parameters: hostname (path, string, required)
200 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 site_not_found · 500 internal error
200 — the candidate list, its ties, and the counts it is read against
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — site_not_found — the site is not in your working set
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/{hostname}/configs \
-H 'authorization: Bearer ab_live_...'The measured detection profile (SQL) plus cited corpus prose. Unlike the free profile this is an LLM generation, so it is entitlement-gated like the axis insights. `insight` is null when no profile has been captured yet or the knowledge base is unwired.
parameters: hostname (path, string, required)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — return the grounded detection insight for a site
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/{hostname}/insight \
-H 'authorization: Bearer ab_live_...'The crowdsource sensor: the SDK's `report` lands here — a real-world pass/fail for a target, feeding the scheduler's scraper-health report. Authenticated and rate-limited, but NOT token-metered (telemetry is opt-in, not a paid lookup). An unknown hostname is rejected.
parameters: hostname (path, string, required)
202 · 400 · 401 · 404 · 429 · 500
errors: 400 request validation failed · 401 authentication required · 404 not_found · 429 rate_limited · 500 internal error
| field | type | required | note |
|---|---|---|---|
| outcome | "pass" | "fail" | required | |
| detail | string | optional |
202 — recorded
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — not_found — unknown target site
429 — rate_limited
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/sites/{hostname}/outcomes \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"outcome":"pass","detail":"<string>"}'Zero-touch site profiling on target intake: detected vendor(s), the detection surface (trapped APIs / exfil / categories / enforced layers), the challenge type(s), and a matrix-predicted recommended config. Authenticated but NOT tracking-gated (it works on the first intake of a brand-new hostname) and NOT token-metered — profiling is the free funnel. A fresh cached surface answers 200 `{status: "ready"}`; a miss/stale enqueues a `detect-profile` job and answers 202 `{status: "pending", jobId}`, deduping onto an in-flight profile job and still showing the stale surface while the new capture runs. The recommended config is matrix-derived and carries only a scalar `confidence` plus `verifiedRecipeAvailable`: the paid winning bundle stays behind the metered `GET /v1/sites/{hostname}/config`.
parameters: hostname (path, string, required)
200 · 202 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — a ready profile (`cached: true` when served from the store)
202 — a capture is pending — poll `jobId`
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/{hostname}/profile \
-H 'authorization: Bearer ab_live_...'Addressed by INDEX into the run's `artifactKeys`, never by key and never by a pre-signed URL, so the internal key never leaves the server and the reference cannot outlive the caller's entitlement. Same ownership check as the evidence route, plus the paid-tier gate, evaluated on every request. The paid gate is a PLAN check only: the self-grantable `evidence` scope unlocks the derived evidence view and never a capture, so a free-plan key carrying it answers 402 here. Restricted to `screenshot` kinds on purpose: `dom.html` and captured script bodies reach the same bucket unscrubbed, so a general artifact proxy over this index would serve unscrubbed hostile page HTML to a customer. Responses carry `nosniff` and `content-disposition: inline` — captured bytes must never execute in our origin.
parameters: hostname (path, string, required), runId (path, string (uuid), required), index (path, integer, required)
200 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 artifact_not_found · 500 internal error
200 — the image bytes, with the stored content type
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — run captures are included from the Pro plan; the self-grantable `evidence` scope does not substitute for it
404 — artifact_not_found — out of range, not a screenshot, or no longer stored (the row outlives the bucket)
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/{hostname}/runs/{runId}/artifacts/{index} \
-H 'authorization: Bearer ab_live_...'Raw `artifactKeys` are NEVER exposed at any tier — they are internal object-store paths that may carry another account's watermark, PII, or the full winning fingerprint. A run is the caller's iff its row-level `requestedBy` is theirs, or (only for fleet/legacy rows carrying no stamp) it belongs to a site in their tracked working set; any other run answers a uniform 404 that never discloses whether it exists. The run's tool axis (`config`) is included only for a run the caller REQUESTED, so the metered recipe stays behind `/config`. The body narrows for callers without the evidence entitlement (the `evidence` API-key scope, or any paid plan on a session): they see the verdict and whether evidence exists, but not the derived per-kind counts, digests or scalar signals. It is a redaction, not a 403. The 404 is chosen by what the caller already knows and never by what the run turns out to be: a hostname outside their working set answers `site_not_found` for every run id, and a hostname inside it answers `run_not_found` identically for an id that resolves to nothing, an id stamped for another account, and a run belonging to a different site — so no response confirms that a run exists.
parameters: hostname (path, string, required), runId (path, string (uuid), required)
400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 site_not_found for a hostname outside the working set; run_not_found for every unresolvable or invisible run under one inside it · 500 internal error
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — site_not_found for a hostname outside the working set; run_not_found for every unresolvable or invisible run under one inside it — identical whatever the reason
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/{hostname}/runs/{runId}/evidence \
-H 'authorization: Bearer ab_live_...'The per-target detection surface (the scripts a page serves that read a visiting browser, the APIs they trap, the tells they trip and what they exfiltrate) for a site in the caller's working set. It exists because `GET /v1/detection-surfaces` is a caller-invariant bulk dump that can only carry PUBLIC surfaces: a customer's own PRIVATE site is excluded from it, so its surface is served here instead, owner-scoped, and never enters the shared dump. The surface is neutral benchmark knowledge about the TARGET, so two accounts that both track a hostname read the same answer; the gate is the working set, not per-account attribution. A tracked site that has never been profiled answers 200 with `surface: null`. A site the caller does not track answers 404, never 403, so this never leaks whether another account's private site exists. Owner-scoped by the caller's working set at the hostname (the same predicate as `GET /v1/sites/:hostname` and `resolveOwnedRun`): a hostname outside it answers `site_not_found` identically whether or not a surface exists, so the response is not an oracle for another account's private site. Nothing here widens the public `GET /v1/detection-surfaces` dump; that route still excludes customer-private profile rows.
parameters: hostname (path, string, required)
200 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 site_not_found · 500 internal error
200 — the surface, or `surface: null` when the site has never been profiled
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — site_not_found — the site is not in your working set
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/{hostname}/surface \
-H 'authorization: Bearer ab_live_...'The per-site answer: the site's attributed detector vendors, and the strongest toolchain we have against them, carrying whether that chain is `measured`, `declared` or `mixed`. When no vendor is attributed the two cases are answered as OPPOSITES — `unattributed-detection` (detection scripts no catalog vendor claims, so the site IS defended and gets the strongest chain plus a statement that it is uncharacterised) versus `no-detection-observed` (nothing observed defending it). Reads the cached surface; it never triggers a capture.
parameters: hostname (path, string, required)
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — the recommendation, its basis, and why
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/sites/{hostname}/tooling \
-H 'authorization: Bearer ab_live_...'Returns `{effectiveness, ideal, gaps}` — the per-solver leaderboard carrying the token-accept rate rather than just solve-rate, the ideal solver per challenge x vendor, and the challenges no solver clears. Scored neutrally over measured solver outcome facts alone, with recency decay applied.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — score every captcha solver, the ideal solver per challenge, and the unsolved ones
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the solvers console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/solvers \ -H 'authorization: Bearer ab_live_...'
Structured solver metrics (SQL) joined with cited prose from the research corpus.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — grounded solver-axis insight: structured metrics plus cited corpus prose
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the solvers console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/solvers/insight \ -H 'authorization: Bearer ab_live_...'
A "stack" is the chosen `proxy + browser + solver` a benchmark sweeps. This enumerates the options per axis from the tool catalog — slug and display name only, no scores and no secrets — each tagged `runnable` (whether the fleet actually runs it). The browser axis lists the automation browsers we run followed by the anti-detect apps we track but do not yet run. Any catalog load error degrades to empty lists, never a 500.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — list the proxy, browser, and solver options for the benchmark composer
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/stack/options \ -H 'authorization: Bearer ab_live_...'
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — list the teams the caller owns or belongs to
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/teams \ -H 'authorization: Bearer ab_live_...'
Requires the caller's OWN subscription to be `team` or `enterprise` (owner's request, 2026-09-01) — this REVERSES the decision that left creation open to any account. `plan` is still not accepted from the body (the other half of): a team's plan elevates every member's console entitlements, so a team is created on the default plan and changing it is a billing action.
201 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
| field | type | required | note |
|---|---|---|---|
| name | string | required |
201 — the created team
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — creating a team needs the team or enterprise plan (reverses)
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/teams \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"name":"acme"}'request body
{
"name": "acme"
}response
{
"team": {
"id": "team_1a2b3c4d",
"name": "acme",
"ownerId": "user_5e6f7a8b",
"plan": "free",
"tokenBalance": 0,
"seatsPurchased": 0,
"createdAt": "2026-01-15T09:24:00.000Z"
}
}Owner only, and the team's name must be typed back in `confirmName` — the same shape as the account danger zone. Members lose the elevated entitlements the team granted them; the shared pool goes with it, which is why the confirmation is not optional.
parameters: id (path, string (uuid), required)
204 · 400 · 401 · 500
errors: 400 name_mismatch · 401 authentication required · 500 internal error
| field | type | required | note |
|---|---|---|---|
| confirmName | string | required |
204 — deleted
400 — name_mismatch — the confirmation did not match
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X DELETE https://automationbenchmark.com/v1/teams/{id} \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"confirmName":"<string>"}'Members only. A team the caller does not belong to answers 404, the same answer an unknown id gets, so existence is not leaked to a non-member.
parameters: id (path, string (uuid), required)
400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 team_not_found · 500 internal error
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — team_not_found — unknown team, or the caller is not a member
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/teams/{id} \
-H 'authorization: Bearer ab_live_...'Owner only. The projection never includes the invite token or its hash — the plaintext was shown once at creation and is not recoverable here.
parameters: id (path, string (uuid), required)
400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 team_not_found · 500 internal error
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — team_not_found — unknown team, or the caller is not a member
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/teams/{id}/invites \
-H 'authorization: Bearer ab_live_...'Owner only. The plaintext accept token is returned exactly once — the inviter shares it with the invitee, who redeems it at `POST /v1/teams/invites/accept`.
parameters: id (path, string (uuid), required)
201 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 team_not_found · 500 internal error
| field | type | required | note |
|---|---|---|---|
| string (email) | required | ||
| role | "admin" | "member" | optional |
201 — the invite plus the one-time plaintext `token`
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — team_not_found — unknown team, or the caller is not a member
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/teams/{id}/invites \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"email":"<string (email)>","role":"admin"}'Any member may remove themselves. The owner cannot: they transfer ownership or delete the team, because a team with no owner has nobody who can pay for it or close it.
parameters: id (path, string (uuid), required)
204 · 400 · 401 · 409 · 500
errors: 400 request validation failed · 401 authentication required · 409 owner_cannot_leave · 500 internal error
204 — left
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
409 — owner_cannot_leave — transfer ownership or delete the team
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/teams/{id}/leave \
-H 'authorization: Bearer ab_live_...'Owner only. The team owner cannot be removed, so a team is never left without one.
parameters: id (path, string (uuid), required), userId (path, string (uuid), required)
204 · 400 · 401 · 404 · 500
errors: 400 cannot_remove_owner · 401 authentication required · 404 team_not_found for a team the caller cannot see; member_not_found for a user who is not a member of it · 500 internal error
204 — the member was removed
400 — cannot_remove_owner — the team owner cannot be removed
401 — authentication required
ErrorEnvelope
404 — team_not_found for a team the caller cannot see; member_not_found for a user who is not a member of it
500 — internal error
ErrorEnvelope
curl -X DELETE https://automationbenchmark.com/v1/teams/{id}/members/{userId} \
-H 'authorization: Bearer ab_live_...'Owner only. Promoting a member to `owner` hands them every owner-gated action on this team, including inviting and removing other members and granting to the shared pool.
parameters: id (path, string (uuid), required), userId (path, string (uuid), required)
400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 team_not_found for a team the caller cannot see; member_not_found for a user who is not a member of it · 500 internal error
| field | type | required | note |
|---|---|---|---|
| role | "admin" | "member" | required |
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — team_not_found for a team the caller cannot see; member_not_found for a user who is not a member of it
500 — internal error
ErrorEnvelope
curl -X PATCH https://automationbenchmark.com/v1/teams/{id}/members/{userId} \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"role":"admin"}'Owner only, and a STUB: it adjusts the team-owned prepaid balance directly with no payment behind it.
parameters: id (path, string (uuid), required)
201 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 team_not_found · 500 internal error
| field | type | required | note |
|---|---|---|---|
| amount | integer | required |
201 — the granted amount and the resulting shared balance
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — team_not_found — unknown team, or the caller is not a member
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/teams/{id}/tokens/grant \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"amount":0}'Owner only. A team has exactly one owner; this is the only way it changes. The outgoing owner becomes an `admin` rather than being removed, because dropping them to `member` (or out entirely) on a handover is a surprise nobody asked for.
parameters: id (path, string (uuid), required)
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
| field | type | required | note |
|---|---|---|---|
| userId | string (uuid) | required |
200 — the team, with its new owner
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/teams/{id}/transfer \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"userId":"<string (uuid)>"}'The caller joins with the role the invite was minted for, which elevates their console entitlements to the team's plan. An invite that is unknown or already used answers the same 404.
400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 invite_not_found · 500 internal error
| field | type | required | note |
|---|---|---|---|
| token | string | required |
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — invite_not_found — unknown or used invite
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/teams/invites/accept \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"token":"<string>"}'The only usage view; there is no cost dashboard. Each ledger entry carries the signed `delta`, the `reason` it was written for, and the idempotency `ref`.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — read the token balance and recent ledger entries
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tokens \ -H 'authorization: Bearer ab_live_...'
response
{
"balance": 4975,
"ledger": [
{
"id": "led_1a2b3c4d",
"delta": -25,
"reason": "benchmark",
"ref": "job_9f3a2b1c",
"subject": "example.com",
"createdAt": "2026-01-15T09:24:00.000Z"
}
]
}A fold over the ledger, which is already the balance source of truth, so nothing here can drift from it. Buckets are `day` (default), `week` or `month`, and empty buckets are emitted so a chart shows the gaps rather than drawing a burn line steeper than the truth. Spend is NET of refunds and reserve adjustments: a refunded benchmark cost the customer nothing. The `projection` is a trailing-14-day mean and says so in `method`; its `confidence` is derived from the sample, and at `low` there is no exhaustion date at all, because a projection drawn through three days of lumpy usage is worse than none.
parameters: from (query, string (date-time)), to (query, string (date-time)), bucket (query, "day" | "week" | "month")
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — buckets, balance, allotment and projection
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tokens/usage \ -H 'authorization: Bearer ab_live_...'
Resolved from the capability matrix — the same source `/v1/matrix` serves, so the two surfaces cannot drift apart. `id` accepts either the canonical plugin id (`browser.camoufox`) or the bare slug the browser and proxy pages address (`camoufox`), which is resolved server-side across every `/v1/tools/:id*` route; a legacy alias resolves too. An id that resolves to nothing, or a bare slug two categories both mint, answers 404 rather than guessing.
parameters: id (path, string, required)
200 · 400 · 404 · 500
errors: 400 request validation failed · 404 tool_not_found · 500 internal error
200 — the tool metadata, capabilities, and provider
400 — request validation failed
ErrorEnvelope
404 — tool_not_found
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tools/{id} \
-H 'authorization: Bearer ab_live_...'The live fold from `tool_fingerprint_facts`: how the fingerprint reads (clean/suspect/bot), the hard automation tells it leaks by name, the contradiction reasons, and its stealth. Paid, on the `detector` console, mirroring the fingerprint route, and resolving `id` the same way (canonical plugin id or bare slug). An absent fact yields honest nulls, never a fabricated "clean".
parameters: id (path, string, required)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — get how a tool fingerprint reads: quality, hard automation tells, and stealth
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tools/{id}/coherence \
-H 'authorization: Bearer ab_live_...'Paid, on the `detector` console: this is what a MEASURED run observed, not a catalog claim, and it is the depth that console sells. Resolves `id` as `/v1/tools/:id` does (canonical plugin id or bare slug). Degrades to `fingerprint: null` (never a 404) when no run has captured one yet, so the tool page renders a clean empty state and still links the live xray-scanner probe. `toolId` echoes the CANONICAL id the slug resolved to, not the string that was sent.
parameters: id (path, string, required)
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — get the browser fingerprint a tool presents, captured from real benchmark runs
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tools/{id}/fingerprint \
-H 'authorization: Bearer ab_live_...'The tool's own measured run data (proxy/solver/browser effectiveness) is pulled from the relational store and handed to the KB seam as an AUTHORITATIVE metrics block alongside the retrieved research prose — so numbers come from the database and prose is cited from the corpus. Read-through the durable insights table, hash-gated on those metrics: a match skips the model, a change regenerates. `insight` is null when the KB is not wired or the corpus is empty.
parameters: id (path, string, required)
200 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 tool_not_found · 500 internal error
200 — metrics, best-for, and the cited insight (or null)
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
404 — tool_not_found
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tools/{id}/insight \
-H 'authorization: Bearer ab_live_...'One fetch drives the tool speed/score/success charts, its per-vendor breakdown, and the list of individual runs it took part in. Ambiguous attempts are held out of the pass-rate denominator. The headline is full-stack survival: the tool recency-decayed pass rate over ALL of its targets, the same one number `/v1/matrix`, `/v1/browsers` and the live grid publish, so the surfaces cannot disagree about a tool pass rate; the `scope` object reports `basis: full-stack`. A proxy additionally carries `byClass` — residential / isp / datacenter / mobile folded separately, never averaged together. `stealth` carries what the detector pages SCORED this tool — one reading per (signal key x target) with its median, range, latest value and a bucketed trend. Readings are NEVER merged across targets: CreepJS `stealthPct` and xray-scanner `stealthScore` are different instruments on different scales, and a cross-instrument average is a number no target ever reported. These are EVIDENCE, never the verdict — `humanWhen` remains the sole pass/fail authority, and nothing in `stealth` feeds `totals`, `windows` or any ranking.
parameters: id (path, string, required), bucket (query, "hour" | "day"), sinceDays (query, integer), vendor (query, string), limit (query, integer)
200 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 tool_not_found · 500 internal error
200 — windows, per-vendor cells, recent runs, and headline totals
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
404 — tool_not_found
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tools/{id}/runs \
-H 'authorization: Bearer ab_live_...'Gated like the sibling measured surfaces because it carries per-version pass rates. `current` is the newest version; `history` is the drift, newest-first, with ambiguous outcomes held out of the denominator. The engine build is a separate axis from the tool version, so it comes off the latest observed row.
parameters: id (path, string, required)
200 · 400 · 401 · 402 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 404 tool_not_found · 500 internal error
200 — the current version and the newest-first version history
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
404 — tool_not_found
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tools/{id}/versions \
-H 'authorization: Bearer ab_live_...'Paid, on the `detector` console: the latest observed version per tool from the run provenance the fleet stamps, plus the browser `engineVersion` (null = not determined). It carries no rate and no verdict, but it is an OBSERVATION rather than a catalog claim. A tool with no versioned run yet simply does not appear — an absence, never a guess.
200 · 400 · 401 · 402 · 500
errors: 400 request validation failed · 401 authentication required · 402 upgrade_required · 500 internal error
200 — list the latest observed version of every tool the fleet runs
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
402 — upgrade_required — the detector console needs the pro plan
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/tools/versions \ -H 'authorization: Bearer ab_live_...'
Requires an account rather than being anonymous: the blurb is computed over the corpus with no provenance filter, which makes it an aggregate OVER fleet items, and prose that says "detections rose 40% this month" leaks the fleet stream's shape even though it quotes no item from it.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — grounded trends blurb: market-trend deltas plus cited corpus prose
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/trends/insight \ -H 'authorization: Bearer ab_live_...'
Non-sensitive metadata only: neither the signing secret nor its hash is ever returned, so a listed endpoint cannot be used to re-derive its signature. `signable` reports whether this deploy can still recover the endpoint's signing secret — `false` means the endpoint receives nothing until it is re-registered, because a delivery to it could not be signed.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — list the webhook endpoints registered by this account
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/webhooks \ -H 'authorization: Bearer ab_live_...'
The URL is run through the SSRF guard — HTTPS-only, DNS-resolved, private/loopback/link-local/metadata ranges rejected — BEFORE it is persisted, and re-validated at delivery time in the worker (anti-rebind). `signingSecret` is returned exactly once: verify `X-AB-Signature` (HMAC-SHA256) against it. The plaintext is always stored hashed, and additionally sealed at rest when a recoverable-secret box is configured. Without one the secret is unrecoverable, so the endpoint can never be signed for and receives nothing — the response reports `signable: false` and the endpoint must be re-registered once a key is configured.
201 · 400 · 401 · 402 · 429 · 500
errors: 400 invalid_webhook_url · 401 authentication required · 402 upgrade_required · 429 rate_limited · 500 internal error
| field | type | required | note |
|---|---|---|---|
| url | string (uri) | required | |
| events | string[] | optional |
201 — the endpoint, plus the one-time `signingSecret`
400 — invalid_webhook_url — the SSRF guard rejected the url
401 — authentication required
ErrorEnvelope
402 — upgrade_required — registering an endpoint needs pro, team or enterprise
429 — rate_limited
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl -X POST https://automationbenchmark.com/v1/webhooks \
-H 'authorization: Bearer ab_live_...' \
-H 'content-type: application/json' \
-d '{"url":"https://your-app.example.com/hooks/ab"}'request body
{
"url": "https://your-app.example.com/hooks/ab"
}response
{
"id": "wh_1a2b3c4d",
"url": "https://your-app.example.com/hooks/ab",
"active": true,
"events": null,
"signable": true,
"signingSecret": "whsec_9f3a2b1c4d5e6f70"
}Owner-scoped: an id belonging to another account answers the same 404 an unknown id gets. Deliveries to the endpoint stop immediately.
parameters: id (path, string, required)
204 · 400 · 401 · 404 · 500
errors: 400 request validation failed · 401 authentication required · 404 webhook_not_found · 500 internal error
204 — deleted
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
404 — webhook_not_found — unknown webhook
500 — internal error
ErrorEnvelope
curl -X DELETE https://automationbenchmark.com/v1/webhooks/{id} \
-H 'authorization: Bearer ab_live_...'Owner-scoped through the endpoint join. `status` is `pending` | `delivered` | `dead` | `unsignable`. `unsignable` is a delivery that was OWED to a registered endpoint and deliberately not sent, because the endpoint’s signing secret could not be recovered — those rows carry no `lastAttemptAt`, because nothing was ever attempted, and they lead the list. Re-register the endpoint to fix it.
200 · 400 · 401 · 500
errors: 400 request validation failed · 401 authentication required · 500 internal error
200 — the newest deliveries, attempts-first
400 — request validation failed
ErrorEnvelope
401 — authentication required
ErrorEnvelope
500 — internal error
ErrorEnvelope
curl https://automationbenchmark.com/v1/webhooks/deliveries \ -H 'authorization: Bearer ab_live_...'
a benchmark you trigger delivers its result to every active endpoint you have registered. one exception, and it is not recoverable: an endpoint registered while this deploy had no recoverable-secret key holds only a HASH of its signing secret, which can verify a secret but can never reproduce one. we will not sign with an empty key (that is a signature anyone can forge) and we will not POST unsigned to a receiver we promised signatures to, so such an endpoint receives nothing. it reports signable: false on GET /v1/webhooks, its owed deliveries appear as unsignable in GET /v1/webhooks/deliveries, and re-registering it is the only fix.
we POST the result body to your url with these headers. verify the signature before trusting the payload: recompute the HMAC-SHA256 of "${timestamp}.${body}" with your secret and compare in constant time (see the snippet below). the X-AB-Signature value is scheme-prefixed (sha256=<hex>); the timestamp binds the signature for replay protection. deliveries retry with exponential backoff and dead-letter after the max attempts. an X-AB-Idempotency-Key lets you dedupe at-least-once delivery.
POST https://your-app.example.com/hooks/ab
Content-Type: application/json
X-AB-Signature: sha256=<hex>
X-AB-Timestamp: 1751371200
X-AB-Idempotency-Key: job_...:endpoint_...
{ "jobId": "job_...", "type": "benchmark", "status": "done",
"result": { "hostname": "example.com", "configuration": { } } }// node: verify the signature
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, rawBody, header, timestamp) {
const expected = 'sha256=' +
createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}the api is versioned by its path prefix. every endpoint on this reference lives under /v1, and a breaking change would ship under a new prefix rather than mutating /v1 under you. additive changes such as new endpoints and new optional fields arrive on /v1 without a version bump, so read defensively and ignore fields you do not recognise.
before an endpoint is removed it is marked deprecated in the machine-readable spec (the OpenAPI deprecated flag) and on its card here, so a client can detect the mark and migrate before anything breaks. the list below is generated from that flag, so it is never out of date.
no endpoint is currently deprecated. when one is, it appears here automatically.
picking up where you left off