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:
- Read
evidence_state— can this conclusion be trusted at all? - Then
risk.score/risk.level. - Then
risk.signals— why it scored (proxy vs dial-up pool vs cloud phone are different playbooks). - Optionally network shape (
network.usage_type) for blast-radius intuition when you do not have Pulse’sshare_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 ascrawler
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
- Live lookup & product: https://ip99.com
- English API reference (auth, quota headers, Pulse, errors): https://ip99.com/api
- Machine-readable:
GET https://ip99.com/openapi.json
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.

