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.
Top comments (1)
tr.ee/dev-to