DEV Community

IP99
IP99

Posted on

Score Zero Has Two Meanings: Branch on evidence_state First

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
Enter fullscreen mode Exit fullscreen mode

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"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)