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:
-
evidence_state— is the conclusion even scorable right now? -
risk.score/risk.level— how strong/fresh is the evidence whenactive? -
risk.signals— branch by type (proxy/vpn/dialup_pool/cloud_phonevscrawlervs network attributeshosting/mobile). - Ban scope — on Pulse,
share_tier(T0–T3) decides whether an IP ban hits one tenant or a whole CGNAT building. The free/v1/ipresponse does not includeshare_tier; keep that in mind before hard-blocking. -
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.