中文

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.

FieldMeaning
risk.levelnone (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_stateactive 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_semanticsStable identifier capture_evidence_freshness, restated in every response so a consumer cannot silently drift into treating the number as a probability.
source / source_urlWhere the data came from and where a human can verify it.

Endpoints

EndpointReturns
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/meYour current tier and remaining quota
POST /v1/phone/lookupPhone-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.

EndpointBodyBilled
POST /v1/phone/lookup{"number": "13800001234"}1 number
POST /v1/phone/batch{"numbers": [...]}, at most 1000; results keep the input orderevery parseable number, duplicates counted each time
GET /v1/phone/meta/tagsdictionary: label and description for every slugfree
GET /v1/phone/meta/coveragecoverage and update cadencefree

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"}}
}
FieldMeaning
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.
hitWhether the number is in the risk database. false does not mean safe — the database only holds numbers already captured.
evidence_stateactive: 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.level0–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.
carrierMainland 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_reasonOnly 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.

HTTPcodeMeaning
400bad_request / bad_numberMalformed body or wrong field types / the number cannot be parsed.
401key_required / invalid_keyNo key, or an invalid one.
403capability_not_eligible / capability_not_enabledThe account's plan cannot have this capability / this key does not have it enabled.
413payload_too_largeMore than 1000 numbers, or a body over 1 MiB.
429rate_limited / quota_exceeded / batch_over_rate_limitPer-minute limit / daily quota exhausted / batch larger than the per-minute allowance. Carries Retry-After.
502upstream_unavailableThe profile database is temporarily unreachable; Retry-After: 5; not billed.
503phone_disabled / quota_unavailableCapability 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"}}