中文
IP99 API
IP risk scoring and geolocation. Proxy, VPN, dial-up pool, cloud phone and
datacenter detection, with ASN, network usage type and district-level geo.
Try it — no signup
curl https://ip99.com/v1/ip/8.8.8.8
No API key, no registration, no cookies. Returns JSON. Responses contain no
Chinese text: place names, network types and risk types are returned as
standard codes or stable slugs.
What the score means — and what it does not
risk.score (0–100) measures the freshness and strength of verifiable
risk evidence, decaying with the age of that evidence.
It is not a probability that the address is an attacker.
| Field | Meaning |
risk.level | none (0) / low (1–29) / medium (30–69) / high (70–100).
none means we hold no current verifiable evidence — not that the address is safe. |
evidence_state | active fresh evidence · stale seen before but expired (score 0, still a weak signal) ·
none never seen. A score of 0 has two very different meanings — branch on this field. |
score_semantics | Stable identifier capture_evidence_freshness, restated in every response so a
consumer cannot silently drift into treating the number as a probability. |
source / source_url | Where the data came from and where a human can verify it. |
Endpoints
| Endpoint | Returns |
GET /v1/ip/{ip} | Risk score, tags, geo, network — free, no key |
GET /pulse/v1/ip/{ip} | Same score with per-evidence detail — professional tier only |
GET /v1/me | Your current tier and remaining quota |
POST /v1/phone/lookup | Phone-number risk profile (separate capability, enterprise only) — details |
/pulse/v1/ip/{ip} requires the professional data tier. Without a
professional key it answers HTTP 403 professional_required — there is no
exception for answer engines. Use the free /v1/ip/{ip} for the score, or
see ip99.com/en/enterprise for a key.
Authentication
Send a key as X-API-Key: <key> or Authorization: Bearer <key>.
Never in the query string — URLs end up in access logs, so ?key=
is rejected with HTTP 400 key_in_query.
MCP (Model Context Protocol)
If your agent speaks MCP, call IP99 as a tool rather than scraping a URL:
endpoint: https://ip99.com/mcp (Streamable HTTP, POST)
tool: ip_risk_lookup { "ip": "8.8.8.8" }
The endpoint is stateless: no Mcp-Session-Id is issued and none is
required, so it works behind ordinary load balancers. GET returns 405 — there is
no server-initiated stream here. No authentication is needed for the free tier;
send X-API-Key for a higher one.
Errors on the MCP endpoint
A failed tool call is HTTP 200 with isError: true, the MCP convention: a bare
429 makes clients report the server as broken instead of telling the model what to do next.
Every content[0].text is a JSON string, so JSON.parse never throws:
on success it is the same document as GET /v1/ip/{ip}, on failure it is the REST
error envelope with the same error.code table, plus what to do next:
{"error":{"code":"quota_exceeded_anon",
"message":"daily quota exhausted for anonymous callers; sign in for a higher free limit, or see https://ip99.com/en/pricing",
"retry_after":32269, "resets_at":"2026-09-12T16:00:00Z",
"next_step":"Sign in for 5000 lookups/day free: connect https://ip99.com/en/mcp/account as the MCP server URL and approve the login it opens, or send an API key as the X-API-Key header",
"signup_url":"https://ip99.com/en/console", "pricing_url":"https://ip99.com/en/pricing",
"signed_in_endpoint":"https://ip99.com/en/mcp/account"}}
Argument problems use missing_ip, invalid_ip, reserved_ip
and not_found; limits and credentials use the REST codes (rate_limited,
quota_exceeded, quota_exceeded_anon, invalid_key…) with
retry_after in seconds and resets_at where they apply. The
Retry-After and X-Daily-* headers are still set on the HTTP response.
Below 20% of the daily quota a successful result carries a second text block,
{"notice":{"code":"quota_low",…}}, with the same next_step fields.
Handshake methods (initialize, tools/list) refused by the per-minute
limit get a JSON-RPC error -32000 carrying the same object as
error.data.error. Malformed JSON is HTTP 400 with -32700, an unknown
tool is -32601, and a wrong argument type or an argument outside
inputSchema is -32602.
Sign in from your MCP client (nothing to copy)
Connect https://ip99.com/en/mcp/account instead of /mcp. That endpoint answers
401 with OAuth discovery metadata, so a client that implements the MCP authorization spec (Claude Code,
Claude.ai and ChatGPT connectors, Cursor, VS Code) registers itself, opens the IP99 sign-in page in
your browser and keeps the token. The quota is your account's, the same counter as your API keys;
tokens last an hour and refresh silently for 30 days.
# Claude Code
claude mcp add --transport http ip99 https://ip99.com/en/mcp/account
# then run /mcp inside Claude Code and pick "Authenticate"
# Claude.ai / ChatGPT: add a custom connector with the URL https://ip99.com/en/mcp/account
# Cursor / VS Code: "url": "https://ip99.com/en/mcp/account", no headers needed
The free endpoint /mcp stays open without sign-in and counts per client IP. An API key
in the Authorization: Bearer or X-API-Key header works on
/mcp/account as well. Discovery lives at
/.well-known/oauth-authorization-server/en and
/.well-known/oauth-protected-resource/en/mcp/account; only public clients with PKCE (S256)
are accepted. The sign-in page follows the URL: /en/mcp/account signs in on the English
console, /mcp/account on the Chinese one; tokens work on both. Connected apps are listed under
Console › API keys, where a click disconnects one.
Using your API key from an MCP client
The same key that raises the REST quota raises the MCP quota — it is one counter. Put it
in the X-API-Key header of the server entry, never in the URL:
# Claude Code
claude mcp add --transport http ip99 https://ip99.com/mcp --header "X-API-Key: YOUR_KEY"
# Cursor (.cursor/mcp.json) — VS Code uses the same shape under "servers" with "type": "http"
{
"mcpServers": {
"ip99": {
"url": "https://ip99.com/mcp",
"headers": { "X-API-Key": "YOUR_KEY" }
}
}
}
# Claude Desktop (remote servers cannot carry custom headers yet): bridge with mcp-remote
{
"mcpServers": {
"ip99": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://ip99.com/mcp", "--header", "X-API-Key:YOUR_KEY"]
}
}
}
Without a key the anonymous allowance applies, counted per client IP. A free account key
gets the signed-in allowance; paid plans raise it further — see
pricing. When you are close to the limit, the tool result carries a
notice saying how much is left and what to do next.
For AI assistants and answer engines
Verified answer-engine traffic gets its own daily allowance, counted
per crawler family rather than per IP, because your users share your
egress addresses. Verification is by user-agent and source IP against each
vendor's officially published prefix list (OpenAI, Anthropic, Perplexity,
Common Crawl). Nothing to configure — call the endpoint with your normal
user-agent.
On exhaustion you receive HTTP 429 with code quota_exceeded_ai_agent
and a pointer to request a key — the service is not unavailable.
Machine-readable summary: /llms.txt
Phone-number risk profile
A second capability, separate from IP lookups: is a mobile number a SIM-farm card, an
account-farming card or a recycled number. Enterprise accounts only — the key
must have the capability enabled, and it has its own quota, counted per number,
not per request. Free and signed-in accounts get 401 / 403 and nothing is charged.
See ip99.com/en/enterprise to get access.
| Endpoint | Body | Billed |
POST /v1/phone/lookup | {"number": "13800001234"} | 1 number |
POST /v1/phone/batch | {"numbers": [...]}, at most 1000; results keep the input order | every parseable number, duplicates counted each time |
GET /v1/phone/meta/tags | dictionary: label and description for every slug | free |
GET /v1/phone/meta/coverage | coverage and update cadence | free |
Numbers travel in the request body only — there is no GET /v1/phone/{number},
because a phone number is personal data and URLs are written to access logs at every hop.
curl -X POST https://ip99.com/v1/phone/lookup \
-H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"number": "13800001234"}'
Input
Separators (spaces, dashes, brackets, dots), full-width digits and the zero-width or
non-breaking characters that come with copy-paste are normalised. +86… and
0086… are international forms; a bare digit string is read as a mainland China
mobile number when it looks like one (1[3-9] plus nine digits), otherwise as an
international number without its + when a country code and a valid national
length can be split off (12025550001 becomes +1), and only then with
the default +86. Send the + when it matters.
Letters, quotes, emoji or anything else that does not belong in a phone number make the
input unparseable — nothing is guessed or extracted: a single lookup answers
400 bad_number, a batch item carries an inline error, and neither is
billed. A number with the wrong length (one digit short, a landline) does parse and is
looked up, so it is billed; it comes back with
query.valid_length: false and miss_reason: "unqueryable_bad_length"
so you can tell "bad input" from "looked up, clean".
Response
{
"query": {"input": "13800001234", "e164": "+8613800001234", "national_number": "13800001234",
"country_code": 86, "country": {"iso2": "CN"}, "valid_length": true},
"hit": true,
"evidence_state": "active",
"risk": {"score": 6, "level": "medium",
"primary_tag": {"code": 2, "slug": "dormant"}, "tags": [{"code": 2, "slug": "dormant"}]},
"activity": {"first_seen": "2026-06-20T10:54:16Z", "last_active": "2026-06-20T10:54:16Z",
"last_active_days_ago": 83, "observed_days": 0},
"carrier": {"province": "CN-BJ", "city_code": "1101", "carrier": "CMCC",
"attribute": {"code": 0, "slug": "base_carrier"}, "card_type": {"code": 0, "slug": "standard"}},
"sources": [{"library": "cn", "risk": 6, "tag": {"code": 2, "slug": "dormant"},
"first_seen": "2026-06-20T10:54:16Z", "last_active": "2026-06-20T10:54:16Z"}],
"meta": {"request_id": "472ef7d6e49a49fe", "data_version": {"cn": "20260911", "sg": "20260911"}}
}
| Field | Meaning |
query.* | Your input and its normalised form. country.iso2 is null for +1 (25 regions share it); valid_length is null when the numbering plan is unknown. |
hit | Whether the number is in the risk database. false does not mean safe — the database only holds numbers already captured. |
evidence_state | active: fresh evidence, act on it. stale: seen, but not captured for over 90 days — the number may be back with a legitimate user, down-weight rather than block. none: never captured. |
risk.score / risk.level | 0–9 raw score from the data source, passed through unchanged. level (high/medium/low/none) is a display band, not a decision. |
risk.primary_tag / risk.tags[] | {code, slug}; the slug is the stable identifier (sim_farm, dormant, …). Labels and descriptions come from /v1/phone/meta/tags. |
activity.* | First seen, last active, days since, observed days. The same score means something different when it was active yesterday versus three years ago. |
carrier | Mainland numbers: province (ISO 3166-2, e.g. CN-BJ), city_code (GB/T 2260), carrier (CMCC/CUCC/CTCC/…); numbers outside mainland China have none of the three. attribute and card_type are {code, slug}. Missing sub-fields are simply absent. |
sources[] | Which library the hit comes from (cn or sg) with its own score and timestamps. |
miss_reason | Only when hit is false: not_in_risk_db (looked up, nothing there) or unqueryable_bad_length (cannot hit — validate the input instead of letting it through). |
A batch answers count, billable, hit_count and
results[] in input order; each item has the single-lookup shape, and an
unparseable one carries only query.input, hit: false and
error.
Quota headers and errors
X-Billable-Units is what this response cost; nothing is charged on non-200.
X-Daily-Limit / X-Daily-Remaining / X-Daily-Reset and
X-RateLimit-Limit / X-RateLimit-Remaining are this capability's own numbers,
in phone numbers (X-Quota-Unit: phone_number), independent of the IP quota.
A batch is all-or-nothing: if the remaining quota cannot hold it, the whole batch is refused
with 429 quota_exceeded and nothing is charged; a batch larger than the per-minute
allowance gets 429 batch_over_rate_limit and will never pass — split it.
GET /v1/me shows capabilities.phone with
used_today, refunded_today and remaining.
| HTTP | code | Meaning |
| 400 | bad_request / bad_number | Malformed body or wrong field types / the number cannot be parsed. |
| 401 | key_required / invalid_key | No key, or an invalid one. |
| 403 | capability_not_eligible / capability_not_enabled | The account's plan cannot have this capability / this key does not have it enabled. |
| 413 | payload_too_large | More than 1000 numbers, or a body over 1 MiB. |
| 429 | rate_limited / quota_exceeded / batch_over_rate_limit | Per-minute limit / daily quota exhausted / batch larger than the per-minute allowance. Carries Retry-After. |
| 502 | upstream_unavailable | The profile database is temporarily unreachable; Retry-After: 5; not billed. |
| 503 | phone_disabled / quota_unavailable | Capability switched off / billing backend not ready; retry shortly. |
Errors
JSON on every path, including 4xx and 5xx. Branch on error.code
(a stable lowercase identifier), never on error.message (English prose,
may change). 429 responses carry Retry-After.
{"error":{"code":"invalid_ip","message":"not a valid IPv4/IPv6 address"}}