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.
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
<script src="https://detectbt-api.detectbt.workers.dev/detect-bt.js?key=vip_pub_…">
<script … data-consent="false"> or ?consent=0
<script … data-allow-iframe="true"> or ?iframe=1
<script … data-debug="true"> or ?debug=1
DetectBT.evaluate()
DetectBT.getToken()
DetectBT.getResult()
POST /verify { token, api_key }
DetectBT.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.code | When |
|---|---|
| invalid_api_key | 401 without a server code. |
| origin_not_allowed | 403 without a server code. |
| rate_limited | 429 without a server code. |
| server_error | 5xx without a server code. |
| request_rejected | Any other 4xx without a server code. |
| network_error | The request did not complete. |
| timeout | The timeoutMs budget (default 8,000 ms) ran out. |
| unavailable | Fail-fast after repeated failures. |
| bad_response | The answer could not be read. |
| not_configured | Client 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 field | Type | Meaning |
|---|---|---|
| token | string | Required. The token from DetectBT.getToken() / evaluate(). |
| api_key | string | Required. Your DetectBT secret key (sk_live_…). A publishable key is refused (401 key_type). |
| expected_origin | string | Optional. 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_id | string | Optional, 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_ip | string | Optional. 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:
| Field | Type | Meaning |
|---|---|---|
| valid | boolean | true. |
| expired | boolean | false. |
| verdict | string | human, fraud, bot, unknown, verified_agent or unscored. |
| risk_score | integer | null | 0 (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. |
| reason | string | Present with unscored: quota_exhausted. |
| request_id | string | The evaluation’s request id. |
| key_id | string | The publishable key id the token was issued to (always yours). |
| token_version | integer | 5 (HMAC) or 6 (Ed25519; also verifiable offline with the JWKS). |
| session_id | string | The evaluation session. |
| origin | string | The page origin the token was minted on. |
| jti | string | Token id: the key for your own replay store. |
| exp | integer | Expiry, Unix seconds (tokens live 5 minutes). |
| ip_bound | boolean | The token carries the client’s network prefix. |
| ip_checked | boolean | expected_ip was actually compared (same address family). |
| replayed | boolean | Always false: /verify does not consume tokens. |
| single_use | boolean | Always false: verification is repeatable within the token’s 5 minutes. Use jti for single use. |
| agent | object | { 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.
| verdict | Meaning |
|---|---|
| human | Allow. |
| bot | Block. |
| fraud | Suspicious, below the bot line. |
| unknown | Could not judge; often a real visitor whose SDK was blocked. |
| verified_agent | A declared AI agent or crawler with a verified Web Bot Auth signature. |
| unscored | Your monthly allowance is used up; not a judgement. |
Errors:
| HTTP | error | When |
|---|---|---|
| 400 | invalid_payload | Body not JSON or not a JSON object; token / api_key missing or not strings; expected_ip not a string. |
| 401 | key_type | A publishable key (or anything not sk_…) in api_key. |
| 401 | invalid_api_key | Unknown or revoked secret key. |
| 403 | token_not_for_key | The token was issued to another DetectBT key (body also has valid: false). |
| 404 | not_found | Wrong path or method (it is POST /verify). |
| 413 | payload_too_large | Body over 8 KB. |
| 429 | rate_limit_exceeded | Your key’s /verify rate limit, or repeated invalid keys from your IP. |
| 503 | auth_unavailable | Key lookup temporarily unavailable; retry shortly. |
| 503 | not_configured | The service is not configured. |
| 500 | internal_error | Server error. |
Other DetectBT endpoints
| Endpoint | Auth | Answer |
|---|---|---|
| GET /health | none | Liveness only: {"status":"ok","version":"edge-1.0.0","product":"detectbt"} (200); status "not_configured" (503) when the service is missing configuration. |
| GET /health/scoring | none | Whether 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.json | none | The 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 /feedback | secret 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.
| Situation | Default |
|---|---|
| Verified human | Allow. |
| Verified bot, or a risk_score at or over riskThreshold (default 70) | Block (403 bot_detected), in every mode. |
| Verified fraud verdict below the threshold | Allow, flagged fraud_verdict (onFraud to change). |
| Verified unknown verdict | Allow, 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 network | Block (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 again | From 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 token | Allow, 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.
| Option | Default | Meaning |
|---|---|---|
| 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 / allowedOrigins | unset | Your page origin(s); sent as expected_origin. Strongly recommended. |
| riskThreshold | 70 | Block 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. |
| sensitive | false | Payment, 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. |
| challengeHeader | x-detectbt-challenge | Header carrying the solved step-up (Turnstile) token on the retry. |
| verifiedAgents | [] | Agent hosts to let through unflagged (['openai.com']). |
| bindIp | false | Send the client IP as expected_ip (needs a trustworthy client IP; see trustProxy). In the page, use getToken({ action }) for these routes. |
| trustProxy / clientIpHeader / trustedProxyHops | false / x-forwarded-for / 1 | How the client IP is read behind your proxies. |
| replayStore | in memory, one process | Where 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. |
| turnstile | unset | Optional step-up for challenge decisions: { secretKey, siteKey?, expectedHostname?, expectedAction? } (your own Cloudflare Turnstile keys). |
| recentBots | on, 10 min | Block a missing or expired token from a client that just produced a verified bot; false turns it off. |
| keyMismatchSafetyValve | 20 | After 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 / expectedKeyId | unset | Verify 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. |
| tokenHeader | x-detectbt-token | Header carrying the token. Form posts: the _detectbt_token field. |
| timeoutMs | 3000 | /verify budget. |
| verboseErrors | false | Include 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.