If your antifraud rule treats every risk.score == 0 as "clean," you will misread two very different situations the same way. On IP99 by ThreatHunter, score zero can mean never seen (evidence_state: none) or seen before, evidence window expired (evidence_state: stale). Both print 0. Only one of them is a coverage gap; the other is a fading memory with leftover signal slugs. The documented read order starts with evidence_state for that reason.

This walkthrough stays on the free, no-key GET /v1/ip/{ip} endpoint. Live samples were captured on 2026-11-03 (~13:11 UTC).

What evidence_state actually means

IP99's risk score is freshness and strength of verifiable risk evidence, on a 0–100 scale that decays as evidence ages. It is not the probability that an address is an attacker. risk.level is derived from the score: 0 → none, 1–29 → low, 30–69 → medium, 70–100 → high.

evidence_state tells you whether that score is even in play:

evidence_state Meaning Score behavior
active Fresh enough evidence to participate in scoring Score can be 1–100
stale Address was seen before; evidence window expired Forced to 0; leftover signals are a weak feature
none Never seen in the risk evidence set Also 0; coverage gap — not "safe"

Official disposition guidance (generic advice, not a contract): score 0 → do not mark the IP clean; 1–29 → observe; 30–69 → step-up (captcha, throttle, second factor) rather than a hard ban; 70–100 → block only when business cost allows. hijacked_proxy should not automatically ban the account holder — that address is often a victim of malware-turned-exit capacity.

Example A: score 0 + none (never seen as risk evidence)

curl https://ip99.com/v1/ip/8.8.8.8 

Captured 2026-11-03T13:11:32Z:

{ "ip": "8.8.8.8", "computed_at": "2026-11-03T13:11:32Z", "evidence_state": "none", "geo": { "continent": "NA", "country": "US", "region": "California", "city": "Mountain View", "district": "", "tz": "America/Los_Angeles" }, "network": { "asn": 15169, "usage_type": "IDC" }, "risk": { "score": 0, "level": "none", "signals": ["hosting"] } } 

usage_type: IDC and signals: ["hosting"] are network attributes, not a proxy verdict — Google Public DNS lives in hosting space. evidence_state: none with score 0 means IP99 holds no current verifiable risk evidence for this address. That is not a clean bill of health; it is "we have nothing risk-shaped on file." Do not invent Tor or other signals that are not in the response.

Example B: score 0 + stale (same demo IP, different day)

Public docs often demonstrate with a residential dial-up / proxy-supply address. Full address works in curl; prose and tickets should mask the last octet.

curl https://ip99.com/v1/ip/27.22.66.200 

Captured 2026-11-03T13:11:48Z (last octet masked in the body):

{ "ip": "27.22.66.x", "computed_at": "2026-11-03T13:11:48Z", "evidence_state": "stale", "geo": { "continent": "AS", "country": "CN", "region": "Hubei", "city": "Xiangyang", "district": "Yicheng", "tz": "Asia/Shanghai" }, "network": { "asn": 4134, "usage_type": "DYN" }, "risk": { "score": 0, "level": "none", "signals": ["dialup_pool", "hijacked_proxy", "proxy"] } } 

Same score shape as Example A — 0 / none — but a different story. usage_type: DYN is residential broadband. The signal list still carries proxy, dialup_pool, and hijacked_proxy. On 2026-11-01, this same demo address returned evidence_state: active with a low non-zero score. Today the window has expired: score is forced to 0, state is stale, and those slugs survive as a weak feature — useful for logging and soft routing, not as a fresh "ban now" trigger.

If your code only checks score == 0, Examples A and B collapse into one bucket. Branch on evidence_state first and they stay distinct: A is a gap; B is expired evidence that still names the historical observation types.

The decision order (free /v1/ip)

IP99's documented order is intentionally boring:

  1. evidence_state — is the conclusion even scorable right now?
  2. risk.score / risk.level — how strong/fresh is the evidence when active?
  3. risk.signals — branch by type (proxy / vpn / dialup_pool / cloud_phone vs crawler vs network attributes hosting / mobile).
  4. Ban scope — on Pulse, share_tier (T0–T3) decides whether an IP ban hits one tenant or a whole CGNAT building. The free /v1/ip response does not include share_tier; keep that in mind before hard-blocking.
  5. degraded — if present, some upstreams were unavailable; discount the answer.

A small branch that matches the schema:

if evidence_state == "none" and score == 0: do_not_mark_clean() # coverage gap elif evidence_state == "stale" and score == 0: log_weak_feature(signals) # expired memory; soft weight only elif evidence_state == "active": if score >= 70: block_if_business_allows() elif score >= 30: step_up() # captcha / throttle / review else: observe() # hosting / mobile in signals → network context only; never sole ban reason # hijacked_proxy → step-up, not automatic account ban 

Anti-patterns this avoids: treating every zero as trusted; caching yesterday's label without storing computed_at + evidence_state; equating hosting with fraud; hard-banning on hijacked_proxy alone.

Try it without a key

curl https://ip99.com/v1/ip/8.8.8.8 curl https://ip99.com/v1/ip/27.22.66.200 curl https://ip99.com/v1/ip # caller's own egress curl https://ip99.com/v1/me # quota / plan headers context 

Anonymous quota is 500 lookups/day per client IP and 60/min, resetting at midnight Asia/Shanghai. CORS is open (Access-Control-Allow-Origin: *). Machine-readable contract: OpenAPI. Field deep-dives: evidence freshness, risk score, detect proxy/VPN, and the English API reference.

Paid volume plans change quotas, not the free answer shape. Per-evidence rows, share_tier, and ?at= history sit on Pulse (professional).

Takeaway

Score zero is ambiguous until you read evidence_state. none means no risk evidence on file. stale means evidence existed, the scoring window expired, and leftover signal slugs are weak features — not a fresh verdict. Branch on state first, then score, then signal type. For the free lookup and field definitions, start at ip99.com.