DEV Community

IP99
IP99

Posted on

IP Risk That Expires: Trying IP99's Free No-Key Lookup API

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

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

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

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

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

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

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

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

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to