Response values
Every value you can branch on, exhaustively. Not a representative sample — a value you have never seen falls through to your default case and gets treated as whatever that happens to be, which for most integrations means allowing it.
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.
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. | Accept. |
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 | We could not check. Not the same as invalid. | Judge on the other signals. |
newly_registered_domain | The domain was registered very recently. | 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. |
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 recently, via RDAP.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 recently.
Device /v1/score/device
is_botBot heuristics fired.is_automatedAn automation framework was detected (webdriver and similar).is_headlessA headless browser.timezone_mismatchBrowser timezone disagrees with the IP's location.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.
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.