Most IP intelligence products hand you a durable label: proxy, VPN, datacenter — as if that fact stays true for weeks. In fraud and abuse work, that assumption is often wrong. An address that was a dial-up egress this morning may be a quiet residential NAT by evening. What you need at decision time is not a permanent brand, but a verifiable, time-aware verdict.

IP99 (ThreatHunter) exposes that as a simple HTTPS JSON API. The basic lookup needs no signup and no API key:

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

A typical response looks like this (field names and shape match production; values move as evidence ages):

{ "ip": "8.8.8.8", "computed_at": "2026-10-10T08:13:49Z", "evidence_state": "none", "risk": { "score": 0, "level": "none", "signals": ["hosting"] }, "network": { "asn": 15169, "usage_type": "IDC" }, "geo": { "continent": "NA", "country": "US", "region": "California", "city": "Mountain View", "lat": 37.386051, "lon": -122.083847, "tz": "America/Los_Angeles" } } 

Anonymous callers get a daily allowance (see X-Daily-Limit / X-Daily-Remaining on the response). Sign in and create a key if you outgrow the free IP-scoped quota. Full field dictionary, error codes, and OpenAPI live at ip99.com/api.

What risk.score actually means

This is the part most teams get wrong on first integration.

Field Contract
risk.score (0–100) Freshness and strength of verifiable evidence, not P(attacker). The score decays as evidence ages.
risk.level Derived only from score: 0 → none, 1–29 → low, 30–69 → medium, 70–100 → high.
evidence_state active (fresh evidence still scoring), stale (seen before, evidence expired, score 0), none (never seen).
computed_at The moment this verdict belongs to (RFC 3339). Same IP hours later → different score is expected.

Score 0 is not “safe.” It means IP99 has no current verifiable evidence. That can be expired evidence (stale) or coverage gap (none). Branch on evidence_state before you treat a zero as a clean pass.

Suggested triage order in a risk pipeline:

  1. Read evidence_state — can this conclusion be trusted at all?
  2. Then risk.score / risk.level.
  3. Then risk.signals — why it scored (proxy vs dial-up pool vs cloud phone are different playbooks).
  4. Optionally network shape (network.usage_type) for blast-radius intuition when you do not have Pulse’s share_tier.

Signals you can key policy on

risk.signals is an alphabetical list of matched slugs only. The key is omitted when nothing matched. Documented risk-related slugs include:

  • proxy — used as proxy egress
  • vpn — commercial VPN static egress
  • dialup_pool — redial / “second-dial” style rotation
  • cloud_phone — handset-in-datacenter style egress
  • hijacked_proxy — malware-abused residential proxy (owner is often the victim; prefer step-up over hard ban)
  • plus osint, cloud_service, and benign/non-scoring tags such as crawler

Two slugs describe network property, not bad behavior: hosting and mobile. Google Public DNS can return hosting with score: 0. Do not block on those alone unless you intend to take out clouds and carriers.

IP99 does not claim Tor exit detection in this API — do not invent that signal in your wrappers.

Illustrative high-evidence shape (IP masked; structure from the public API):

{ "ip": "203.0.113.42", "computed_at": "2026-09-17T08:47:02Z", "evidence_state": "active", "risk": { "score": 86, "level": "high", "signals": ["proxy"] }, "network": { "asn": 15547, "usage_type": "DYN" }, "geo": { "continent": "EU", "country": "CH", "region": "Valais", "city": "Sitten", "lat": 46.22739, "lon": 7.35559, "tz": "Europe/Zurich" } } 

For China addresses, geo goes to district/county: Latin region / city / district, plus subdivision (ISO 3166-2, e.g. CN-HA) and admin_code (GB/T 2260 six-digit). Elsewhere you typically get city-level Latin names. Missing keys mean “not in the database,” not zero.

When to use Pulse

Question Endpoint
Risk + geo right now for signup / login / checkout Free GET /v1/ip/{ip}
Why it was judged that way (per-item evidence, freshness, resource pool handles) Pulse GET /pulse/v1/ip/{ip} (professional tier)
Reproduce the verdict at a past moment (review, dispute, backtest) Pulse with ?at= (RFC 3339, up to ~30 days; history enabled separately)

There is one score per IP. Pulse does not invent a second number; it adds evidence rows (evidence[] with risk_type, freshness, optional captured_at / age_min, and obs_days), plus share_tier (T0–T3) for block blast radius. Without a professional key you get 403 professional_required and spend no quota — the free basic endpoint still works.

# Professional key required curl -H "X-API-Key: YOUR_KEY" \ "https://ip99.com/pulse/v1/ip/203.0.113.42?at=2026-09-14T09:29:23Z" 

Adjacent free try endpoints: pDNS and CT

If you are already hunting infra around an IP decision, IP99 also exposes lightweight domain intel you can curl without ceremony:

# Passive DNS — historical A/AAAA (and related) observations for a domain curl https://ip99.com/v1/domain/dns/example.com # Certificate Transparency — certs issued for a domain curl https://ip99.com/v1/domain/certs/example.com 

Useful for correlating “this signup IP” with “domains that recently pointed here” or “certs that just appeared.” Treat them as investigation aids alongside the IP risk call, not as a substitute for evidence_state triage.

A minimal integration pattern

import json, urllib.request def ip99_lookup(ip: str) -> dict: req = urllib.request.Request(f"https://ip99.com/v1/ip/{ip}") with urllib.request.urlopen(req, timeout=5) as r: return json.load(r) def gate(ip: str) -> str: d = ip99_lookup(ip) state = d["evidence_state"] score = d["risk"]["score"] signals = d["risk"].get("signals") or [] if state == "none": return "allow_with_baseline" # no evidence ≠ trusted  if state == "stale": return "allow_weak_feature" # weak prior only  if score >= 70 and "hijacked_proxy" not in signals: return "step_up_or_block" if score >= 30: return "captcha_or_mfa" return "allow_log" print(gate("8.8.8.8")) 

Wire this next to device fingerprinting, velocity, and payment signals — IP risk is a feature, not a sole verdict.

Try it

One line to start:

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

Build policy on evidence that expires, not on labels that pretend they do not.