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 with403 Forbiddenif itsscopesinclude any the minting key does not hold, and the message names them. A key whose only scope iskeys:writecan mint keys withkeys:writeand nothing more. (Public keys carry no scopes; minting one is part ofkeys: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) andassess(/assessand 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 is403 KeyNotPermitted. - A secret key that reads identification data needs
identifications:readto mint — one with no uses listed, oridentificationamong them (403otherwise). A key withkeys:writealone mints public keys, secret keys limited toassess, 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}toowneris403), cannot change or remove an owner or another admin (403), and cannot invite anyone as an owner (400: the role is not one ofviewer,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 (
403otherwise, inviting or promoting). An admin mints keys with every scope, so making one hands every scope out: a key withmembers:writealone — 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, withkeys: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 needskeys:writealone, and mints keys with"scopes": ["assess"].sessions:writehands out the keys that sign receipts (GETandPOST /sessions/keysreturn 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:readlists them without it.webhooks:writedecides where data is sent: a webhook or a log stream receives every identification and assessment it subscribes to, at any publichttpsaddress. So pointing one is a read: creating a webhook, or changing its address, needsidentifications:readtoo (every webhook receives every identification), and a log stream needs the scope that reads each dataset it sends —identifications:readfor identifications,decisions:readfor assessments (their addresses, paths and User-Agents) —403otherwise. Withwebhooks:writealone 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 needsidentifications:readtoo, 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, andsolving 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.