← All docs

Management API

Everything the dashboard can configure, reachable from a script. Provision keys in a deploy pipeline, mirror rules from version control, reconcile members from your identity provider.

Authenticate with a management key:

Authorization: Bearer mk_live_…

Auth-API-Key and X-API-Key headers are read too. A key in the query string (?api_key=, which the Server API reads for clients written against another) is not: in a URL it is left in access logs and proxy caches. A missing or unknown key is 401 Unauthorized.

Management API → Create management key. Shown once.

Why it is a separate credential

Reading identification data and minting new credentials are different powers. A leaked analytics token should not be able to grant itself more, so a secret key cannot reach any of these endpoints, and a management key reads identification data only at one remove — a secret key it mints, a webhook or log stream it points — and only with identifications:read.

Scopes

Each key carries an explicit scope list rather than inheriting a role, so a key made for a deploy pipeline can rotate API keys and nothing else — something the role ladder cannot express.

Resource Scopes
Organization, its environments and custom domains organization:read, organization:write
API keys keys:read, keys:write
Webhooks webhooks:read, webhooks:write
Rules rules:read, rules:write
Request filters filters:read, filters:write
Members members:read, members:write
Audit log audit:read
Usage usage:read
Requests' addresses, paths and User-Agents: refusals, and assessments sent to a log stream decisions:read
Identification data: minting a secret key that reads it, pointing a webhook or log stream at it identifications:read
Learned model model:read, model:write
Integrity telemetry integrity:read
Threat feed threat:read, threat:write
Session revocation sessions:read, sessions:write
Credential corpus credentials:read, credentials:write
Waiting rooms queues:read, queues:write
Page scripts scripts:read, scripts:write

A denied request names the scope it wanted, which turns a dead end into a one-line fix:

{ "error": { "code": "Forbidden", "message": "This key is missing the \"keys:write\" scope." } }

A key can only hand out what it holds

The scope list is a ceiling that cannot be raised from inside:

  • Minting a management key (POST /api-keys, "type": "management") is refused with 403 Forbidden if its scopes include any the minting key does not hold, and the message names them. A key whose only scope is keys:write can mint keys with keys:write and nothing more. (Public keys carry no scopes; minting one is part of keys:write.)
  • A secret key may be narrowed, never widened. "scopes" on a secret key lists the uses it is limited to: identification (events, visitors, related visitors, the credential corpus's lookup) and assess (/assess and everything under it — outcomes, trap reports, and what an edge reports and polls). Leave it out for a key that does both. "scopes": ["assess"] is the key to hand an edge: leaked, it reads no visitor, and anything else it is used for is 403 KeyNotPermitted.
  • A secret key that reads identification data needs identifications:read to mint — one with no uses listed, or identification among them (403 otherwise). A key with keys:write alone mints public keys, secret keys limited to assess, and management keys within its own scopes: a pipeline that rotates an edge's key holds nothing that reads a visitor.
  • A key stands in for an admin, never an owner, the same rule the dashboard applies to a human admin. It cannot grant ownership (PATCH /members/{id} to owner is 403), cannot change or remove an owner or another admin (403), and cannot invite anyone as an owner (400: the role is not one of viewer, member, admin). Ownership moves only through the owner-only transfer in the dashboard. A key can change viewers and members, and remove ordinary members.
  • Keys belong to the organization, not to whoever minted them. Removing or demoting a member leaves the keys they minted working — an edge's, a pipeline's — so that a person leaving never takes enforcement down with them. When an admin leaves, revoke the keys of theirs you do not recognise: the API keys and Management API pages say who created each, and mark a key whose creator is no longer a member. Their invitations still pending are void: an invitation stands only while whoever sent it could still give it (one a key sent stands, as the key's scopes were weighed when it was sent).
  • Making someone an admin needs a key that holds every scope (403 otherwise, inviting or promoting). An admin mints keys with every scope, so making one hands every scope out: a key with members:write alone — an identity provider's sync, a script that invites the team — would otherwise be the whole organization but its ownership, one invitation away.

Without these, one leaked key with members:write — or with keys:write alone — was a takeover of the organization, which is the opposite of why narrow keys exist.

Scopes that reach the data

No management scope reads identifications, but these decide who can:

  • identifications:read, with keys:write, mints secret keys that read identification data (events, visitors), and encryption keys, which open every sealed identification — what a deployment pipeline provisioning servers is for. Grant it as you would read access to the data; a pipeline that only provisions edges needs keys:write alone, and mints keys with "scopes": ["assess"].
  • sessions:write hands out the keys that sign receipts (GET and POST /sessions/keys return the raw key to it, and to nothing less): whoever holds one signs a receipt with any score and any action, and every edge and server SDK lets it through. Grant it to what deploys your enforcement, and to nothing that only watches sessions — sessions:read lists them without it.
  • webhooks:write decides where data is sent: a webhook or a log stream receives every identification and assessment it subscribes to, at any public https address. So pointing one is a read: creating a webhook, or changing its address, needs identifications:read too (every webhook receives every identification), and a log stream needs the scope that reads each dataset it sends — identifications:read for identifications, decisions:read for assessments (their addresses, paths and User-Agents) — 403 otherwise. With webhooks:write alone a key turns destinations off and on, narrows what a stream sends, and rotates a collector's token. Rolling a webhook's signing secret hands the new one back, and whoever holds it signs deliveries the receiver trusts: it needs identifications:read too, as creating a webhook does.

Endpoints

GET  | PATCH           /api/management/v1/organization
GET  | POST            /api/management/v1/api-keys
GET  | DELETE          /api/management/v1/api-keys/{keyId}
GET  | POST            /api/management/v1/proxy-secrets
GET  | DELETE          /api/management/v1/proxy-secrets/{secretId}
GET  | POST            /api/management/v1/encryption-keys
GET  | DELETE          /api/management/v1/encryption-keys/{encryptionKeyId}
POST                   /api/management/v1/encryption-keys/{encryptionKeyId}/activate
GET  | POST            /api/management/v1/webhooks
GET  | PATCH | DELETE  /api/management/v1/webhooks/{webhookId}
POST                   /api/management/v1/webhooks/{webhookId}/secret
POST                   /api/management/v1/webhooks/{webhookId}/test
GET                    /api/management/v1/webhooks/{webhookId}/deliveries
POST                   /api/management/v1/webhooks/{webhookId}/deliveries/{deliveryId}/replay
GET  | POST            /api/management/v1/log-streams
GET  | PATCH | DELETE  /api/management/v1/log-streams/{streamId}
POST                   /api/management/v1/log-streams/{streamId}/test
GET  | POST            /api/management/v1/rules
GET  | PATCH | DELETE  /api/management/v1/rules/{ruleId}
GET  | POST            /api/management/v1/filters
GET  | PATCH | DELETE  /api/management/v1/filters/{filterId}
GET                    /api/management/v1/page-scripts
GET  | PATCH           /api/management/v1/page-scripts/{scriptId}
GET  | POST            /api/management/v1/environments
GET  | PATCH | DELETE  /api/management/v1/environments/{environmentId}
GET  | POST            /api/management/v1/domains
GET  | POST | DELETE   /api/management/v1/domains/{domainId}
GET                    /api/management/v1/members
PATCH | DELETE         /api/management/v1/members/{membershipId}
GET  | POST            /api/management/v1/invitations
DELETE                 /api/management/v1/invitations/{invitationId}
GET  | POST            /api/management/v1/scim-tokens
GET  | DELETE          /api/management/v1/scim-tokens/{tokenId}
GET                    /api/management/v1/audit-logs
GET                    /api/management/v1/usage
GET                    /api/management/v1/traffic?environment=…&hours=48
GET                    /api/management/v1/traffic/decisions?environment=…&limit=50
GET                    /api/management/v1/traffic/decisions/{reference}
GET                    /api/management/v1/traffic/challenges?environment=…&hours=24
GET  | POST            /api/management/v1/model
GET                    /api/management/v1/integrity-stats
GET  | POST | DELETE   /api/management/v1/threat-feed
GET  | POST | DELETE   /api/management/v1/sessions
GET  | POST | DELETE   /api/management/v1/sessions/keys
GET                    /api/management/v1/sessions/live
GET                    /api/management/v1/sessions/live/{sessionId}
GET  | POST | DELETE   /api/management/v1/credentials/corpus
GET                    /api/management/v1/credentials/corpus/{digest}
GET  | POST            /api/management/v1/queues
GET  | PATCH | DELETE  /api/management/v1/queues/{queueId}

Listings accept ?environment= to scope them. Unscoped means the whole organization. The model, integrity stats, traffic and its decisions, and the listings of the threat feed, the credential corpus and waiting rooms require ?environment= (400 BadRequest without it): a model — and a blocklist — belongs to exactly one environment. Listings of keys, proxy secrets, encryption keys, webhooks, log streams, rules, filters, domains, members, invitations, the audit log, the threat feed and waiting rooms page with limit (default 50, at most 200) and offset; there is no cursor, so a page shorter than limit is the last.

A create (POST to a collection) lands in the environment named by ?environment= or by "environment" in the body — id or slug, either place — and in the organization's default when neither names one. Naming two different environments is 400 BadRequest, never a guess.

What an environment and an organization hold

What is checked or sent on every request, by processes every tenant shares, is held to a number — far past what a team needs, short of what would slow anyone else's requests. Each is counted and made under one lock, by the dashboard and this API alike, so requests side by side cannot pass it.

Kind Holds at most One more
Rules 500 an environment 409 RuleLimitReached
Request filters 1,000 an environment 409 Conflict
Webhooks 20 an environment 409 Conflict
Log streams 10 an environment 409 Conflict
Threat-feed entries 50,000 live an environment 409 ThreatFeedFull, the batch refused whole
Proxy secrets 20 active an organization 409 Conflict
Encryption keys 10 active an organization 409 Conflict
SCIM tokens 10 active an organization 409 Conflict
Allowed origins 100 an organization 400 BadRequest

Names are 2–80 characters for an organization, 2–60 for an environment, 1–80 for a key and 2–120 for a rule (400 BadRequest otherwise), the same in the dashboard.

Expensive calls have an allowance

Most calls read a page, a row or a setting. Three read a window of your traffic or fit a model, and each has an allowance per hour, past which it is 429 TooManyRequests with Retry-After:

Call Allowance
GET /usage 3,650 days of window an hour per organization, each call its days — ten calls for a year, a 30-day window every minute
POST /model 12 fits an hour per environment (new labels retrain it on their own)
GET /model/readings 60 an hour per environment

A refused call counts too: a loop on a closed allowance keeps it closed.

POST /domains/{domainId} runs the domain's DNS check now and answers with the domain, status active or failed (with lastError) — or expired once its 14-day window has closed. Call it once the records are published. Only a verified domain holds its name: claiming one that another organization has not verified is allowed, and whichever proves it first is active (the other's check then fails, saying so). An active domain's TXT proof is checked again once a day; missing for a week, the domain lapses to failed, with fourteen days to be checked again.

GET /traffic (usage:read, ?environment= required, hours 1–720, default 48) is what the dashboard's Edge traffic page shows: assessed requests by verdict in total and per hour, the reasons given, and the environment's last twenty traffic alerts — each with its kind, startedAt, endedAt (null while it lasts), peak volume, peak refused share and reasons — for an alarm or a SIEM that polls rather than receives webhooks. For every assessment and identification as it happens, in Splunk, Datadog or your own collector, use a log stream (webhooks:write). It also returns crawlers: requests whose User-Agent named a known crawler, by purpose (search, aiTraining, …) with how many were allowed, challenged and blocked — the edge's own refusals included when it has a secret key to report them with. And protection: the setup checklist from the dashboard, each line { id, state, title, detail } with state one of on, warn, off, optional, decided from the last seven days of what arrived (assessments, accounts and outcomes, trap hits, crawler refusals, cards, phones, which keys assessed, how many identifications arrived sealed) — for an alarm on assessed going off, on a key that could read every visitor being the one that assesses, or on sealed going warn: most identifications arriving unsealed, because the pages' Content-Security-Policy does not let WebAssembly compile (add 'wasm-unsafe-eval' to script-src), or on attestation going warn: phones' Play Integrity verdicts or App Attest assertions failing alike, most of them and almost none verified, for a reason only the environment's setup gives — response encryption keys that are not the app's (rotated in Play Console), a service account Google refuses, a package or signing certificate not registered, development builds attesting against production. Its detail names which. Forgeries beside phones that verify are an attack, not the setup, and leave it on, with the share verified. Two lines say what the deployment makes possible, optional until it does: distance — browsers timed against their connection, which takes a proxy that knows the connection's round trip and a proxy secret — and arrivals — the edge saying how each page was reached, which takes the edge placing the agent.

GET /traffic/decisions (decisions:read — a scope of its own, since unlike the figures usage:read reads it returns addresses, paths and User-Agents; ?environment= required, limit 1–200, default 50) lists the most recent assessed requests that were challenged or blocked, newest first; GET /traffic/decisions/{reference} finds one by the reference its visitor was shown — asr_…, on the edge's block and challenge pages and in its JSON errors — in any of the organization's environments, and names the environment. Each carries action, score, reasons, request (method, host, path — never the query string — and User-Agent), ip (null while addresses are pseudonymized), visitorId (the device a valid receipt named, null without one: a rule on session.visitorId lets that device alone through), countryCode, asn, monitored (decided by a guard in monitor mode, which let it through) and, for a challenge, resolution — passed or convicted once the browser came back through the interstitial and identified, empty while unanswered — with resolvedAt. Allowed requests are never kept; an environment keeps at most 120 refusals a minute (an attack's are counted, not kept), and they go after seven days. The dashboard's Edge traffic → Refusals finds the same, and lets the device (or the address) through with an assessment rule placed first.

GET /traffic/challenges (decisions:read, ?environment= required, hours 1–168, default 24) says how the challenges ended, per reason: how many each reason was part of, how many the browser passed (it came back through the interstitial with a clean identification), how many convicted (it came back a bot, or scored 70 or more — what the edge refuses a receipt at by default, whatever an environment's blockAt) and how many went unanswered (abandoned, or never a page that could run it), with passRate: of the answeredAddresses the answers came from, the share whose last answer passed. An address counts once, so a client answering its own challenges over and over to make a reason look costly moves it by one; the counts beside it are of challenges. A reason whose challenges people nearly all pass is costing people — weigh it less, or lower it with an assessment rule; one whose challenges are convicted or abandoned is doing its job. The dashboard shows the same, over 48 hours, under Edge traffic → Challenges, by how they ended, and marks a reason Challenging people once ten addresses or more have answered and most passed (attack mode, which challenges everyone on purpose, never). Monitored decisions are not read. The interstitial names the challenge it answers in its identification (a tag key of its own, taken out before the tag is stored), bound to the browser by its User-Agent. POST /rules and PATCH /rules/{ruleId} take scope: identification (the default) or assessment, for rules on requests without the agent.

PATCH /environments/{environmentId} also registers the environment's Apple app ids ({"appleAppIds": ["TEAMID.bundleId", …]}, up to 20), which is the prerequisite for iOS App Attest: the attestation's rpId hash is SHA-256 of that exact string, so until the environment knows the app id every registration is rejected. See Mobile SDKs.

The same call registers the Android side of attestation: androidApps (up to 20 { "packageName", "certificateDigests": [...] }) and playIntegrity — a Google service account's JSON key (serviceAccount), the app's Play Console response keys (local), or both; null clears it. The keys are checked to be usable when they are set, are write-only, and are never echoed: the environment reports playIntegrity: { "local": true|false, "serviceAccount": "<its email>" | null }. See Mobile SDKs.

attackMode switches the environment's attack mode: { "attackMode": { "minutes": 60 } } on for that long (a day at most), { "attackMode": null } off now; attackModeAuto (true/false) has an attack alert switch it on for an hour. The environment reports attackMode: { "until": "<ISO time>" } while it is on, null otherwise, and attackModeAuto. Changes are audited as environment.attack_mode.

A webhook takes sessions (true/false, off by default) beside alerts: also deliver session escalations.

POST /webhooks/{webhookId}/secret rolls its signing secret and answers with the webhook and its new secret, once. Audited as webhook.secret_rolled, without the secret. POST /webhooks/{webhookId}/test sends it a test event now and answers with what the endpoint said: { "delivered": false, "responseCode": 400, "error": "HTTP 400", "durationMs": 41 } — a paused endpoint too, so one can be checked before it goes live.

GET /webhooks/{webhookId}/deliveries lists its deliveries, newest first (limit, offset): each attempt's status, attempt, responseCode, error, durationMs, whether it was sent by hand, and nextRetryAt while a retry is due — never the payload, which names a visitor, nor the endpoint's answer. POST …/deliveries/{deliveryId}/replay (webhooks:write) sends one again now, as the bytes it was signed over the first time, and answers as the test does.

mutedReadings holds readings back (Holding a reading back): the whole list, replacing the last — ["jumpedClicks", "webrtc_leak"], or [] for none. Only the readings that may be held back are accepted; anything else is 400. The next identification reads it at once. The environment reports mutedReadings; changes are audited as environment.readings_muted.

Which to hold back, the press-and-hold says: GET /api/management/v1/model/readings?environment=…&hours=168 (model:read) matches each challenge of the window — a week at most, what challenges are kept — to the identification it was issued on, and counts it under every holdable reading that identification showed, by how it ended: a hand passed it (passed), the hold found no hand (notAHand), or it was never held (unanswered). peopleShare is the share a hand passed of the answered ones, othersPeopleShare the same for the challenges it played no part in, and advice reads the two: holdBack when the people it brings to the hold pass it as often as anyone and mostly pass (at least 80%), keep when what it brings fails twenty points more often than the rest, mixed between, tooFew under twenty answered. Advice only, by design: held back on its own, a farm paying people to pass holds could quiet the very readings that name it.

The learned model

GET returns the environment's fitted logistic model — coefficients per feature, intercept, training corpus size and accuracy — because a model you cannot inspect is a model you cannot defend to a customer whose login it just blocked. POST forces a retrain from the current label set; labelling events with suspect — through the Server API or the dashboard — also retrains in the background once enough labels have been set since the last fit (a quarter of what it was fitted on, and at least 25). They are counted by when each label was set, not when its event happened: labelling last month's orders after their chargebacks counts. A fit reads the newest 10,000 labels of web identifications, by when they were set.

Until at least 50 labeled events exist (10 bot, 10 human), GET — and a POST that cannot train — answers { "trained": false, "reason": "…" } and identifications score on heuristics alone.

accuracy is measured on labels the fit never saw: five fits, each without a fifth of the labels, judging that fifth — balanced across the two classes, so 0.5 is chance and answering "human" to everything scores 0.5, not your share of people. A fit judged on its own labels is right about fifty of them with thirty readings to spare, and says nothing. A model under 0.65 is reported with "applies": false and a reason, and does not score identifications: its points would fall on people as readily as on bots. On a small corpus the bar is higher, at what chance alone reaches about one fit in a hundred: with ten bots among fifty labels, readings that tell nothing reach 65% about one fit in eleven, so such a model needs 71%; from about thirty of each class, 65% is the bar. bots is how many of the labels were bots, and reason says the bar. More labels, or labels that agree with each other, raise it.

Integrity path mix

GET /api/management/v1/integrity-stats?environment=…&hours=24 reports how recent events arrived: signed by the current per-deploy WASM build (wasm), the legacy unsalted scheme (legacy), a rotated-out build (stale), with a failed MAC (invalid), or unsigned. It requires ?environment= and the integrity:read scope.

The fallback schemes are attacker-selectable by construction (a solver can always target the stable legacy scheme), so the mix is how you notice one: a healthy browser population is overwhelmingly wasm, and a sustained legacy share above ~25% — or unsigned above ~50% — is what a farm looks like. The response's assessment field says this in plain language; currentBuild names the build id freshly served agents are stamped with.

programs counts what each event's VM program said (ok, emulated, forged, pow, missing, absent, error; none where the key came with none, or there was no key). A browser answers its program, so noBrowserShare — of the events whose key came with one, those answered as no browser answers: emulated, forged, missing — sits near zero in a healthy population, and programAssessment says so; above one in fifty, someone is minting identifications in jsdom or a Node sandbox, or building payloads without running the program. Their reasons are under bot.

Threat feed

The threat feed is integrator-sourced IP reputation: you push the IPs, CIDR ranges and AS numbers your own threat-intel subscriptions or blocklists flag, and every identification in that environment is matched against them, surfacing matches through the ip_blocklist signal. We do the matching; the data — and the decision of what belongs on it — stays yours.

Entries are per environment, so a blocklist being tried out in staging cannot decide a production request. GET requires ?environment=, and so does POST — in the query or as "environment" in the body; unlike other creates it never falls back to the default environment (400 BadRequest), since an entry meant for staging would otherwise block production traffic. DELETE goes by entry id. Each entry may carry a label (returned with the hit), a source naming the feed it came from, and a ttlHours after which it stops matching on its own — reputation decays, and an entry that never expires eventually blocks someone it shouldn't. Entries added or deleted apply within five seconds: each server keeps an environment's feed that long between reads, as it keeps its rules and filters.

curl -X POST "https://api.antibot.dev/api/management/v1/threat-feed?environment=production" \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"entries":[
    {"kind":"cidr","value":"198.51.100.0/24","label":"scanner range","source":"feed-x","ttlHours":720},
    {"kind":"ip","value":"203.0.113.7"},
    {"kind":"asn","value":"AS64512"}
  ]}'

POST accepts up to 1000 entries per call and validates every value (an ASN may be written AS64512 or bare 64512; both normalise to the same entry; an IPv6 address matches however it is spelt, in either case and its zeros compressed or not; an IPv4 address is written as IPv4 — not IPv6-mapped, and no octet with a leading zero — and a range's prefix in digits); it answers 201 with { "added": n, "ids": [...] }. An environment holds at most 50,000 live entries: a batch that would pass it is refused whole (409 ThreatFeedFull) — delete what no longer applies, or add entries with a ttlHours so they lapse. A request is matched by lookup, however large the feed: its address itself first, then the narrowest network that contains it, then its AS number. DELETE takes { "ids": [...] }, up to 1000 a call as POST adds them, and returns how many were removed. GET lists the environment's live entries, expired ones excluded.

Credentials

The breached-credential corpus behind the ato_credential signal — the same posture as the threat feed: we do the matching; the data, and the decision of what belongs in it, stays yours.

Ingestion is digest-only. Hash each pair on your side and upload the digests; raw credentials never touch this API:

// Per pair, exactly what the browser agent computes:
const digest = sha256hex(`${username.trim().toLowerCase()}:${password}`)
curl -X POST "https://api.antibot.dev/api/management/v1/credentials/corpus?environment=production" \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"entries":[{"digest":"<64 hex>","label":"2024-collection-1"}]}'

POST accepts up to 10,000 entries per call, within the 1 MB body limit (10,000 digests fit only with short labels or none), and is idempotent — re-ingesting a digest refreshes its label. A digest named twice in one call is kept once, with the last label given; upserted counts the distinct digests. GET, the digest lookup and DELETE require ?environment=; POST lands where any create does. GET returns counts by source (feed / observed) and total presentations, never the entries: a breach list is itself sensitive (digests of weak passwords are brute-forceable), so the API offers no way to walk it. Membership is checked by exact digest (GET …/corpus/{digest} — 200 with metadata, 404 otherwise), and DELETE takes { "digests": [...] }.

The observed source is the corpus teaching itself: when a session convicted as automation presents a credential digest, it is recorded as actively stuffed, and the next session presenting it — whatever it claims to be — reads as a stuffing attempt. A clean session's unmatched digest is never stored. One lookup bucket (the first ten hex characters) learns at most 16 digests: a breached pair's bucket is known to whoever holds the pair, and made-up digests presented into it would otherwise crowd your own entry out of a lookup's answer. What you upload is never limited this way.

Queues

The waiting rooms behind Virtual waiting rooms. Rooms are per environment (listing them requires ?environment=; a create lands where any create does; a room's own routes go by its id); creation is inert — a room starts idle and only the explicit open action starts the clock:

curl -X POST "https://api.antibot.dev/api/management/v1/queues?environment=production" \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"slug":"spring-drop","name":"Spring drop","ratePerMinute":600,"redirectUrl":"https://shop.example.com/drop"}'

curl -X PATCH "https://api.antibot.dev/api/management/v1/queues/qr_…" \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"open","opensAt":1798761600000}'

PATCH accepts config fields (ratePerMinute, holdScore, admissionTtlSec, bindIp, redirectUrl, name — not slug), an action, or both: open (schedules the clock, reseeds the pre-queue shuffle; on a paused room it resumes instead, keeping both and ignoring opensAt), pause (freezes admissions; an open room only, 400 otherwise), or close (expires every live spot, resets the counters and returns the room to idle). A room's status is idle, open or paused. GET on a room returns live stats plus, while it is open, the line as the drain sees it — ticket, position, visitor, score — capped at 200 rows. DELETE answers { "deleted": true }.

Page scripts

The inventory behind Page scripts: every script the watched pages run, newest sighting first, with what was decided, by whom, and why. Which pages are watched is the environment's scriptPages (PATCH /environments/{environmentId} with the whole list, organization:write).

# The whole inventory as a spreadsheet, for an assessor (PCI DSS 6.4.3)
curl "https://api.antibot.dev/api/management/v1/page-scripts?environment=production&format=csv" \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" -o page-scripts.csv

# Authorize one, with why it is needed
curl -X PATCH "https://api.antibot.dev/api/management/v1/page-scripts/scr_…" \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"authorized","justification":"The payment provider'"'"'s card form"}'

?status=new|authorized|rejected narrows the listing. A script's content says when the daily check last fetched an authorized script's file and when it last changed — which puts it back to new. Authorizing needs a justification (given, or already on the script); rejected and new do not. In the CSV every cell a spreadsheet would run as a formula (=, +, -, @) is text: a script's address is whatever a page loaded.

Session revocation

The mid-session kill switch. Session tokens are verified offline by your backend, so a minted verdict cannot be recalled — but the session behind it can be ended. While a revocation is live:

  • the revoked visitor (or the bearer of the revoked token id) gets decision.action: "block" on their next identification and no new session token, and

  • solving a challenge earns them nothing (passed: false, reason: "sessionRevoked").

  • the edge refuses its receipts within about fifteen seconds, and the assess call reads them as no receipt (session:revoked).

If your backend verifies tokens offline, refuse what is revoked in your verifier — that is how a token stops passing between identifications there — and check the receipt's env claim against your environment's id: an organization's environments share its session keys, so a receipt minted for staging verifies in production. The server middlewares do both from the edge's list (GET /api/v4/assess/revocations, with a secret key), which also says the key's environment; poll that one rather than GET /sessions, which answers at most 10,000 at a time (see below).

Some revocations are written by the system itself, from what edges report their receipts were used for. A receipt used from five networks within its life is revoked for an hour, with the reason receipt:shared. A device making 1,200 requests to protected paths in a minute is revoked for fifteen minutes, with the reason receipt:rate. Where the edge places the agent on every page, a receipt let through to eight more of those pages than were heard for it — each page a browser runs identifies, or pulses for the receipt's session as a tab of its own — with no identification from its device since, is revoked for an hour, with the reason receipt:pagesNotRun: a browser runs the pages it is given; a cookie carried into an HTTP library does not. (If your pages load the agent themselves with sessionPulse: false and a cache, its pages say nothing between identifications: keep its pulse on, or leave the edge's placing off.) They are listed here like anyone's, and DELETE lifts them.

curl -X POST "https://api.antibot.dev/api/management/v1/sessions?environment=production" \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"visitor","value":"ivk_…","reason":"account takeover","ttlHours":24}'

kind is visitor (a visitor id, covering every token minted for it from then on) or token (one token's jti). ttlHours (a whole number, up to 8760) lifts the revocation on schedule; omit it to keep the revocation until DELETE, which takes { "ids": [...] }. Entries are per environment, like the threat feed — and unlike other creates, POST takes the environment only from ?environment=, which it requires. GET lists the live revocations, of one environment with ?environment= or of the whole organization, newest first: up to 10,000 in an answer, with "truncated": true when there were more — page on with limit (1–10,000) and offset. Requires the sessions:read / sessions:write scopes.

Step-ups

GET /api/management/v1/sessions/step-ups lists the visitors whose session the session pulse hardened to a challenge and who have not passed a press-and-hold since — the ones an edge with ANTIBOT_ENFORCE_STEP_UP holds to one (first-party) — each with what hardened it (reason, the pulse's first note), since, and expiresAt, thirty minutes on. Of one environment with ?environment=, or of the whole organization. DELETE with { "ids": [...] } lifts them, for a visitor a reading hardened wrongly: they are revoked nowhere, so lifting one only spares them the hold. Audited as session.step_up_lifted. Same scopes as revocations.

Session keys

GET /sessions/keys lists the organization's active HMAC session keys — including the raw key material to a key holding sessions:write, on purpose: verifying a token means holding the key, and this endpoint is how your backend or the edge enforcer gets one. To a key holding sessions:read alone it names them without it: whoever holds a session key can sign a receipt every edge and SDK lets through, which is more than a read scope should hand out. POST with {"name": "…"} mints an additional key, DELETE with {"id": "…"} revokes. Rotation is mint-then-revoke: every active key verifies, so deploy the new one everywhere before the old one dies. Revoking the last active key is allowed (a fresh one auto-provisions on the next identification) and the response says so. Same sessions:read / sessions:write scopes.

Live sessions

GET /sessions/live lists the sessions the pulse pipeline is actively scoring — one row per live session token, with the minted score (baseScore), the current running score and action, any sticky flags the engine raised, and pulse/activity timestamps. ?active=1 keeps only sessions heard from in the last five minutes; ?environment= and ?limit= (≤200) narrow the listing.

GET /sessions/live/{sessionId} returns the session plus its pulse trail: every beacon that moved the score or raised a note, the score delta it applied, and the notes explaining it — the explainability contract applied to continuous scoring, so a condemned session reads as the exact sequence of evidence that condemned it. A beacon that changed nothing — a hidden tab's, a quiet reader's, a person's whose score is already at its floor — is counted in the session's pulseCount but not kept.

Sessions die with their tokens (ten minutes) and the state is swept a day later, so this is a view of the present, not an archive. Requires sessions:read.

Proxy secrets and encryption keys

What a pipeline that deploys an edge or a backend mints for it, as the dashboard does, under the keys scopes; both belong to the whole organization.

A proxy secret is what a first-party proxy sends as antibot-proxy-secret, so that the visitor's address and TLS handshake it forwards are believed. POST /proxy-secrets ({"name":"Cloudflare"}, or {}) returns the plaintext once; only its SHA-256 is kept, and listings show its prefix and when a proxy last presented it. Several can be active, so a new one is rolled out to every edge before the old is revoked; DELETE revokes, and from the next request a proxy presenting it is a stranger. At most 20 active (409).

SECRET=$(curl -s -X POST https://api.antibot.dev/api/management/v1/proxy-secrets \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"edge-'"$GIT_SHA"'"}' | jq -r .secret)
echo "$SECRET" | wrangler secret put ANTIBOT_PROXY_SECRET

An encryption key is what identifications are sealed with. POST /encryption-keys returns the key once. The organization's first seals at once; one made while another seals waits (activatedAt: null, sealing: false) until POST /encryption-keys/{id}/activate, once the backend that opens sealed results holds it — it tries every key it holds — and every server then seals with it within five seconds ("activate": true at creation seals at once). Activating one that sealed before puts it back. Since it opens every sealed identification, minting one needs identifications:read too (403). DELETE revokes it, and the newest activated key left seals. At most 10 active (409).

SCIM tokens

The bearer token your identity provider provisions members with at /api/scim/v2 — see User provisioning (SCIM). members:write mints one ({"name":"Okta"}); the plaintext and the scimBaseUrl come back once, only the SHA-256 is kept. members:read lists them as prefix…last4 (?includeRevoked=true for revoked ones); DELETE revokes, and the provider's next request is 401. At most 10 active per organization (409). A token can do no more than a members:write key: it provisions viewers and members on the organization's verified single sign-on domains and deactivates or removes them, and never changes an owner or an admin. Its changes are audited as scim:<name>; minting and revoking as key:<name>.

Discovery

curl https://api.antibot.dev/api/management/v1 \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY"
{
  "organization": { "id": "org_…", "name": "Acme", "slug": "acme" },
  "key": { "id": "key_…", "name": "deploy-pipeline", "scopes": ["keys:read", "keys:write"] },
  "scopes": {
    "granted": ["keys:read", "keys:write"],
    "available": ["organization:read", "organization:write", "keys:read", "keys:write", "…"]
  },
  "environmentParameter": "environment",
  "resources": [
    { "path": "/api/management/v1/api-keys", "methods": ["GET", "POST"], "scope": "keys" },
    "…"
  ]
}

scopes.available is every scope there is. resources names endpoints with their methods and the scope family they need (keys for keys:read / keys:write); it is a guide, not the complete list — the endpoint list above is.

This is the one endpoint needing no scope. A key must be able to ask what it holds without already holding something, or the answer is unreachable exactly when it is most needed.

Example: rotate a key from a pipeline

NEW=$(curl -s -X POST https://api.antibot.dev/api/management/v1/api-keys \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"deploy-'"$GIT_SHA"'","type":"secret"}' | jq -r .key)

deploy_with "$NEW"

curl -s -X DELETE \
  "https://api.antibot.dev/api/management/v1/api-keys/$OLD_KEY_ID" \
  -H "Authorization: Bearer $ANTIBOT_MANAGEMENT_KEY"

The plaintext is returned once, in the create response. Capture it there or mint another.

Guards with no in-product recovery

  • A management key can never revoke itself.
  • A management key never changes or removes an owner, so the last owner cannot be lost through the API. (Demoting or removing the last owner is also refused from the dashboard, as a second layer.)

They exist because the alternative locks you out of your own organization with no way back.

Audit

Every write lands in the audit log attributed to the key by name, which is why naming them after the thing that uses them pays off later.

Tenancy

A key issued to one organization can neither read nor delete another's records. It gets the same 404 either way — the only answer that does not confirm the record exists.