Documentation · DetectBT

DetectBT browser bot detection reference 

The reference for DetectBT, invisible browser bot detection: one script tag evaluates the visitor and hands your page a signed token, and your server checks it with POST /verify. Below: the script tag and its methods, evaluate() error codes, the /verify request and response, the decision table and billing. New to DetectBT? Start with the quickstart.

Jump to

DetectBT v3.3.0 — browser bot detection script. 15 browser modules (detection probes, the hidden browser challenge and the device key), server-side scoring (weights never exposed to the client), signed verdict tokens (5-minute TTL), and TLS consistency checks from Cloudflare's view of the connection. Endpoint: https://detectbt-api.detectbt.workers.dev.

Billing. You’re billed for one evaluation per page load: the DetectBT script checks each tab once when it loads, and the first token your page requests reuses that check if it is under 4.5 minutes old. Each additional token is a new evaluation. Pages that are prerendered but never opened, and embedded iframes, aren’t evaluated automatically, and bot verdicts are never billed. Fetching detect-bt.js is not billed; its automatic check is the one evaluation.

Script tag and methods

SDK

<script src="https://detectbt-api.detectbt.workers.dev/detect-bt.js?key=vip_pub_…">

Drop-in script tag (about 23 KB gzipped, 65 KB minified). It initializes itself with your publishable key (vip_pub_…, from the console) and runs one evaluation per page load, which counts toward your DetectBT allowance; the page’s first getToken() or evaluate() without options reuses it (see getToken), and every other call is a new evaluation. It waits while the page is prerendered, so a prerender that is never opened is not evaluated, and it does not evaluate automatically inside an iframe (see iframe). The key works only on pages on the allowed sites you list for it (at least one is required). When the load-time evaluation finishes, the SDK sets DetectBT.token (and DetectBT.verdict) and fires a detectbt-evaluated event (detail: the result), or a detectbt-error event (detail: { message, code, status }) when there is no token. Any script on the page can write those globals or fire those events: trust only what POST /verify returns.
SDK

<script … data-allow-iframe="true"> or ?iframe=1

Inside an iframe the tag initializes but does not evaluate automatically, so an embed that carries the tag but calls nothing itself adds no evaluation to the page view. Add the attribute to an embed that needs its own load-time evaluation (DetectBT.token and the detectbt-evaluated event); an explicit getToken() or evaluate() inside a frame always works. This changed in v3.3.0: an existing embed that reads DetectBT.token or waits for the event must add the attribute.
SDK

<script … data-debug="true"> or ?debug=1

Console logging, the same as init({ debug: true }). It says why an iframe was skipped and warns when the page reads DetectBT.token after getToken() handed that token out.
SDK

DetectBT.evaluate()

Runs detection and sends the signals for server-side scoring (one evaluation; the first call without options may reuse the load-time one, as getToken() does). Resolves { verdict, risk_factor, token }. If scoring could not be used (key or site refused, rate limit, network, outage, or the timeoutMs budget, default 8,000 ms) it still resolves, with verdict "unknown", token null and error { code, status }: send the request without a token. It rejects, with no error code, only when init() was not called, consent has not been granted, or reset() was called while it ran. The risk score is never sent to the browser; your server reads it from POST /verify.
SDK

DetectBT.getToken()

Returns a signed token (5-minute lifetime, not single-use by itself). The first call without options returns the load-time evaluation’s token when it is unused, under 4.5 minutes old and was billed (not bot or unscored, not failed, and not run without its session) (waiting for it if it is still running); any other call, including one with options such as getToken({ action: "login" }), runs a new evaluation. Reading the token yourself (DetectBT.token, DetectBT.result.token or the event’s detail.token, including by copying or serialising the result) counts as using it. When there is no token it throws an error carrying the same code and status as evaluate()’s error (e.g. "timeout"); then send the request without one. It also throws, with no code, when init() was not called, consent has not been granted, or reset() ran mid-flight. Call it for every request to a sensitive route: it never returns a token your page already read. On sensitive (single-use) routes and routes verified with expected_ip, use getToken({ action }), which always mints a token at submit, and don’t also send DetectBT.token. Your server verifies it with POST /verify.
SDK

DetectBT.getResult()

Returns raw detection signals (no score — scoring is server-side only).
SDK

POST /verify { token, api_key }

Server-side token verification with your DetectBT secret key (sk_live_…; keep it on your server, never in a page). Create one in the console under API Keys → DetectBT publishable key → Server keys, where it is shown once. A publishable key (vip_pub_…) is refused with 401 key_type. Checks the token was issued to your key and returns { valid, verdict, risk_score, … }; the full request and response are below.
SDK

DetectBT.reset()

Tears the SDK down: clears its configuration, cached result and any evaluation in flight (which then rejects). Nothing works again until you call DetectBT.init() yourself. If you load DetectBT with the script tag, do not call it: the tag initializes once per page load and nothing re-initializes it after a reset.

evaluate() error codes

When scoring could not be used, evaluate() resolves with an error (and getToken() throws one). error.code is the DetectBT error code when the server sent one (invalid_api_key, origin_not_allowed, rate_limit_exceeded, auth_unavailable, quota_unavailable, internal_error, not_configured, invalid_payload, payload_too_large, not_found; payload_too_large means the signals were over 64 KB); otherwise it is one of these. Send the request without a token; your server applies the decision table below.

error.codeWhen
invalid_api_key401 without a server code.
origin_not_allowed403 without a server code.
rate_limited429 without a server code.
server_error5xx without a server code.
request_rejectedAny other 4xx without a server code.
network_errorThe request did not complete.
timeoutThe timeoutMs budget (default 8,000 ms) ran out.
unavailableFail-fast after repeated failures.
bad_responseThe answer could not be read.
not_configuredClient side: init() was called without an endpoint (status null). The script tag always sets one.

POST /verify · request and response

POST https://detectbt-api.detectbt.workers.dev/verify with a JSON body of at most 8 KB (the body is read as JSON whatever the Content-Type). Call it from your server, with your secret key.

Request fieldTypeMeaning
tokenstringRequired. The token from DetectBT.getToken() / evaluate().
api_keystringRequired. Your DetectBT secret key (sk_live_…). A publishable key is refused (401 key_type).
expected_originstringOptional. The page origin the token must have been minted on (https://shop.example). Strongly recommended; a token from another site fails with origin_mismatch. A value that is not a string is ignored.
expected_request_idstringOptional, advanced: the browser SDK picks its own request id and you cannot set it. Leave unset unless you call /evaluate yourself. A different id fails with request_mismatch; a value that is not a string is ignored.
expected_ipstringOptional. The visitor’s IP as your server sees it. A token minted on another /24 (IPv4) or /64 (IPv6) fails with ip_mismatch; compared only within one address family. Not a string: 400.

Response 200, valid token:

FieldTypeMeaning
validbooleantrue.
expiredbooleanfalse.
verdictstringhuman, fraud, bot, unknown, verified_agent or unscored.
risk_scoreinteger | null0 (human) to 100 (bot). null for an unscored token, and when the token’s score can no longer be read (after DetectBT rotates its token secret); decide on verdict then.
reasonstringPresent with unscored: quota_exhausted.
request_idstringThe evaluation’s request id.
key_idstringThe publishable key id the token was issued to (always yours).
token_versioninteger5 (HMAC) or 6 (Ed25519; also verifiable offline with the JWKS).
session_idstringThe evaluation session.
originstringThe page origin the token was minted on.
jtistringToken id: the key for your own replay store.
expintegerExpiry, Unix seconds (tokens live 5 minutes).
ip_boundbooleanThe token carries the client’s network prefix.
ip_checkedbooleanexpected_ip was actually compared (same address family).
replayedbooleanAlways false: /verify does not consume tokens.
single_usebooleanAlways false: verification is repeatable within the token’s 5 minutes. Use jti for single use.
agentobject{ host, keyid }, present when a Web Bot Auth signature verified.

Response 200, token did not verify: { "valid": false, "expired": <bool>, "reason": "<reason>", "replayed": false, "single_use": false, "ip_checked": <bool> }. No verdict or score. reason is one of malformed, bad_signature, expired, origin_mismatch, request_mismatch, ip_mismatch.

verdictMeaning
humanAllow.
botBlock.
fraudSuspicious, below the bot line.
unknownCould not judge; often a real visitor whose SDK was blocked.
verified_agentA declared AI agent or crawler with a verified Web Bot Auth signature.
unscoredYour monthly allowance is used up; not a judgement.

Errors:

HTTPerrorWhen
400invalid_payloadBody not JSON or not a JSON object; token / api_key missing or not strings; expected_ip not a string.
401key_typeA publishable key (or anything not sk_…) in api_key.
401invalid_api_keyUnknown or revoked secret key.
403token_not_for_keyThe token was issued to another DetectBT key (body also has valid: false).
404not_foundWrong path or method (it is POST /verify).
413payload_too_largeBody over 8 KB.
429rate_limit_exceededYour key’s /verify rate limit, or repeated invalid keys from your IP.
503auth_unavailableKey lookup temporarily unavailable; retry shortly.
503not_configuredThe service is not configured.
500internal_errorServer error.

Other DetectBT endpoints

EndpointAuthAnswer
GET /healthnoneLiveness only: {"status":"ok","version":"edge-1.0.0","product":"detectbt"} (200); status "not_configured" (503) when the service is missing configuration.
GET /health/scoringnoneWhether the browser path (/session, /evaluate) can serve right now: {"status":"ok", …, "checks":{"auth":"ok","quota":"ok"}} (200), or degraded / not_configured (503). Cached about 10 s. Use it on sensitive routes to tell an outage from a missing token.
GET /.well-known/jwks.jsonnoneThe Ed25519 public keys that version-6 tokens are signed with (application/jwk-set+json, Cache-Control: public, max-age=300). Offline verification with them gives you the verdict, never risk_score (only /verify can read it), and you must check the token’s key id against your own DetectBT key id, or another customer’s token verifies too. The token format and its canonical encoding live in @detectbt/types, which is not published: use POST /verify.
POST /feedbacksecret key (Authorization: Bearer sk_live_…, or api_key in the body)Report what an evaluation turned out to be: { "jti": "<uuid>" | "token": "<token>", "outcome": "fraud" | "legit" | "chargeback" | "ato" | "fake_signup" | "spam", "note": "≤ 200 chars, no personal data" }, answered 202 { accepted: true, jti, outcome }; a repeat is answered 202 with duplicate: true. Expired tokens are accepted. Errors: 400 invalid_payload, 400 invalid_token (not a DetectBT token: send its jti), 401 invalid_api_key, 403 secret_key_required (a publishable key), 403 token_not_for_key, 413 payload_too_large (over 8 KB), 429 rate_limit_exceeded (including the daily cap per key), 503 auth_unavailable (key lookup briefly unavailable; retry shortly), 503 feedback_unavailable where not enabled.

Handling the verdict on your server

There are no npm packages for DetectBT. Call POST /verify from your server over HTTPS with fetch or any HTTP client, as shown above, and apply the table below yourself.

DetectBT does not block by default: it scores every visitor invisibly and your server decides. Apply the defaults below and pass the verdict and the reason on to your handler. On payment, sign-up or password-reset routes, treat the route as sensitive: refuse an expired token, and a missing one unless GET /health/scoring shows DetectBT is down, and accept each token only once. To refuse every unverified request, including during a DetectBT outage, fail closed.

SituationDefault
Verified humanAllow.
Verified bot, or a risk_score at or over riskThreshold (default 70)Block (403 bot_detected), in every mode.
Verified fraud verdict below the thresholdAllow, flagged fraud_verdict (onFraud to change).
Verified unknown verdictAllow, flagged unknown_verdict (onUnknown to change).
Verified AI agent or crawler (verified_agent)Allow; flagged verified_agent unless its host is in verifiedAgents (onVerifiedAgent to change).
Forged, malformed or tampered token; a token for another DetectBT key; a token bound to another site, request or networkBlock (403 verification_failed). Safety valve: once one server process has seen 20 tokens for another key and none for yours (a key mix-up), it allows them, flagged token_not_for_key.
The same token presented againFrom another client (IPv4 address, IPv6 /64): block (403 token_replayed). From the same client: allow, flagged token_reused for a human verdict (a verified agent you listed is flagged verified_agent; other verdicts are decided as above). With sensitive: true, or your own replayStore: every second use is blocked.
Missing or expired tokenAllow, flagged, unless the same client had a verified bot in the last 10 minutes (block, recent_bot). With sensitive: true: refused (403 missing_token / expired_token, or a 428 challenge with a turnstile step-up), except a missing token while DetectBT itself is down (allowed, flagged).
DetectBT unavailable, slow or rate limiting, or a setup error (key or secret wrong)Allow, flagged, also with sensitive: true. A setup error is also logged loudly, once per process.
Plan quota used up (token verdict "unscored")Allow, flagged quota_exhausted. Never charged.

Decision-table options (nothing published implements them)

The server adapters are not published, so nothing you can install implements these options. They name the defaults the table above uses, so your own code can follow the same rules.

OptionDefaultMeaning
apiKey—Your DetectBT secret key (sk_live_…). Never the publishable key.
verifyEndpoint—https://<DetectBT host>/verify. Must be https and a DetectBT host (or listed in trustedApiHosts).
site / expectedOrigin / allowedOriginsunsetYour page origin(s); sent as expected_origin. Strongly recommended.
riskThreshold70Block when the verified risk_score is at or over this. A bot verdict blocks regardless.
failClosed'flag'Unverified requests. 'flag': allow flagged (block only provably bad tokens and recent bots). 'challenge': 428 challenge instead. true: block every unverified request, outages included. false: allow every unverified request except a replay.
sensitivefalsePayment, sign-up or reset routes: every token single-use (replayMode: 'strict'), and, with failClosed unset, a missing or expired token is refused (403 missing_token / expired_token, or a 428 challenge with turnstile), except a missing token while DetectBT itself is down. Outages and setup errors stay allowed, flagged.
onUnknown / onFraud / onVerifiedAgent'allow' (flagged)'allow', 'challenge' or 'block' for those verdicts.
onChallenge'respond'What a challenge decision does. 'respond': the request stops with 428 { "error": "challenge_required" } and your client steps up and retries. 'pass': your handler gets payload.challenge = true and steps up itself.
challengeHeaderx-detectbt-challengeHeader carrying the solved step-up (Turnstile) token on the retry.
verifiedAgents[]Agent hosts to let through unflagged (['openai.com']).
bindIpfalseSend the client IP as expected_ip (needs a trustworthy client IP; see trustProxy). In the page, use getToken({ action }) for these routes.
trustProxy / clientIpHeader / trustedProxyHopsfalse / x-forwarded-for / 1How the client IP is read behind your proxies.
replayStorein memory, one processWhere your server records used token ids until they expire. Memory is enough for a single process; with several processes or instances (a cluster, serverless), record them in a shared store with an atomic claim (for example Redis SET NX EX) so every one sees each use. Without it there is no replay protection.
replayMode'per-client' ('strict' with your own replayStore)'per-client': reuse from the same client is allowed (flagged token_reused for a human); from another client blocked. 'strict': every second use blocked.
turnstileunsetOptional step-up for challenge decisions: { secretKey, siteKey?, expectedHostname?, expectedAction? } (your own Cloudflare Turnstile keys).
recentBotson, 10 minBlock a missing or expired token from a client that just produced a verified bot; false turns it off.
keyMismatchSafetyValve20After your server has seen this many tokens for another DetectBT key and none for yours since it started, allow them flagged token_not_for_key (a key mix-up would otherwise block every visitor). The first token for your key closes it. false: always block.
jwks / expectedKeyIdunsetVerify tokens offline against /.well-known/jwks.json (the key set, or { url, cacheTtlSec }, cached 600 s by default) instead of calling /verify. Needs expectedKeyId, your DetectBT key id; without it, or when offline verification cannot decide, call /verify. Offline answers carry no risk_score.
tokenHeaderx-detectbt-tokenHeader carrying the token. Form posts: the _detectbt_token field.
timeoutMs3000/verify budget.
verboseErrorsfalseInclude verdict and score in block bodies (off: it would help an attacker tune against you).

Cloudflare Turnstile is optional: an opt-in step-up for routes where you want a challenge. DetectBT does not need it.

Ready to see who isbehind your traffic?