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.
Top comments (0)