Response values
Two rules worth knowing before you write any of this. Read verdict if you want one decision and nothing else. Read actor.proven when the difference between evidence and proof matters — it is true for exactly one thing, a verified cryptographic signature, and false for every inference we make.
summary
/v1/score/user
One sentence describing the decision — the text for a Slack alert, a review ticket, or a log line someone greps in six months. Everything else in the response is the same decision in detail.
| Value | Means | What to do |
|---|---|---|
"Blocked (100/100) — Tor exit node, disposable domain and machine-generated handle." | Verdict, score, and the strongest reasons in plain language. | Show it to a person. It is built from the same top_signals as the rest of the response, so it can never disagree with them. |
verdict
Every scoring endpoint
The only field most integrations need. Thresholds move with strictness (0–3); the score does not.
| Value | Means | What to do |
|---|---|---|
allow | Below the review threshold for your strictness. | Let them through. |
review | Enough signal to be worth a second step, not enough to refuse. | Step up — email or SMS verification, or a manual queue. Do not reject: this band is where false positives live. |
block | Past the block threshold, or a hard rule fired. | Refuse, or hold for manual approval if the cost of being wrong is high. |
actor.type
/v1/score/user
What kind of thing this is, as opposed to what to do about it. Branch on actor.proven rather than on this list when you need certainty — it is true only for a verified signature.
| Value | Means | What to do |
|---|---|---|
verified_agent | A Web Bot Auth signature verified against the operator's published key. Proven. | Check actor.trigger: fetcher means a person asked for this and refusing refuses your customer. crawler means nobody did. |
impersonated_agent | A signature was presented and did not verify. | Refuse. Nobody sends a broken Signature-Agent header by accident. |
automation | Headless or automation markers in the browser, and unsigned. Inferred. | Challenge rather than refuse outright — plenty of legitimate automation exists, and these markers can be patched by anything that cares. |
likely_human | A device fingerprint with no automation markers. This is a statement about our evidence, not about your visitor. | Treat as ordinary traffic. Do not treat it as proof a person is present — a capable AI agent driving a real browser reads identically. |
unknown | No signature and no device fingerprint. | Nothing to act on here. Serve /fp.js to get a device answer, or pass the agent's signed request. |
actor.proven
/v1/score/user
The field to branch on when the difference between evidence and proof matters.
| Value | Means | What to do |
|---|---|---|
true | A cryptographic signature settled it. No false-positive rate. | Safe to act on automatically, including allowing traffic you would otherwise challenge. |
false | Everything else on the actor object is inference. | Weigh it, do not obey it. Inference belongs in a challenge, not an automatic refusal. |
agent.decision
/v1/agent/authorize and /v1/score/user
Your policy applied to this agent. Rules are evaluated in order, first match wins, and the defaults below apply when none match.
| Value | Means | What to do |
|---|---|---|
allow | Your policy permits this agent to do this here. | Serve the request. |
deny | Your policy refuses it. On /v1/score/user this also forces verdict to block. | Refuse. You configured this — we are not overruling you by reporting allow. |
review | No rule matched and the defaults could not decide — typically a verified agent that publishes no card. | Fall back to your normal risk handling; the score is still there. |
agent.matched
/v1/agent/authorize and /v1/score/user
Which rule decided. rule[N] is your own policy at that index; the rest are the built-in defaults, which apply only when no rule of yours matched.
| Value | Means | What to do |
|---|---|---|
rule[N] | Your policy rule at index N matched. | Nothing — this is your own configuration working. |
default:signature_failed | A signature was offered and did not verify. | Denied. Strictly worse than sending no signature at all. |
default:unsigned | No Web Bot Auth signature present. Most traffic. | Review. Identity is not a question this can answer — use the risk score. |
default:verified_fetcher | Verified, and the operator declares a person initiated it. | Allowed, including on POST. Refusing this refuses the customer who asked for it. |
default:crawler_state_change | Verified autonomous crawler attempting POST/PUT/PATCH/DELETE. | Denied. An agent acting on nobody's behalf has no business signing up or checking out. |
default:verified_crawler_read | Verified autonomous crawler making a safe request. | Allowed. |
default:verified_no_card | Verified cryptographically, publishes no Signature Agent Card. | Review. We know who, not what. Currently the common case. |
status
/v1/verify/email
ZeroBounce-compatible vocabulary, so existing branching ports across unchanged.
| Value | Means | What to do |
|---|---|---|
valid | Nothing disqualifying found — the syntax, the domain and the mail servers all check out. It does NOT mean the specific mailbox exists: that check is not enabled yet, which is what sub_status: mailbox_not_verified is telling you. | Accept for a signup. For a mailing list, treat it as 'the domain can receive mail', not 'this person can'. |
invalid | Structurally bad, no MX, or the mailbox was verified not to exist. | Reject and ask for a correction — check did_you_mean first. |
risky | Deliverable but carries risk: catch-all domain, suspicious handle. | Accept with verification rather than rejecting. |
do_not_mail | Disposable or a role account. | Reject for signup; never add to a mailing list. |
sub_status
/v1/verify/email
Why the status is what it is. Free-form, and stable — new values may be added.
| Value | Means | What to do |
|---|---|---|
failed_syntax_check | Not a valid address. | Reject; ask again. |
disposable | A throwaway provider. | Reject for signup. |
role_based | A shared inbox (info@, support@). | Fine for B2B contact, poor for an individual account. |
suspicious_handle | The local part looks machine-generated. | Verify rather than reject — real people do have odd addresses. |
catch_all | The domain accepts mail for addresses that do not exist. | Nobody can verify this mailbox. Do not treat acceptance as evidence. |
mailbox_verified | The mail server confirmed the mailbox exists. | Strongest deliverability signal available. |
mailbox_not_found | The mail server said it does not exist. | Reject. |
mailbox_not_verified | This provider does not give a trustworthy answer to a mailbox probe, so we do not claim one. Not the same as invalid. | Judge on the other signals — everything except the mailbox itself was checked. |
newly_registered_domain | The domain was registered fewer than 30 days ago. | Strong fraud signal in combination; weak alone. |
risky_tld | A TLD with disproportionate abuse. | Contributing signal, never a reason on its own. |
possible_typo | Looks like a misspelling of a common domain. | Show did_you_mean and let the user correct it. |
community_reported_fraud | Another customer reported this value as confirmed fraud. | Treat seriously — this is a real report, not an inference. |
signals.mailbox_status
/v1/verify/email
Which kind of 'unknown' you have. A bare null cannot distinguish 'nobody can ever verify this' from 'we have not got to it yet', and those call for opposite reactions.
| Value | Means | What to do |
|---|---|---|
verified | A real yes/no from the mail server. | Trust mailbox_exists. |
catch_all | The domain accepts everything. Unverifiable by anyone, at any price. | Stop asking. Roughly a quarter of sampled domains, concentrated in B2B SaaS. |
pending | Queued; the answer will be there on the next lookup. | Proceed on the other signals, or pass ?wait_for_mailbox=true to block on it. |
unsupported | We deliberately do not probe this domain. | Judge on the other signals. |
unavailable | No probe host is configured. | Same as unsupported. |
unknown | The request's own time budget ran out before the mailbox stage was reached, so nothing was attempted. | Retry, or pass ?wait_for_mailbox=true. Unlike unsupported and catch_all, this one can succeed next time. |
parse_status
/v1/lookup/phone
Why a number did not parse — and, in one case, how to make it parse. This file opens by promising every value you can branch on, exhaustively, and warning that an unlisted value falls through to a default and gets treated as allow. parse_status appeared here zero times and nowhere on the docs site, so a customer validating numbers entered without a country code got country_required, did not recognise it, fell through to their default branch, and let the number through unvalidated. They believed they were checking phones.
| Value | Means | What to do |
|---|---|---|
ok | Parsed and assigned to a real numbering plan. | Read the rest of the response. |
country_required | A plausible run of digits with no country code. We cannot tell +1 415 555 2671 from +44 7911 123456 without one. | Resend with &country=XX (ISO-3166 alpha-2) and it will work. This is the one parse_status a retry fixes. |
impossible | Not a phone number in any plan on earth — too few digits, or a country code that was tried and rejected. | Reject it. A retry with a country cannot help. |
invalid_pattern | A plausible length and prefix, but not an assigned pattern in that country. | Reject it, or ask the person to check what they typed. |
Signal flags
Every boolean in a signals object. Most are true-is-bad, but not all — the ones marked TRUE IS GOOD are health flags whose absence is the risk, and getting that backwards is the most common way an integration ends up rejecting good users.
A null anywhere means we could not determine it. It never means false, and it should never count against a user.
IP /v1/score/ip
is_vpnCommercial VPN egress. Review, not block — plenty of legitimate users.is_proxyAnonymising egress. Derived from is_tor or is_vpn, so it adds no new information.is_datacenterHosting provider rather than a consumer connection.is_torA Tor exit node.recent_abuseAbuse history from our own reputation network.is_hijacked_netblockInside a block Spamhaus DROP lists as hijacked or criminal-controlled. Attribution in hijacked_source.is_private_or_reservedRFC1918 or reserved space — not routable, usually a misconfiguration on your side.
Email /v1/verify/email
syntax_validParses as an address. Unicode local parts are accepted (RFC 6531).mx_foundThe domain publishes MX records.is_disposableA known throwaway provider.is_homographMixed-script domain designed to look like another.is_role_accountShared inbox rather than a person.is_free_providerGmail, Outlook and similar. Ambiguous on its own.is_suspicious_handleThe local part looks machine-generated.is_taggedUses a +tag. Often legitimate; useful for dedupe via normalized_email.is_risky_tldA TLD with disproportionate abuse.is_new_domainRegistered fewer than 30 days ago, via RDAP. null means the registry could not be reached — an unknown age, not an old domain.has_spf / has_dmarcEmail authentication posture. Absence is the risk signal.has_websiteThe domain serves a site. null means undetermined, never counted against it.mailbox_existsSMTP confirmation. null when unverified — see mailbox_status.is_catch_allThe domain accepts mail for addresses that do not exist.has_digital_footprintPublic Gravatar or breach history — an aged real identity lowers risk.
Phone /v1/lookup/phone
syntax_validParses for its country.is_possibleMatches a possible length. TRUE IS GOOD — this is a health flag despite the name.assigned_area_codeThe area code is actually allocated. TRUE IS GOOD.is_voipVoIP range. Available for GB, FR, NL, IT, CA and others — not for +1 US numbers, whose plan has no VoIP range.is_premium_ratePremium-rate number.is_toll_freeToll-free number. Ambiguous rather than bad.is_fictionalA reserved or film-use number (555 ranges).
Domain /v1/score/domain
resolvesDNS resolves. TRUE IS GOOD.mx_foundCan receive mail. TRUE IS GOOD.has_spf / has_dmarcAuthentication posture. TRUE IS GOOD.is_disposableA known throwaway domain.is_homographMixed-script lookalike.is_free_providerA consumer mail provider rather than an owned domain.is_risky_tldHigh-abuse TLD.newly_registeredRegistered fewer than 30 days ago. null means the registry could not be reached — an unknown age, not an old domain.
Device /v1/score/device
is_botbot_probability reached 60 or more. That number is the sum of what was actually observed: injected automation globals (80), navigator.webdriver (70), a headless browser (60), a user agent that names a scraping tool (55), a zero-sized screen (30), no plugins (10), no languages (10).is_automatedAn automation framework was detected (webdriver and similar).is_headlessA headless browser.timezone_mismatchBrowser timezone disagrees with the IP's location. null = the country could not be resolved, so no comparison was made — treat it as unknown, not as agreement.repeat_deviceSeen before. Ambiguous — a returning customer looks the same as a trial cycler until you check linkage.
Linkage /v1/score/user
device_email_countDistinct emails seen with this device. The strongest multi-account signal here.email_device_countDistinct devices seen with this email.email_ip_countDistinct addresses seen with this email.subnet_rate_1hLookups from this /24 in the last hour. Scoped to the subnet because rotating inside one is the cheapest evasion there is.domain_rate_1hLookups against this email domain in the last hour. Free providers are exempt.
The numbers
Every numeric field, and which end is the bad end. Two of these run in opposite directions inside the same response, so it is worth reading the direction line before writing a comparison.
billable_lookups every scored response, including test mode and cache hits
- Range
- whole numbers. Usually 1. /v1/score/user bills one per component, so up to 4.
- Direction
- This is the number you are charged for — not the number of fields returned, and not the number of feeds consulted.
- What to do
- Sum it to reconcile your own bill against ours. Two cases surprise people and both are deliberate: a cached answer bills 0, and a test-mode call bills 0 while still counting against the test allowance. If it is 0 you were not charged.
processing_time_sec every scored response
- Range
- seconds, three decimal places. Typically 0.2–0.6 warm; a cold instance can be several seconds.
- Direction
- Our server time only. It excludes the network both ways, so it will always read lower than what your own client measures.
- What to do
- Use it to tell 'the API was slow' apart from 'the connection was slow' before raising a ticket. Do not use it as an SLA figure — the number your users feel is the round trip, not this.
signals_unavailable any scored response, ABSENT when everything answered — never an empty array
- Range
- an array of source names: tor, ip_reputation, vpn_providers, cloud_ranges, hijacked_ranges, disposable_domains, dns, geo, abuse_reports, gravatar, domain_age, reputation_network, custom_rules, digital_footprint.
- Direction
- Names what was NOT measured. A clean answer carrying a name is weaker evidence than the same answer without it — nothing is being asserted about those sources, in either direction.
- What to do
- READ THE NAMES, do not test the key. Two different facts arrive here: a source that was down, which clears, and a source that does not cover the input you sent, which never does. Every IPv6 address permanently carries ip_reputation, cloud_ranges, hijacked_ranges, abuse_reports and vpn_providers, because those feeds are IPv4-only — and mobile carriers are heavily IPv6, so `if (res.signals_unavailable) review()` sends a large and permanent share of real signups to manual review. Branch on the name you actually depend on: screening for Tor, check whether `tor` is in the list; if it is not, the Tor check ran.
request_id every response, including errors
- Range
- a string, `req_` followed by a UUID
- Direction
- Not a value to branch on — a value to KEEP. It is the one thing that lets us find your exact call in our logs.
- What to do
- Log it alongside your own record of the decision. Quoting it in a support ticket is the difference between us answering in minutes and asking you for a reproduction.
risk_score every scoring endpoint
- Range
- 0–100, whole numbers
- Direction
- HIGH is bad. 100 is the worst.
- What to do
- Prefer `verdict` — it already applies the thresholds, and it moves with the strictness setting. Branch on the number only when you want your own cut-offs.
bot_probability /v1/score/device, and the device component of /v1/score/user
- Range
- 0–100, whole numbers. The same scale in test mode and in production.
- Direction
- HIGH means more likely automated. It is NOT a risk score — plenty of automated traffic is legitimate, which is why it is reported separately.
- What to do
- 60 or more is what sets is_bot, and is a reasonable place to start. 95 or more is a stacked, unambiguous automation signature: injected globals plus webdriver, or a headless browser naming itself. Below 55 no single automation marker has fired at all.
deliverability_score /v1/verify/email
- Range
- 0–100, whole numbers
- Direction
- LOW is bad — the OPPOSITE of risk_score, which sits beside it in the same response and also ends in _score. 0 means do not send.
- What to do
- This is a mail-sending judgement, not a fraud judgement: it answers 'will this reach an inbox', while risk_score answers 'is this signup safe'. They disagree often and correctly. Note that mailbox verification is not yet enabled, so a syntactically valid address at a live domain scores 90 whether or not the mailbox exists — sub_status says mailbox_not_verified when that is the case.
abuse_reports every scoring endpoint
- Range
- 0 upward
- Direction
- HIGH is bad. Confirmed reports from the network, not guesses.
- What to do
- Any non-zero value is worth reading. It is already folded into risk_score; the raw count is there so you can weight it yourself.
domain_age_days /v1/score/domain, /v1/verify/email
- Range
- 0 upward, or null
- Direction
- LOW is bad. Under 30 days is what sets newly_registered.
- What to do
- null means the registry could not be reached, or publishes no RDAP service for that extension — an UNKNOWN age, never an old domain. Coverage is UNEVEN and worth planning for: measured across 36 real domains, every .com returned a real age while 8 returned null, spread across .co.jp, .us, .so, .me, .de, .ru and .it. Roughly a fifth. Domain age is one of the strongest fraud signals there is, so on those extensions a brand-new domain gets none of the newness penalty a .com would — do not read null as reassurance.
times_seen every scoring endpoint
- Range
- 1 upward
- Direction
- Neither, on its own. A returning customer and a script look identical here.
- What to do
- Read it with first_seen and the linkage counts. Forty sightings in an hour is a script; forty over a year is a customer.
A starting policy
If you want something to paste and adjust rather than a menu, this is a defensible default. It refuses almost nothing outright, because the expensive mistake in this product is refusing a real customer.
const r = await score(signup); // 1. A denied agent is your own policy. Obey it. if (r.agent?.decision === "deny") return reject(r.agent.reason); // 2. Proof outranks inference. A verified user-initiated agent // is a customer's assistant doing what they asked. if (r.actor.proven && r.actor.type === "verified_agent" && r.actor.trigger === "fetcher") return allow(); // 3. Disposable addresses and confirmed fraud: refuse. if (r.components.email?.status === "do_not_mail") return reject(); if (r.components.email?.sub_status === "community_reported_fraud") return reject(); // 4. One machine across many identities is the clearest // multi-account pattern you will get. if ((r.linkage.device_email_count ?? 0) >= 5) return stepUp(); // 5. Otherwise let the verdict decide. if (r.verdict === "block") return reject(); if (r.verdict === "review") return stepUp(); // verify, don't refuse return allow();
Note what is not in it: nothing refuses on is_vpn, is_datacenter or automation alone. Each is a contributing signal with a real false-positive rate, and blocking on any one of them is the most common way to lose exactly the technical users you want.
Once you are live, send outcomes back to /v1/outcome. It is free, and it is the only way either of us finds out whether these thresholds are right for your traffic.