api reference

DutyRadar API.

One POST /v1/classify call. A three-state verdict you can act on (verified_bot, unverified, or unknown), plus the operator and the method that produced it.

DutyRadar is the positive-ID layer for declared AI bots. It does not detect stealth bots running on residential proxies; for that traffic the honest answer is unverified. Compose DutyRadar with your existing bot-management vendor for stealth scoring.

base URL
https://api.dutyradar.com
version
v1
format
application/json

Quickstart

Three call sites, same request. The verdict comes back in under 50 ms steady-state from a Cloudflare edge.

shell
# verify a GPTBot request from your shell curl -X POST https://api.dutyradar.com/v1/classify \ -H 'authorization: Bearer dr_live_<32 hex>' \ -H 'content-type: application/json' \ -d '{ "user_agent": "Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)", "ip": "20.171.207.1" }' # => { "data": { "verdict": "verified_bot", "operator": "openai", ... }, # "error": null }

Production traffic needs a bearer key. Sign up for a free key in seconds at dutyradar.com/signup; you'll mint the plaintext from the dashboard immediately after verifying your email. The verifier on dutyradar.com stays accessible without a signup so anyone can paste a UA + IP and see a real verdict.

Authentication

DutyRadar uses bearer tokens. Sign up for a free key on the landing page; keys look like dr_live_<32 hex> and are shown once at creation. DutyRadar stores only a sha-256 hash.

http
Authorization: Bearer dr_live_<your-32-hex-key-from-dashboard> # example, not a real key

Put the key in an env var; never commit it. Rotate by minting a new key and revoking the old one. The free tier covers 500 verifications per month, enough to evaluate the API end-to-end before upgrading to a paid tier.

Rate limits

fieldtypedescription
freeevaluation-grade500 verifications/month with a free signed-up key. For evaluation, not production.
starterproduction-grade250,000 verifications/month at $39/mo. For indie sites, small SaaS.
proproduction-grade500,000 verifications/month at $79/mo. Higher per-key rate limit, email support.
scalenegotiatedAbove 500k/month, custom SLAs. Email hi@dutyradar.com.

On 429 the response body is the same error envelope as everywhere else. See Errors. Retry with backoff; the limit is a rolling window, not a hard cliff.

POST /v1/classify

POST/v1/classify

Submit a user_agent (and optionally an IP and a subset of request headers). DutyRadar runs the appropriate verification path for the claimed vendor and returns the verdict envelope plus the underlying 7-category taxonomy.

Request body

fieldtypedescription
user_agent reqstringRaw User-Agent header value from the request you want to verify. 1–2048 chars.
ipstringClient IP, IPv4 or IPv6. Optional but strongly recommended; without an IP the verdict can only be ua_only at best.
headersobject<string, string>Subset of request headers, lower-cased keys. Pass signature-input, signature, signature-agent to enable Web Bot Auth verification. Max 32 entries, 8 KiB per value.
authoritystringRequest authority (host). Required if a Web Bot Auth signature covers @authority.
methodstringHTTP method. Required if a Web Bot Auth signature covers @method.
pathstringRequest path. Required if a Web Bot Auth signature covers @path.

Response body

All responses follow the envelope shape { data: T, error: null } on success and { data: null, error: { code, message, request_id } } on failure. Never both.

fieldtypedescription
data.verdict reqverified_bot | unverified | unknownTop-line answer. See Verdict envelope below.
data.operator reqstring | nullVendor slug for the identified operator (lower-cased, snake-case). null when no operator was identified. See /openapi.json for the current enum.
data.method reqweb_bot_auth | ip_list | reverse_dns | ua_only | nullWhich verification path produced the verdict. null when no bot pattern matched the UA.
data.category reqstring7-category taxonomy: human, training_crawler, retrieval_crawler, user_triggered_fetcher, traditional_bot, undeclared_agent, unknown.
data.bot reqBot | nullMatched bot record (name, vendor, category, match string), or null.
data.verification reqVerification{ ua_match, ip_match, signature_agent_verified }: three booleans for the underlying signals.
data.recommended_action reqallow | allow_with_caution | allow_or_block_per_policy | blockSuggested policy for routing the request. Advisory; your WAF rule can branch on verdict directly.

Examples

Three real shapes you'll see in production:

verified_botreal GPTBot from OpenAI's IP range
json response
{ "data": { "verdict": "verified_bot", "operator": "openai", "method": "ip_list", "category": "training_crawler", "bot": { "match": "gptbot", "name": "GPTBot", "vendor": "OpenAI", "category": "training_crawler" }, "verification": { "ua_match": true, "ip_match": true, "signature_agent_verified": false }, "recommended_action": "allow_or_block_per_policy" }, "error": null }
unverifiedUA claims GPTBot, IP doesn't match
json response
{ "data": { "verdict": "unverified", "operator": "openai", "method": "ua_only", "category": "undeclared_agent", "bot": { "match": "gptbot", "name": "GPTBot", "vendor": "OpenAI", "category": "training_crawler" }, "verification": { "ua_match": true, "ip_match": false, "signature_agent_verified": false }, "recommended_action": "block" }, "error": null }
unknownregular browser
json response
{ "data": { "verdict": "unknown", "operator": null, "method": null, "category": "human", "bot": null, "verification": { "ua_match": false, "ip_match": false, "signature_agent_verified": false }, "recommended_action": "allow" }, "error": null }

Verdict envelope

Most callers only care about three fields: verdict, operator, and method. Everything else is supporting evidence.

verified_bot

Identity confirmed via signature, IP allow-list, or rDNS + forward-confirm. Safe to allow training crawlers, bypass anti-bot rate limits, attribute traffic.

unverified

UA claims a bot but no method confirmed identity, or the signal said the UA is impersonating. Spoofers, vendor-claim mismatches, and undeclared agents all land here. Treat as suspicious.

unknown

Looks human, or unrecognised UA we have no opinion on. Default allow. DutyRadar is not a stealth-bot detector, so this bucket includes both real users and stealth scrapers we can't see.

Verification methods

Each operator publishes a different way to prove their bot is real. DutyRadar runs the right check for the claimed vendor; you read the verdict.

fieldtypedescription
web_bot_authRFC 9421 Ed25519Signature verified against the operator's published JWK directory. Strongest signal: cryptographic proof of origin.
ip_listoperator allow-listSource IP falls within the operator's published allow-list, kept current from each vendor's authoritative source.
reverse_dnsrDNS + forward-confirmReverse-DNS lookup against the operator's domain, then forward-confirmed against the source IP.
ua_onlyno verification confirmedUA matched a known bot pattern but no verification method confirmed identity. Returned with verdict: unverified.

The three booleans on data.verification tell you which signals fired: ua_match, ip_match, signature_agent_verified. The verdict is derived from those, so you can branch on them directly if you want finer-grained policy.

Coverage

We tier each bot by what its vendor actually publishes. Tiers run from strongest signal (signed) to UA-only (no source). The honest read for the no-source bots is part of the product, not a footnote, so you can write WAF rules that don't pretend Bytespider or TikTokSpider are verifiable when they aren't.

Signed4Web Bot Auth signature plus an IP allow-list.
IP-verified16Vendor publishes a machine-readable IP allow-list.
rDNS only4No machine-readable list. Forward-confirmed reverse DNS is the documented path.
No source18Vendor publishes nothing verifiable. UA-only confidence.

Signed (4)

Web Bot Auth signature plus an IP allow-list.

  • GPTBot OpenAI
  • OAI-SearchBot OpenAI
  • ChatGPT-User OpenAI
  • DuckAssistBot DuckDuckGo

IP-verified (16)

Vendor publishes a machine-readable IP allow-list.

  • ClaudeBot Anthropic
  • Claude-User Anthropic
  • Claude-SearchBot Anthropic
  • PerplexityBot Perplexity
  • Bingbot Microsoft
  • Googlebot Google
  • GoogleOther Google
  • GoogleOther-Image Google
  • GoogleOther-Video Google
  • Google-Extended Google
  • Google-CloudVertexBot Google
  • Applebot Apple
  • Applebot-Extended Apple
  • CCBot Common Crawl
  • MistralAI-User Mistral
  • MistralAI-Index Mistral

rDNS only (4)

No machine-readable list. Forward-confirmed reverse DNS is the documented path.

  • YandexBot Yandex
  • Baiduspider Baidu
  • Mail.RU_Bot Mail.ru
  • PetalBot Huawei

No source (18)

Vendor publishes nothing verifiable. UA-only confidence.

  • Bytespider ByteDance
  • TikTokSpider ByteDance
  • Meta-ExternalAgent Meta
  • Meta-ExternalFetcher Meta
  • Meta-WebIndexer Meta
  • FacebookBot Meta
  • anthropic-ai Anthropic
  • Perplexity-User Perplexity
  • Sogou web spider Sogou
  • 360Spider Qihoo 360
  • HaosouSpider Qihoo 360
  • Diffbot Diffbot
  • Amazonbot Amazon
  • DuckDuckBot DuckDuckGo
  • YouBot You.com
  • cohere-ai Cohere
  • UptimeRobot UptimeRobot
  • Slurp Yahoo

Per-bot research notes (vendor-published sources, citations, and the verification path we run for each) are available in your dashboard once you have a key. Coverage moves over time; live captured-at timestamps are surfaced on /health.

Errors

Every error returns the same envelope shape: a JSON body with data: null and a populated error object that always includes a request_id for support correlation.

json error envelope
{ "data": null, "error": { "code": "invalid_input", "message": "user_agent: String must contain at least 1 character(s)", "request_id": "01JABC..." } }

Status codes

fieldtypedescription
400 invalid_inputclientBody wasn't valid JSON, or schema validation failed. Error message names the offending field.
401 unauthorizedclientMissing or invalid API key. Returned once production auth is enabled.
413 payload_too_largeclientRequest body exceeded the 32 KiB hard cap. Most callers won't hit this; typical bodies are < 1 KiB.
429 rate_limitedclientPer-IP or per-tier rate limit. Retry with backoff.
404 not_foundclientUnknown route. The API surface is one POST + /health, so you probably want /v1/classify.
500 internalserverUnexpected error. Capture the request_id and email hi@dutyradar.com.

GET /health

GET/health

Public, unauthenticated, CORS-open. Returns the bundled snapshot ages (compiled-in fallbacks) and the live KV snapshot ages. KV ages are not_yet_refreshed until the daily cron has populated each namespace at least once.

json response
{ "data": { "ok": true, "snapshots": { "bundled": { "captured_at": "2026-05-04T12:00:00Z", "age_hours": 48, "status": "fresh" }, "kv": { "ip_ranges": { "captured_at": "2026-05-06T06:00:00Z", "age_hours": 8, "status": "fresh" }, "keys": { "captured_at": "2026-05-06T06:00:00Z", "age_hours": 8, "status": "fresh" } } } }, "error": null }

Use this for a Cloudflare-side health check or your own external monitor. Don't poll it from clients on a tight loop; it isn't rate-limited per-key (no key concept on this endpoint), but it is per-IP rate-limited by Cloudflare's platform defaults.

OpenAPI spec

The full machine-readable contract is at /openapi.json. OpenAPI 3.1, hand-written so descriptions and examples carry the same weight as schemas. Useful for importing into Postman / Insomnia, or for grafting DutyRadar into your existing API gateway.