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://api.automation-benchmark.dev/v1 Content-Type: application/json Authorization: Bearer <your-api-key>
the hosted api is not serving on that hostname yet. every example below is correct against a running instance. point them at your own with BASE=http://localhost:3000 and they run as written. we will drop this note the day the domain resolves rather than quietly leaving a dead url in the examples.
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://api.automation-benchmark.dev/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://api.automation-benchmark.dev/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://api.automation-benchmark.dev/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://api.automation-benchmark.dev/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://api.automation-benchmark.dev/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.
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
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
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
The discovery layer for the console FacetBar (KAN-267) — what a caller can filter or group the metric cube by, folded from the same `tool_run_facts` window.
parameters: windowDays (query)
200 · 400 · 401 · 402 · 403 · 500
The sibling of `/v1/analytics/uptime` on a different axis (KAN-309): detector availability there, OUR tooling's reachability here.
parameters: windowDays (query), componentSlug (query)
200 · 400 · 401 · 402 · 403 · 500
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 · 403 · 500
Shaped from the config-canary history (KAN-260) so the console can draw per-detector uptime/outage ribbons (KAN-263). `vendor` is a detection FAMILY (the `@ab/harness` 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 the console never guesses a link from a name (KAN-457). `products: []` means the catalog declares none; `products: null` means the catalog could not be read (unknown, never "none").
parameters: windowDays (query), vendor (query)
200 · 400 · 401 · 402 · 403 · 500
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 · 403 · 500
`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 · 403 · 409 · 500
Owner-scoped: another account's view id answers 404, the same answer an unknown id gets.
parameters: id (path, required)
204 · 400 · 401 · 402 · 403 · 404 · 500
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, required)
200 · 400 · 401 · 402 · 403 · 404 · 409 · 500
Sets the httpOnly session cookie on success. An unknown address and a wrong password answer the same 401 `invalid_credentials`.
200 · 400 · 403 · 500
Idempotent — a caller with no session still gets 200 and still has the cookie cleared.
200 · 400 · 500
The compromise lever: ends every session for the account, on every device, and clears this response's session cookie because the caller's own session is among them. Deliberately does NOT spare the caller — someone reaching for this cannot tell their sessions from an attacker's. Idempotent. API keys are a separate credential and are unaffected; revoke those with `DELETE /v1/keys/:id`.
200 · 400 · 401 · 403 · 500
`emailVerified` is a boolean, never the timestamp — the console only ever asks whether the address is confirmed.
200 · 400 · 401 · 500
Always 202 with the same body, whether or not an account holds that address. This is the one endpoint that takes an arbitrary address from an anonymous caller and does something conditional on it, so it must not become a way to enumerate the user table: one status, one body, and comparable timing on every path. A rate-limited request answers with that same 202 rather than a 429, because per-address counting would itself be a signal. A malformed body keeps the ordinary 400 — that says nothing about who has an account.
202 · 400 · 500
Unauthenticated and POST only, for the same reasons as `/v1/auth/verify`. No session cookie comes back: every session for the account was just deleted, so the next step is signing in with the new password. Handing out a fresh session here would quietly make this a login that skips the password check for anyone who ever intercepted a link. The caller's own cookie is cleared.
400 · 500
Sets the httpOnly session cookie on success. `acceptedTerms` MUST be true — the server enforces the terms/privacy acceptance, not just the client checkbox. Credits the one-time activation token grant (idempotent by ledger ref) and mails the confirmation link; neither is awaited into the success condition, so a mail-provider outage degrades to "click resend" rather than a failed signup.
201 · 400 · 409 · 500
POST, and only POST: mail scanners and link pre-fetchers routinely fetch every URL in an inbound message, and a GET that verified would let them burn the token before the recipient clicked it. Unauthenticated on purpose — possession of the token is the proof, because the person clicking the link is frequently not the person holding a session in that browser.
200 · 400 · 500
Always 202, including for an account that is already verified. A 409-if-verified would turn this into a state oracle for a stolen cookie, and a user who is unsure whether they confirmed should be able to press the button and get a coherent answer either way. Rate-limited through the shared bucket so the button is not a mail-bombing primitive.
202 · 400 · 401 · 429 · 500
`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.
200 · 400 · 401 · 500
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
Cancels at the end of the paid period by default, so entitlements stand until `currentPeriodEnd`. `immediate: true` ends it now. The row is not written here beyond the intent flag: the authoritative status arrives on the `customer.subscription.updated` / `.deleted` webhook, so our state cannot disagree with what Stripe actually did.
200 · 400 · 401 · 404 · 500
Creates (or reuses) the account's Stripe customer and opens a Checkout Session in subscription mode for the plan and term. Nothing is granted here: the plan and its token allotment land only when the verified webhook confirms payment, so an abandoned checkout leaves no entitlement behind. Enterprise answers 409 `contact_sales`; the free plan answers 400, because there is nothing to charge for.
201 · 400 · 401 · 409 · 500 · 503
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 and the lookup/benchmark token costs, read from the same context the billing path bills from, so a published figure cannot drift from what a card is charged.
200 · 400 · 500
Returns a redirect URL to Stripe's hosted portal, where the customer manages their card, downloads invoices, and cancels. An account that has never reached a payment surface has no Stripe customer and answers 404.
201 · 400 · 401 · 404 · 500
STUB self-serve grant with no Stripe round-trip: it sets the plan directly and credits the allotment, which elevates the console entitlements the plan unlocks. Paid plans require `selfServePlanGrants` (local dev only) and otherwise answer 501; downgrading to `free` is always allowed. The grant is period-stamped in the ledger, so a re-subscribe never double-credits.
201 · 400 · 401 · 500 · 501
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 flywheel; 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 (KAN-200). 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, KAN-164) — 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 (KAN-464). 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 (KAN-433). `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 · 403 · 500
The path parameter `browser` is the browser slug. A slug we do not have 404s (KAN-422) — 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 (KAN-164), 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 (KAN-464). `confidenceBasis` states that definition on the surface, including the half-life and the instant the decay was evaluated at.
parameters: browser (path, required)
200 · 400 · 401 · 402 · 403 · 404 · 500
The catalog of datasets is public metadata; the freshness gate is per-dataset on `/v1/datasets/:slug`.
200 · 400 · 500
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, required), asOf (query)
200 · 400 · 500
Payload-free — capture date, row count, content hash and catalog version per snapshot, for building "how this dataset moved" charts.
parameters: slug (path, required), limit (query)
200 · 400 · 500
The neutral "is our bot vendor actually blocking bots, and how do we compare?" leaderboard. Scoring is delegated to `@ab/defense` over our benchmark outcomes, so the numbers cannot be influenced by who pays (the §2.3/§13.14 neutrality firewall).
200 · 400 · 401 · 402 · 403 · 500
parameters: a (path, required), b (path, required)
400 · 401 · 402 · 403 · 404 · 500
The vendor's measured facts (SQL) joined with cited prose from the research corpus (KAN-131).
parameters: slug (path, required)
200 · 400 · 401 · 402 · 403 · 500
What `script2builtins-runtime` traps per target: the per-vendor profiles (trapped APIs, bot-tells, exfil sinks, categories) plus unknown/new detectors, the surface hash, and whether a change is pending review. Read from `detection_surfaces`, written by the news `vendor-script` loop.
200 · 400 · 401 · 500
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 (KAN-479). 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 · 403 · 500
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, required)
200 · 400 · 401 · 402 · 403 · 500
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: null` means no scan has reached that vendor — an absence, not "it probes nothing", and never a stale count from the imported blob (KAN-479). `asOf` dates the freshest live scan behind the counts.
200 · 400 · 401 · 402 · 403 · 500
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. Every field is live or catalog; the imported snapshot is not read (KAN-479), so `tells: null` means no scan has reached this vendor rather than "it probes nothing". `script` is the script2builtins scan, whose unattributed clusters are what `pendingProposals` exists to fix. `modelledAsOf` dates the modelled half.
parameters: slug (path, required)
200 · 400 · 401 · 402 · 403 · 404 · 500
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
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 by `@ab/defense` over the catalog matrix plus our own vendor/proxy/solver outcome facts; the same composition backs `/v1/roadmap`.
200 · 400 · 401 · 402 · 403 · 500
The cross-axis gap surface (SQL) joined with cited prose from the research corpus (KAN-131).
200 · 400 · 401 · 402 · 403 · 500
Calibration is FREE — it is the model`s honesty check. The real-world half is sourced from the SAME windowed ledger fold as the detectors grid, so the two tabs can never disagree about a tool`s measured outcome; the inferred half stays the model (fingerprint facts). Same window as the detectors grid, so the two age identically.
200 · 400 · 500
Every cell is a live pass/block rate the fleet actually observed, folded from the authoritative per-attempt ledger `tool_run_facts` over a fixed window — not the lossy `browser_outcomes` flywheel and not an imported blob, so this grid and the calibration tab can never disagree. Columns are RESTRICTED to the browser layer`s scoreable targets (controlled test pages that yield a per-tool score); an edge defender we can only observe pass/block on earns no column here even when the fleet measured it. 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: `scoreable` normally, or `unavailable` with a `reason` when the catalog could not be loaded — in which case the grid is served EMPTY rather than silently widened to every measured vendor (KAN-464).
200 · 400 · 500
The MODEL, not an outcome: the tool half (leaks / fingerprint quality / stealth) is the fleet`s own live measurement from `tool_fingerprint_facts`, while the vendor tell-sets come live from the script2builtins 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 (spec §0.6 rule 2), 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 (KAN-478). 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
The flagship, folded LIVE from the fleet`s own facts (KAN-475): the measured grid from the run ledger, the inferred model from `tool_fingerprint_facts` + `detection_surfaces`, and the calibration between them — the SAME three folds `/v1/harness/detectors-live`, `/inferred-live` and `/calibration-live` serve, so this surface can only restate what its tabs say. It used to serve an imported 2026-06-11 snapshot. `basis` dates each half separately (`modelledAsOf` / `measuredAsOf`) rather than blending them, and states the trial window the measured half looks back over. Free gets the verdict; paid gets the breakdown (spec §0.6 rule 2, amending ADR 0012). This surface has a PUBLIC face, so the gate is not a rejecting preHandler: an unentitled caller gets a NARROWER object and a 200. Entitlement is the `detector` plan console, with a `defense`-scoped API key as the alternate path. With nothing measured yet the answer is `basis: null` with a reason — an empty state, never an all-clear grid. The response body is projected server-side by caller: an unentitled caller receives a narrower object than an entitled one, and `detailed` says which projection was served. The withholding happens in the API, not the UI.
200 · 400 · 500
Columns are RESTRICTED to the proxy layer's scoreable targets (the IP-reputation scorers), so a proxy's grid measures the PROXY — its egress IP — and not the browser it rode. 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 falling back to an unrestricted vendor axis (KAN-464).
200 · 400 · 500
Columns are RESTRICTED to the solver layer's scoreable targets (the real reCAPTCHA/hCaptcha challenges), so the grid measures the solver rather than the stack around it. 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 falling back to an unrestricted vendor axis (KAN-464).
200 · 400 · 500
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), granularity (query)
200 · 400 · 401 · 402 · 403 · 500
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), limit (query), subjectType (query), subject (query)
200 · 400 · 401 · 402 · 403 · 500
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, required), value (query, required), windowDays (query), granularity (query)
200 · 400 · 401 · 402 · 403 · 500
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), entityId (query), eventType (query), sourceId (query), provenance (query), minRelevance (query), since (query), sort (query), limit (query)
200 · 400 · 500
Prefers the DURABLE precomputed insight (KAN-305), 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, required)
200 · 400 · 401 · 404 · 500
`: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, required), entityType (query), entityId (query), eventType (query), sourceId (query), provenance (query), minRelevance (query), since (query), sort (query), limit (query)
400 · 500
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, required)
200 · 400 · 500
200 · 400 · 401 · 500
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
Owner-scoped: another account's subscription id answers 404, the same answer an unknown id gets.
parameters: id (path, required)
204 · 400 · 401 · 404 · 500
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, required)
200 · 400 · 500
Admission validates EXECUTABILITY, not enum membership (KAN-401): 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)
202 · 400 · 401 · 402 · 409 · 422 · 429 · 500
The poll fallback for a missed webhook (§11.1). A job is only visible to the account that created it (`params.requestedBy`); a mismatch answers 404 rather than 403 so another customer's job existence never leaks (IDOR, §22).
parameters: id (path, required)
400 · 401 · 404 · 500
`keyHash` is deliberately absent from the projection: it is a plain SHA-256 of the token, so publishing it would let anyone holding a candidate token confirm it offline.
200 · 400 · 401 · 403 · 500
Session-only (KAN-391): a bearer key cannot mint another key. The body field is `label`, not `name`; unknown fields are stripped. Only `evidence` is self-grantable — any other scope answers 400 `invalid_scope`. A self-grantable scope is a convenience switch, never a receipt: it unlocks `evidence`’s DERIVED, redacted view (per-kind counts, opaque digests, the screenshot index list, trials and latency) and can never substitute for a paid plan, so it does not serve raw captures (KAN-466).
201 · 400 · 401 · 403 · 500
Idempotent — a second DELETE returns 200 with the unchanged `revokedAt`. Another account's key id answers 404, the same answer an unknown id gets, so this is not an id oracle.
parameters: id (path, required)
200 · 400 · 401 · 403 · 404 · 500
Public and ungated by design — the matrix is the free tier`s complete product and the SEO/AI-search surface (§18.3). Since KAN-195 / ADR 0012 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 here is the deliberate boundary — an anonymous reader can check a claim against a result. `measured` also states what it CANNOT separate (KAN-433): 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 (KAN-509): `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, the share of its attempts that would have to move to match the exam every other tool sat.
200 · 400 · 500
`build.stale: true` means the process is running older code than the last build (KAN-201). Never fails the probe: staleness is a report, not a liveness verdict.
200 · 400 · 500
Generated at request time from the routes this instance registered and the zod schemas its handlers validate with. Never hand-edited.
200 · 400 · 500
The merged door onto the `news_items` corpus (C13, KAN-223). 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. 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), entityId (query), eventType (query), sourceId (query), provenance (query), minRelevance (query), since (query), sort (query), limit (query)
200 · 400 · 500
Returns `{effectiveness, ideal, gaps}` — the per-proxy leaderboard, the ideal proxy per detection vendor (the recommendation), and the vendors no proxy beats. Scoring is delegated to `@ab/defense` over our own `proxy_outcomes` facts, with recency decay applied.
200 · 400 · 401 · 402 · 403 · 500
The path parameter `provider` is the proxy provider slug. A slug we do not have 404s (KAN-422) — 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, required)
200 · 400 · 401 · 402 · 403 · 404 · 500
Structured proxy metrics (SQL) joined with cited prose from the research corpus (KAN-131).
200 · 400 · 401 · 402 · 403 · 500
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, required), vendor (query)
200 · 400 · 401 · 402 · 403 · 500
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)
200 · 400 · 401 · 402 · 403 · 500
`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
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 (KAN-152). 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), blockingLayer (query), runReason (query), hostname (query), since (query), limit (query)
200 · 400 · 401 · 402 · 403 · 500
The drill-through the Runs table, the matrix cells and the site/tool cells all point at (KAN-340): 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 (KAN-422). 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, required)
200 · 400 · 401 · 402 · 403 · 404 · 500
There is no customer↔site association table: the tracked/benchmarked working set is derived server-side from the caller's own job history — the distinct hostnames across jobs whose `params.requestedBy` is the caller. 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
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 (ADR 0020) — 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 (KAN-152): 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, required)
400 · 401 · 404 · 500
`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** (ADR 0020): 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, required)
200 · 400 · 401 · 404 · 500
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 (§11.1). "Fresh" means `lastVerified` within `stalenessSeconds` AND `confidence` ≥ `minConfidence` (§8/§11). Balance is checked BEFORE any debit or enqueue. Served configurations are watermarked per account so a leaked corpus is traceable (§13.5).
parameters: hostname (path, required), callbackUrl (query), Idempotency-Key (header)
200 · 202 · 400 · 401 · 402 · 429 · 500
The measured detection profile (SQL) plus cited corpus prose (KAN-131). 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, required)
200 · 400 · 401 · 402 · 403 · 500
The crowdsource sensor (§17.4, WP5.5): 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, required)
202 · 400 · 401 · 404 · 429 · 500
Zero-touch site profiling on target intake (KAN-98): 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, required)
200 · 202 · 400 · 401 · 500
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 (KAN-466): 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, required), runId (path, required), index (path, required)
200 · 400 · 401 · 402 · 404 · 500
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 (KAN-152): 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, required), runId (path, required)
400 · 401 · 404 · 500
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 by `@ab/defense` over our own `solver_outcomes` facts, with recency decay applied.
200 · 400 · 401 · 402 · 403 · 500
Structured solver metrics (SQL) joined with cited prose from the research corpus (KAN-131).
200 · 400 · 401 · 402 · 403 · 500
A "stack" is the chosen `proxy + browser + solver` the console-wide filter highlights or ranks every surface against. This enumerates the pickable options per axis from the bundled `@ab/catalog` — slug and display name only, no scores and no secrets — so the topbar selector can populate before any console data loads. Any catalog load error degrades to empty lists, never a 500.
200 · 400 · 401 · 500
200 · 400 · 401 · 500
`plan` is deliberately not accepted here (KAN-382). A team's plan elevates every member's console entitlements, so taking it from the body let any signup create a team on `enterprise` and read every gated console for free. A team is created on the default plan; changing it is a billing action.
201 · 400 · 401 · 500
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, required)
204 · 400 · 401 · 500
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, required)
400 · 401 · 404 · 500
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, required)
400 · 401 · 404 · 500
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, required)
201 · 400 · 401 · 404 · 500
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, required)
204 · 400 · 401 · 409 · 500
Members only. A team the caller does not belong to answers 404, the same answer an unknown id gets.
parameters: id (path, required)
400 · 401 · 404 · 500
Owner only. The team owner cannot be removed, so a team is never left without one.
parameters: id (path, required), userId (path, required)
204 · 400 · 401 · 404 · 500
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, required), userId (path, required)
400 · 401 · 404 · 500
Owner only, and a STUB: it adjusts the team-owned prepaid balance directly with no payment behind it.
parameters: id (path, required)
201 · 400 · 401 · 404 · 500
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, required)
200 · 400 · 401 · 500
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
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
In Stripe mode nothing is credited here — the ledger is credited only by the verified `POST /v1/stripe/webhook` once payment confirms, and the response carries `mode: "stripe"` plus the checkout session. In dev/test stub mode (no Stripe keys) the ledger is credited directly and the response carries `mode: "stub"`.
201 · 400 · 401 · 500
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), to (query), bucket (query)
200 · 400 · 401 · 500
Resolved from the capability matrix (the `@ab/catalog` registry) — the same source `/v1/matrix` serves, so the two surfaces cannot drift apart (KAN-204). `id` accepts either the canonical plugin id (`browser.camoufox`) or the bare slug the console`s browser/proxy pages address (`camoufox`), which is resolved server-side across every `/v1/tools/:id*` route (KAN-422); 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, required)
200 · 400 · 404 · 500
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. Free, 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, required)
200 · 400 · 500
Free and ungated, mirroring `/v1/tools/:id`, and resolving `id` the same way (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, required)
200 · 400 · 500
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, required)
200 · 400 · 401 · 402 · 403 · 404 · 500
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 (KAN-188). Attempts are counted ONLY against targets the catalog says can rank this tool axis — the same scope `/v1/matrix` and `/v1/browsers` publish their headline over, so the three cannot disagree about a tool pass rate (KAN-400/KAN-385); the `scope` object states the axis, how many targets qualified, and how many attempts that excluded. A proxy additionally carries `byClass` — residential / isp / datacenter / mobile folded separately, never averaged together. `stealth` carries what the detector pages SCORED this tool (KAN-433) — 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, required), bucket (query), sinceDays (query), vendor (query), limit (query)
200 · 400 · 401 · 402 · 403 · 404 · 500
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 (KAN-286) is a separate axis from the tool version, so it comes off the latest observed row.
parameters: id (path, required)
200 · 400 · 401 · 402 · 403 · 404 · 500
Free transparency, mirroring the free matrix: the latest observed version per tool from the run provenance the fleet stamps, plus the browser `engineVersion` (KAN-286; null = not determined). No measured rates here, so it stays public. A tool with no versioned run yet simply does not appear — an absence, never a guess.
200 · 400 · 500
The gap surface clustered into ranked "what to build or acquire" signals, boosted where a rising market trend corroborates the gap.
parameters: windowDays (query), minTotal (query)
200 · 400 · 401 · 402 · 403 · 500
Rising/falling detectors, layers, and event types with fleet-confirmed counts, computed over a recent window versus the equal prior window. Powers the "State of Bot Detection" read.
parameters: windowDays (query), minTotal (query)
200 · 400 · 401 · 402 · 403 · 500
Requires an account rather than being anonymous (KAN-131/KAN-223): 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
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
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 · 429 · 500
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, required)
204 · 400 · 401 · 404 · 500
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
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);
}