中文

IP99 API

Give it an IP address and it returns the risk verdict for that address right now, plus its network and geolocation. Both endpoints share one authentication scheme, one quota model and one error format; every response is JSON, and one address has exactly one score.

1. About this reference#

This is the complete reference for the IP99 IP risk and geolocation API. Every endpoint is HTTP GET, requests and responses are UTF-8 JSON, and place names, network types and risk types come back as standard codes or stable English slugs — never localised text — so a response can be stored and aggregated as-is.

ItemDetails
Service base URLhttps://ip99.com
TransportHTTPS; requests and responses are UTF-8 JSON
AuthenticationThe basic endpoint needs no key; the others take a key in a request header — see 3.1 Authentication
QuotaPer calendar day, resetting at 00:00 Beijing time. The limit and the remaining amount come back in response headers — see 3.2 Quota and response headers
ErrorsOne shape everywhere: {"error":{"code","message"}}. Branch on error.code, never on error.message (that is prose for humans and its wording may change). Every code is in 10. Error codes
Machine-readableGET /openapi.json — OpenAPI 3.1, CORS enabled, importable into Postman, a code generator or a GPT action
FreshnessThe score measures the freshness and strength of verifiable evidence and decays as that evidence ages, so the same IP queried hours apart giving different scores is expected. A verdict with no timestamp cannot be acted on

Both endpoints render risk with the same code, so its shape is identical: risk has exactly three keys — score, level and signals.

2. Choosing an endpoint#

The Pulse professional endpoint is an upgraded view of the basic endpoint, not a second API. Both read the same data and run the same judgement, and one address gets one score from one source: for "how dangerous is this IP right now", either endpoint will do; for "why was it judged that way at the time", only Pulse can answer.

One IP, one score. There is no "basic score" and "professional score". The difference is how much evidence you can see and whether you can ask about a past moment — not the conclusion.

2.1 The one-line pick#

Your questionWhich endpoint
Does this IP carry risk right now, and where is itBasic GET /v1/ip/{ip} — free, no key, every field in one call
Why was it judged that way, which pieces of evidence matched, how fresh is eachPulse GET /pulse/v1/ip/{ip} — professional tier
Reproduce the verdict at a past momentPulse with ?at= — professional tier, and history enabled separately
Typical useBasic for real-time risk control, sign-up, sign-in and pre-order checks; Pulse for after-the-fact review, reconciliation and strategy back-testing

2.2 How the two differ#

GET /v1/ip/{ip}GET /pulse/v1/ip/{ip}
Who can call itAnyone. It works without a keyProfessional tier only. Anonymous callers and keys below that tier get 403 professional_required
Risk scorerisk.scoreAlso present, from the same source and with the same value
Risk levelrisk.levelAlso present, same values
Risk tagsNot listed inside risk; the matched types are in risk.signals (slugs only, no capture time)Also not listed inside risk; the per-item detail sits on the evidence rows: evidence[].risk_type, with freshness on the same row
Signalsrisk.signals, an array of matches only, sorted alphabetically; the key is absent when nothing matchedSame shape, same renderer as the basic endpoint, with identical values
Network and geolocationnetwork + geoAlso present, same shape and values
Per-item evidenceNot returnedevidence[], the type and freshness of each item
Sharing tierNot returnedshare_tier, from T0 to T3
Resource poolsNot returnedresource_pools, present when the address matches a pool
Historical momentNot supported; it only answers for now. Passing at returns 400 at_unsupportedSupports ?at=, back 30 days
Degraded markerA body field unique to the basic endpoint: degraded (present only when set)Not in the body; it is a response header, X-Pulse-Degraded
Typical useReal-time risk control, sign-up, sign-in, pre-order checks — one call returns everything you needAfter-the-fact review, reconciliation, strategy back-testing — "why was it judged this way"

2.3 Who can call it#

CallerGET /v1/ip/{ip}GET /pulse/v1/ip/{ip}
AnonymousYes, within the per-IP allowanceNo — 403 professional_required
Free account keyYesNo — 403 professional_required
Professional keyYesYes, including ?at= once history is enabled
Verified answer enginesYes, with their own allowanceNo — see 12. Answer engines
Field in evidence[]standardprofessionalNotes
evidence[].risk_typenot returnedalwaysThe type of that item — the only carrier of a per-item risk tag
evidence[].freshnessnot returnedalwaysHow recently it was captured
evidence[].captured_atnot returnedwhen knownAn optional field that appears only when there is a value; read it defensively and do not assume it is always there
evidence[].age_minnot returnedwhen knownSame condition as captured_at — the two appear together
evidence[].obs_daysnot returnedalwaysEvery professional evidence row carries this key

obs_days is the odd one out: it is always present — every evidence row on the professional and self-hosted tiers carries this key — while captured_at and age_min appear only when there is a value, and a missing key means that item does not have that piece of data.

3. Authentication and quota#

3.1 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.

EndpointKey
GET /v1/ip/{ip}Optional. Without one the request counts against the visitor IP; with one it counts against the account, and the response fields are identical either way
GET /pulse/v1/ip/{ip}Required, and it must be a professional key
GET /v1/meRequired. Returns the current tier and the remaining quota, so you can read your limits without spending a lookup

3.2 Quota and response headers#

CallerCounted againstPer minutePer day
AnonymousCaller IP (X-Quota-Scope: ip)60 per minute (1 QPS)500 per day
With a keyThat key (X-Quota-Scope: key) or the account300 per minute (5 QPS) by default for self-created keys; enterprise keys as contractedPer account / contract — read the response headers
HeaderMeaning
X-Daily-LimitThe daily allowance for this caller
X-Daily-RemainingHow much is left today
X-RateLimit-LimitThe per-minute allowance actually in force for this caller
X-Quota-Scopeip, key or user — what this request was counted against
X-Data-TierThe data tier of this response: standard or professional
X-PlanThe account plan: free, pro or enterprise
Retry-AfterSeconds to wait, sent with every 429

Exceeding the per-minute limit returns 429 rate_limited; exhausting the daily quota returns 429 quota_exceeded and, for anonymous callers, quota_exceeded_anon — signing in and creating a key raises the limit immediately. Query GET /v1/me to read the current tier and remaining quota without spending a lookup.

Note: quota for keyed calls follows the account and the contract. Read X-Daily-Limit / X-RateLimit-Limit from the response, or ask GET /v1/me.

3.3 Data tier, plan and capability#

ConceptExample valuesWhat it decidesWhere to read it
Data tierstandard / professionalWhich fields appear in the response (including whether evidence[].captured_at / age_min are there)Response header X-Data-Tier
Account planfree / pro / enterpriseQuotaResponse header X-Plan
Capability switchhistory enabled separatelyWhether ?at= works; without it you get 403 history_requires_professionalThe call itself tells you

Note: tier and plan do not imply each other — a higher plan does not hand you the professional fields, and a professional data tier does not enable history. For a higher volume or a professional capability, use the contact link in the footer.

4. Basic endpoint#

GET https://ip99.com/v1/ip/{ip}

Give it an IP address and it returns the risk verdict for that address right now, plus its network and geolocation. The key is optional: the call works without one, and with one it counts against the account quota.

4.1 Request#

ItemDetails
URLhttps://ip99.com/v1/ip/{ip}
MethodGET
AuthenticationNone required. With a key the call counts against the account quota and the returned fields are exactly the same
Daily quotaAnonymous calls count against the caller IP; signed-in or keyed calls count against the account / key. The number is in the X-Daily-Limit response header, or ask GET /v1/me (see 3.2 Quota and response headers)
Per-minute limitAnonymous 60 per minute; self-created keys 300 per minute by default; enterprise keys as contracted. The X-RateLimit-Limit header is authoritative; exceeding it returns 429 rate_limited
CORSSends Access-Control-Allow-Origin: * and answers OPTIONS preflight, so it can be called straight from a browser; the free allowance then counts against the visitor's own IP

Note: the per-minute ceiling differs between anonymous and keyed calls (see 3.2 Quota and response headers).

4.2 Request example#

No registration and no key application needed — call it directly:

Request: no key
curl https://ip99.com/v1/ip/8.8.8.8
Request: with a key
curl -H "X-API-Key: YOUR_KEY" \
  https://ip99.com/v1/ip/8.8.8.8

4.3 Request parameters#

ParameterTypeRequiredDetails
ipstringyes (path)An IPv4 or IPv6 address. Private and reserved addresses return 400 reserved_ip; a malformed address returns 400 invalid_ip. This endpoint does not accept at — passing it returns 400 at_unsupported.

4.4 Response example#

A real call: 86.111.136.23, currently judged a high-risk proxy egress. Field names and values match production; the key order is arranged for reading, and since a JSON object is unordered you should read by name. The risk block is the same shape on both endpoints — one piece of code renders it, with exactly the three keys score, level and signals, so one parser covers both.

Response 200
{
  "ip": "86.111.136.23",
  "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"
  }
}

Note: the slugs that can appear in risk.signals are listed in 8. Risk signals, and their meanings in 9. Risk tags.

4.5 Response fields#

Both endpoints render risk with the same code, so its shape is literally identical: risk has exactly three keys — score, level and signals.

This table lists every field the basic endpoint returns. Four blocks — risk.signals, network, geo and degraded — are present only when they have something to say: risk.signals is absent when no signal matched (in which case risk carries score and level alone), and for the other three an absent key means we have no such data.

FieldTypeMeaningDescription
ipstringEchoes the address you asked aboutMatches the {ip} in the request path, so a response can be tied back to its request.
computed_atstringThe moment this verdict belongs toRFC 3339 with a Z. The score decays as evidence ages, so this timestamp is part of what makes the conclusion checkable.
evidence_statestringEvidence state
  • active: fresh evidence, and it is scoring
  • stale: seen before, but the evidence has expired (the score has decayed to 0)
  • none: never seen
Branch on this field, not on the score alone.
risk.scoreintegerRisk score, 0–100Measures the freshness and strength of verifiable evidence — it is not a probability that the address is an attacker. How to read it: 7. Reading the score.
risk.levelstringThe level derived from the scorenone / low / medium / high, always derived from score; the bands are in 7.1.
risk.signalsarray<string>Matched signal slugsMatches only, sorted alphabetically; the key is absent when nothing matched. It carries slugs and no capture time. Values and meanings: 8. Risk signals.
networkobjectWhere the address sits on the networkAutonomous system number and usage type. Operator names are never returned — the machine identity of that information is network.asn, and looking it up in a public registry is more reliable than us forwarding a translated name. Field by field: 6. network and geo, field by field.
geoobjectWhere the address isContinent code, country code, administrative area, coordinates and time zone. The key set is constant for every address: continent / country / region / city / district / tz are always present; a name the database does not have is an empty string, not 0. region and city carry values for addresses in every country; district carries a value for addresses in China only — the English-language place-name database covers China down to the county/district level and other countries down to the city. Field by field: 6. network and geo, field by field.
degradedbooleanA degraded marker unique to the basic endpointPresent, with the value true, only when part of the data was unreachable and we still produced a score. Treat the answer as discounted when you see it. (Pulse has no such body field; its degraded marker is the X-Pulse-Degraded response header.)

5. Pulse professional endpoint#

GET https://ip99.com/pulse/v1/ip/{ip}

The upgraded view of the basic endpoint: it returns the same score from the same source, lays the evidence out item by item, and can reproduce the verdict as it stood at a past moment.

This endpoint needs a professional key. Anonymous calls, or a key below that tier, return 403 professional_required and do not consume quota. To look up just the risk score of an IP, the free /v1/ip is enough.

5.1 Request#

ItemDetails
URLhttps://ip99.com/pulse/v1/ip/{ip}
MethodGET
AuthenticationA professional key is required: X-API-Key or Authorization: Bearer
Daily quotaShares one daily quota system with the basic endpoint; the number is in the X-Daily-Limit response header (see 3.2 Quota and response headers). A 403 professional_required consumes nothing
Per-minute limitSelf-created keys 300 per minute by default; enterprise keys as contracted. The X-RateLimit-Limit header is authoritative; exceeding it returns 429 rate_limited
Historical moment?at= reaches back 30 days; it needs the professional tier and history enabled separately. A historical verdict is marked in the X-Pulse-Basis: history response header

Note: the per-minute limit applies per key and can differ between keys; the X-RateLimit-Limit header is authoritative.

5.2 Request example#

Request: no at, right now
curl -H "X-API-Key: YOUR_KEY" \
  "https://ip99.com/pulse/v1/ip/86.111.136.23"
Request: with at=, a past moment
curl -H "X-API-Key: YOUR_KEY" \
  "https://ip99.com/pulse/v1/ip/86.111.136.23?at=2026-09-14T09:29:23Z"

5.3 Request parameters#

ParameterTypeRequiredDetails
ipstringyes (path)An IPv4 or IPv6 address.
atstringno (query)An RFC 3339 moment, for example 2026-09-11T09:31:46Z. It reaches back 30 days and needs the professional tier or above. With or without it, the returned fields, key names and shape are exactly the same; only what computed_at means changes — omit it for now, pass it for the moment you name (see 5.7 One set of fields, one moment apart). Only this endpoint accepts at; passing it to /v1/ip returns 400 at_unsupported.

Note: a time-zone offset must have its + escaped as %2B — a bare + in a URL means a space, and the server rejects it as a format error with invalid_time. A malformed or future moment returns 400 invalid_time; a moment before the reachable window returns 400 at_out_of_window with window_from in the body, telling you the earliest moment you can ask about, so you do not have to probe.

5.4 Response example#

No at: right now#

A real response for 86.111.136.23 (captured 2026-09-16): risk.score, risk.level, risk.signals, network and geo have exactly the field names and values as on /v1/ip — asked at the same moment, the two endpoints give the same numbers, down to the shape of the risk block (score / level / signals). Per-item risk tags live only on the evidence rows, in evidence[].risk_type; the basic endpoint does not return that layer.

What is extra here is share_tier, evidence and resource_pools; their meanings are in the field dictionary below. This response has nine top-level keys, and the next one has exactly the same nine.

Response 200
{
  "ip": "86.111.136.23",
  "risk": {
    "score": 77,
    "level": "high",
    "signals": ["proxy"]
  },
  "share_tier": "T1",
  "evidence": [
    {
      "risk_type": "proxy",
      "freshness": "within_12h",
      "captured_at": "2026-09-15T22:43:23Z",
      "age_min": 277.12,
      "obs_days": 14
    }
  ],
  "evidence_state": "active",
  "resource_pools": ["cl-1c004db96b"],
  "computed_at": "2026-09-16T03:20:30Z",
  "network": { "asn": 15547, "usage_type": "DYN" },
  "geo": {
    "continent": "EU", "country": "CH",
    "region": "Valais", "city": "Sitten",
    "lat": 46.22739, "lon": 7.35559,
    "tz": "Europe/Zurich"
  }
}

With at=: a past moment#

The same address with the moment dialled back to 09-14 09:29:23Z: the fields, key names and shape match the response above item for item, down to the resource_pools handle. Only computed_at and the quantities computed relative to it move: the score is 90 (77 now), obs_days is 15 (14 now), and captured_at and age_min follow the evidence as it stood at that moment.

network and geo are exactly the same as above — they have no historical version, and both ways of asking return the current value. risk.signals is not one of them: in both modes it is folded from the risk types that actually made it into evidence[] for this call, so the two correspond item for item (a signal with no evidence row never appears).

Only the meaning of computed_at changes. Apart from network and geo, which are current values, every other field — including risk.signals and evidence[].risk_type — is rebuilt for the moment you named; today's conclusion is never substituted for it.

Response 200
{
  "ip": "86.111.136.23",
  "risk": {
    "score": 90,
    "level": "high",
    "signals": ["proxy"]
  },
  "share_tier": "T1",
  "evidence": [
    {
      "risk_type": "proxy",
      "freshness": "within_2h",
      "captured_at": "2026-09-14T08:32:51Z",
      "age_min": 56.53,
      "obs_days": 15
    }
  ],
  "evidence_state": "active",
  "resource_pools": ["cl-1c004db96b"],
  "computed_at": "2026-09-14T09:29:23Z",
  "network": { "asn": 15547, "usage_type": "DYN" },
  "geo": {
    "continent": "EU", "country": "CH",
    "region": "Valais", "city": "Sitten",
    "lat": 46.22739, "lon": 7.35559,
    "tz": "Europe/Zurich"
  }
}

5.5 Response fields#

The first few entries are the same fields, with the same values, as the basic /v1/ip (the risk block is even the same shape); the rest are unique to Pulse. All of them are present with or without at=, and their meaning differs in exactly one place: computed_at, and the quantities computed relative to it.

FieldTypeMeaningDescription
ipstringEchoes the address you asked aboutMatches the {ip} in the request path.
risk.score / risk.levelinteger / stringRisk score and levelThe same data as risk on /v1/ip — the same names, the same shape, the same values.
risk.signalsarray<string>Matched signal slugsMatches only, sorted alphabetically; the key is absent when nothing matched. Folded from the risk types that actually made it into evidence[] for this call, so the two correspond item for item in both modes (with no at= and with it); a signal that has no evidence row never appears. The one exception is hosting / mobile — network properties, which have no evidence row at all. Values and meanings: 8. Risk signals. The basic endpoint has exactly the same shape.
evidencearray<object>Evidence, item by itemOrdered strongest first. An empty array when there is no evidence. Fields in the next table.
evidence_statestringEvidence stateAs on /v1/ip: active / stale / none. Judged as of the moment in computed_at.
share_tierstringSharing tierT0 to T3, deciding how wide a block would reach; meanings and suggested handling in 7.2 Sharing tier. It also feeds the score: the more people share an address, the harder old evidence decays.
resource_poolsarray<string>Resource pool identifiersPresent only when the address matches a pool. Stable within one account and not comparable across accounts. Its use is turning "these IPs belong together" into a local rule; it carries no other meaning.
computed_atstringThe moment this verdict belongs toRFC 3339. This is also the only difference between the two ways of asking: without at it is now, with ?at= it is the moment you named. Apart from network and geo, every field is interpreted relative to this moment.
network / geoobjectWhere the address sitsThe same shape as /v1/ip: identical key names, presence conditions and values. Field by field: 6. network and geo, field by field (one dictionary covers both endpoints). These are current values and do not roll back with at= — neither block has a historical version, so both ways of asking return the location as of now. They are returned together so that "one report over a batch of IPs, with a region dimension" is a single pass: no need to ask Pulse for the score and then ask the basic endpoint again for the location.

A degraded verdict is not written in the body; it is written in a response header. When one evidence source is unreachable for this call and we still produce a score, the response carries X-Pulse-Degraded: 1. Treat the result as discounted when you see it rather than as a normal response; the body of this endpoint has no field that indicates degradation.

5.6 Inside each evidence[] row#

An evidence row has only these five keys: risk_type, freshness and obs_days are on every row, while captured_at and age_min appear only when there is a value — a missing key means that item does not have that piece of data.

FieldTypeMeaningDescription
risk_typestringThe risk type of this itemAlso the primary key of the row, and the only place a per-item risk tag is carried; the matched ones are duplicated in risk.signals. Every value is listed in 9. Risk tags.
freshnessstringFreshnessFour bands:
  • within_2h: captured within 2 hours
  • within_4h: captured within 4 hours
  • within_12h: captured within 12 hours
  • older: more than 12 hours since the last capture
captured_atstringThe exact capture time of this itemRFC 3339. On the professional and self-hosted tiers it is given whenever known; the standard tier does not return it. It is an optional field that appears only when there is a value — read it defensively and do not assume it is always there.
age_minnumberHow many minutes this item is from computed_atSame presence condition as captured_at; the two always appear together.
obs_daysintegerThe number of distinct days this item has been continuously reported, counted over a window of the 14 days before computed_at (as of that moment when at= is used).Always present: every evidence row on the professional and self-hosted tiers carries this key and never omits it.

obs_days is the odd one out: it is always present — every evidence row on the professional and self-hosted tiers carries this key — while captured_at and age_min appear only when there is a value, and a missing key means that item does not have that piece of data.

5.7 One set of fields, one moment apart#

With or without at, you get the same shape back. The same key names, the same nesting depth, the same set of fields — the two ways of asking are rendered by one piece of code, not two APIs. The only thing that changes is what computed_at means: without at it is now, with at= it is the moment you named.

Everything that is "relative to a moment" is interpreted relative to computed_at: risk.score, risk.level, share_tier, evidence_state, resource_pools, and freshness, captured_at, age_min and obs_days inside evidence[]. The exception is risk.signals, network and geo: they have no historical version and both ways of asking return the current value — so when you look back at a moment, risk.signals may list a signal that was not yet in evidence at that moment.

No at (now)With at= (a past moment)
Answers aboutthe state nowthe state at the moment you named
computed_atnowthe moment you named, echoed back
Response header X-Pulse-Basislivehistory
Tier requiredprofessionalprofessional, and history enabled separately; without it you get 403 history_requires_professional

Asking "what was the score at that moment" must produce the answer for that moment, so the only two outcomes here are a normal response and an explicit error — today's result is never returned as if it were that moment's. Every code is in 10. Error codes.

6. network and geo, field by field#

Both blocks follow the same rule as risk.signals: a key is present only when we have a value for it. A key that is unknown is left out, and the whole block is left out when nothing in it is known — 0, an empty string or null is never sent as a placeholder. So a missing key means exactly one thing: unknown. When you group by these fields, treat "key absent" and "value is 0" as two different facts.

FieldTypePresent whenValuesWhen unknownExample
network.asnintegeran autonomous system number is known (asn > 0) Autonomous system number. Operator and organisation names are not returned; look the ASN up in a public registry. The ASN is the identity of this information. key absent — 0 is never returned"asn": 15547
network.usage_typestringthe scene database covers this address block Usage type, passed through unchanged — we do not rewrite it. The 20 scene codes we have a localised name for: ANY anycast network · CDN content delivery · DNS DNS service · EDU education network · GTW enterprise gateway / NAT egress · GOV government · DYN residential broadband · IDC datacenter · IXP internet exchange · MOB mobile network · NET network infrastructure · ORG organisation · SAT satellite · BOGON reserved / bogon space · AUR allocated but unrouted · RNU routed but unused · UND unallocated · WLA WLAN hotspot · DDT dedicated egress · USE in use. That table is not the set of all possible values: a code outside it is not an error — it is a code we have no localised name for yet. It is returned as-is: treat an unrecognised code as unknown rather than dropping the record. key absent"usage_type": "DYN"
geo.continentstringalways present Two-letter continent code, a closed set: AS Asia · EU Europe · NA North America · SA South America · AF Africa · OC Oceania · AN Antarctica. empty string (a few segments carry no continent)"continent": "EU"
geo.countrystringalways present ISO 3166-1 alpha-2 country / region code. We return the code only; look up the display name in the standard table. empty string (no country information)"country": "CH"
geo.regionstringalways present Latin name of the first-level administrative area. Returned for addresses in every country, China included — for an address in China, for example Shanghai. empty string"region": "Valais"
geo.citystringalways present Latin name of the city. Returned for addresses in every country, China included. empty string"city": "Sitten"
geo.districtstringalways present Latin name of the district or county, finer than the city and coarser than the street. Non-empty for addresses in China only: the English-language place-name database covers China down to the county/district level and other countries down to the city. empty string (always empty outside China)"district": "Xuhui"
geo.lat / geo.lonnumberas a pair — both coordinates are known Decimal degrees, rounded to at most 5 decimal places (about a metre — IP geolocation is not more precise than that, and finer digits would be floating-point noise rather than location). When the geolocation database has no coordinates both are 0, and 0,0 is a real point in the Gulf of Guinea rather than "unknown", so in that case neither key is sent. both keys absent together — 0,0 is never returned "lat": 46.22739
"lon": 7.35559
geo.tzstringthe address is in the geolocation database IANA time zone name.key absent"tz": "Europe/Zurich"

Note: region and city come from an English-language place-name database and are returned for addresses in every country, China included; district comes from the same database but carries a value for addresses in China only, because that database covers China down to the county/district level and other countries down to the city. The key set of geo is constant for every address the query resolves: continent / country / region / city / district / tz are always present — a name the database does not have is an empty string, never a 0 or a missing key — so one parser handles every row the same way, no matter the country (an address in China gets, for example, Shanghai / Shanghai / Xuhui, and an address elsewhere an empty district). Operator names are not returned for the same reason: in the geolocation database they are localised names (some non-Chinese rows are Chinese too), so publishing them for part of the rows would quietly distort any carrier-level statistic.

7. Reading the score#

risk.score (0–100) measures the freshness and strength of verifiable evidence, decaying with the age of that evidence.

It is not a probability that the address is an attacker. Using a 0–100 score directly as a probability puts your threshold in the wrong place.

The biggest difference from a traditional IP intelligence database: what we hand you is a verdict that expires, not a label that stays true. Whether an address that was a dial-up pool last week still is today depends on how old the evidence is. So the same IP giving different scores hours apart is expected.

7.1 Score and level#

ScoreLevel risk.levelMeaningSuggested handling
0noneWe hold no current verifiable evidence — not that the address is safe. Two very different reasons: the evidence expired (evidence_state: stale), or we have never seen it (none).Do not mark it "clean" after letting it through. Look at evidence_state first: stale is only a weak signal, none is no information at all
1–29lowEvidence exists, but it is low in strength or freshness.Safe to let through; record and watch it, but do not block on this alone
30–69mediumModerate evidence — usually captured a while ago, or of middling strength.Verify rather than ban outright: CAPTCHA, a second factor, rate limiting, a lower weight
70–100highStrong or very fresh evidence.You may block if your business case justifies it; before banning, check share_tier for the blast radius (see 7.2). A hijacked_proxy address should not be banned outright

Note: the score-to-level mapping above is the API contract (risk.level is always derived from score and a caller cannot supply its own). The handling column is general advice, not a contractual promise — the same score deserves different answers on sign-in, checkout and sign-up, so set your final thresholds against your own business, cost and risk appetite. Also note that the owner of a hijacked_proxy (hijacked shared proxy) address is the victim, so banning the account is usually the wrong response.

7.2 Sharing tier#

Pulse tells you roughly how many people share the address, which decides how wide a block would reach: the smaller the number, the fewer people you hurt. It also feeds the score — the more people share an address, the harder old evidence decays, so a T3 score is naturally more conservative than a T0 one.

ValueRoughly how many share itTypical networkSuggested handling
T0about 1–10Exclusive address, datacenter egressSafe to block; the smallest blast radius
T1about 1–20Residential broadband NATSafe to block, knowing you may hit one household
T2about 50–2000Corporate leased line, campus egressCareful: blocking one blocks a building
T3about 1000–100000+Mobile carrier CGNATDo not block the IP; use a CAPTCHA, rate limiting or a lower weight instead

Note: share_tier is only returned by Pulse; the basic endpoint does not have it, and there you can gauge the network shape from network.usage_type.

7.3 Triage order#

The sections above list what each field means; this one says what order to read them in — the score is the easiest field to misuse, so do not start there.

  1. Read evidence_state first: it answers "can this conclusion be trusted". When it is none the score is always 0, but that means "never seen", not "clean".
  2. Then read risk.score and risk.level. They only carry content when the state is active or stale.
  3. Then look at which risk types matched — that decides the response: a proxy, a dial-up pool and a cloud phone mean different things and draw different user appeals. On both endpoints start from risk.signals (matches only); for the freshness of each item, go on to evidence[].risk_type on Pulse.
  4. To decide how wide to block, read share_tier (Pulse only). The same score should not be handled the same way on an exclusive IP and on a large mobile NAT.
  5. Finally check the degraded marker: degraded is a body field unique to the basic endpoint (present only when set), while Pulse uses the X-Pulse-Degraded response header. When it appears, discount the result rather than treating it as normal.

A score of 0 does not mean safe. It has two completely different causes: the evidence expired (stale), or we have never seen it (none). The first is only a weak reference; the second is no information.

Branch on evidence_state; do not wave traffic through on the score alone.

8. Risk signals#

There are exactly ten slugs that can appear in risk.signals. Matches only, sorted alphabetically; when nothing matched there is no signals key inside risk at all. Eight of them are derived from a risk tag; the other two describe what kind of network the address sits on and are not risk.

Those eight correspond item for item with evidence[].risk_type in the same response. Every risk tag you see in risk.signals has its row in evidence[]; the reverse need not hold, because evidence[] also carries types from our own collection (see 9. Risk tags). That holds with at= and without it: risk.signals is folded from the risk types that actually made it into evidence[] for this call, not copied from the profile's tag list. The only exception is hosting / mobile — network properties, which have no evidence row; see the second note below.

SignalWhat it means in risk.signalsRisk tag it comes fromSource
proxyThis address is currently used as a proxy egress.proxy proxyrisk tag
vpnA static egress of a commercial VPN.vpn VPNrisk tag
dialup_poolA dial-up pool: one address redialled over and over to rotate IPs.dialup_pool dial-up poolrisk tag
hijacked_proxyAn address hijacked by malware and used as a proxy. The owner is the victim, not the attacker, so banning the account is usually the wrong response.hijacked_proxy hijacked shared proxyrisk tag
osintListed by a public intelligence source.osint open-source intelligencerisk tag
crawlerA search-engine crawler egress. We treat it as benign: it does not score and never becomes a scored piece of evidence — and having no evidence row, it never appears in risk.signals.— (benign, produces no evidence row)risk tag
cloud_serviceA cloud function / serverless egress.cloud_service cloud servicerisk tag
cloud_phoneA cloud phone (a handset device sitting in a datacenter).cloud_phone cloud phonerisk tag
hostingThe address is in a datacenter, on a CDN or on anycast. This states a network property, not something the address did.no corresponding tagderived from usage type
mobileThe address is on a mobile cellular network. Also a network property.no corresponding tagderived from usage type

Do not use the last two signals as risk. For example, 8.8.8.8 has hosting among its risk.signals while its risk.score is 0. Blocking traffic on hosting or mobile takes out whole public clouds and whole mobile carriers.

crawler is a benign tag: it brings no score, does not take part in scoring, and produces no evidence row — so it appears in neither risk.signals nor evidence[]. When an address carries only crawler tags, both fields are empty, and that is the correct answer.

The only signals without a counterpart row are hosting and mobile: they are network properties and have no evidence row at all (evidence[] never carries a hosting item), so both ways of asking return the current value, exactly like network and geo. The eight risk-tag signals have no such exception: with at= they are rebuilt for that moment.

9. Risk tags#

A tag is a fixed identifier, not a human-readable name, so here is the mapping. Both endpoints use one set of spellings: what risk.signals lists is the slugs in the first column, and evidence[].risk_type on Pulse draws on the same batch.

ValueNameWhat it isMatching signal
proxyProxyUsed as a proxy egress; mostly sold proxy inventory.proxy
dialup_poolDial-up poolOne address redialled to rotate IPs.dialup_pool
vpnVPNA static egress of a commercial VPN.vpn
hijacked_proxyHijacked shared proxyA normal user's device hijacked by malware; the owner is the victim.hijacked_proxy
osintOpen-source intelligenceListed by a public intelligence source.osint
crawlerSearch-engine crawlerA search engine's crawl egress. Benign, does not score, and produces no evidence row.— (a benign tag never enters risk.signals)
cloud_serviceCloud serviceA cloud function / serverless egress.cloud_service
cloud_phoneCloud phoneA handset device in a datacenter.cloud_phone
honeypot_scanHoneypot scannerAppears only in evidence[].risk_type, never in risk.signals.—
honeypot_credentialHoneypot credential stuffingAppears only in evidence[].risk_type.—
ipv6_rangeIPv6 rangeAppears only in evidence[].risk_type.—

evidence[].risk_type has a wider range than risk.signals. Pulse evidence can also carry honeypot_scan (honeypot scanner), honeypot_credential (honeypot credential stuffing) and ipv6_range (IPv6 range). Those three never appear in risk.signals.

One risk type is spelled the same way wherever it appears. "Hijacked shared proxy" is always spelled hijacked_proxy, in risk.signals and in evidence[].risk_type alike, so the two can be compared directly with no mapping step.

crawler is a benign tag: it brings no score, does not take part in scoring, and produces no evidence row, so risk.signals never lists it.

10. Error codes#

Every error has one structure, so it can be handled uniformly. Branch on error.code, never on error.message — the message is prose for humans and its wording may change.

Response 400
{
  "error": {
    "code": "invalid_ip",
    "message": "not a valid IPv4/IPv6 address: …"
  }
}

10.1 General#

HTTPcodeMeaningRetry
400invalid_ipNot a valid IP address.No. Fix the parameter and resend
400reserved_ipA private or reserved address; there is no public risk profile for it.No
400key_in_queryThe key was passed in the query string; use a request header instead.No. Resend with X-API-Key
400at_unsupportedThe basic /v1/ip does not accept the at parameter.No. Drop at, or call Pulse instead
401invalid_keyThe API key is not valid.No. Check it or issue a new one
401revoked_keyThe API key has been revoked.No. A new key is needed
401key_expiredThe API key has expired.No. A new key is needed
403professional_requiredThis endpoint needs the professional tier and the current one is below it. Consumes no quota.No. The professional tier must be enabled
404not_foundNo such endpoint path.No. Check the path
429rate_limitedOver the per-minute rate; retry later.Yes. Back off by Retry-After and retry
429quota_exceededThe daily quota is used up; the response carries Retry-After.Yes. Retry after Retry-After; the quota cannot be raised for today
429quota_exceeded_anonThe anonymous quota is used up — sign in and create a key to raise it.Yes. Signing in / creating a key restores it immediately
429key_quota_exceededThis key's own daily quota is used up (appears when quota is counted per key; check the X-Quota-Scope: key header).Yes. Use the account quota, or retry tomorrow

10.2 ?at=-only error codes#

These codes do not mean the same thing as each other, so do not fold them into one branch: the code tells you what to do — fix the parameter, enable the tier, or retry later.

HTTPcodeMeaningRetry
400invalid_timeNot an RFC 3339 moment (a + in the time-zone offset must be written %2B), or a moment in the future.No. Fix the moment and resend
400at_out_of_windowEarlier than the earliest moment this service can answer for; the body carries window_from.No. Adjust the moment per window_from
400history_not_enabledHistory is not enabled on this access point — a configuration state, not a fault.No. It must be enabled first
400history_no_ipv6History indexes IPv4 only, so this address has no history.No
403history_requires_professionalHistory needs the professional tier or above.No. The professional tier must be enabled
503history_unavailableThe history data is temporarily unreadable; carries Retry-After: 10 and can be retried.Yes. Retry after Retry-After

Note: the "malformed parameter, outside the window, no history at this access point" cases consume no quota: the request is rejected before a score is computed and before any data is read.

11. DEMO#

The smallest usable call in three languages, one block each. All three hit only the free endpoint GET /v1/ip/{ip}, use nothing but the language's standard library, and need no key and no third-party dependency — drop in your own IP and run it.

11.1 curl#

Free endpoint, no key needed
curl -s "https://ip99.com/v1/ip/8.8.8.8"

Note: to query Pulse at a past moment, use the line below instead (needs a professional key):

Needs a key (placeholder form)
curl -s -H "X-API-Key: $IP99_API_KEY" "https://ip99.com/pulse/v1/ip/86.111.136.23?at=2026-09-14T09:29:23Z"

Response example (the look-back at 2026-09-14T09:29:23Z)#

The real response to that ?at= command: HTTP 200, response headers X-Data-Tier: professional, X-Pulse-Basis: history, X-Quota-Scope: user, body 505 bytes, with no digit or string altered. It is pretty-printed below so it can be read; Copy gives the compact 505-byte body exactly as it came off the wire.

Response 200
{
  "ip": "86.111.136.23",
  "risk": { "score": 90, "level": "high", "signals": ["proxy"] },
  "share_tier": "T1",
  "evidence": [
    {
      "risk_type": "proxy",
      "freshness": "within_2h",
      "captured_at": "2026-09-14T08:32:51Z",
      "age_min": 56.53,
      "obs_days": 15
    }
  ],
  "evidence_state": "active",
  "resource_pools": ["cl-1c004db96b"],
  "computed_at": "2026-09-14T09:29:23Z",
  "network": { "asn": 15547, "usage_type": "DYN" },
  "geo": { "continent": "EU", "country": "CH",
           "region": "Valais", "city": "Sitten",
           "lat": 46.22739, "lon": 7.35559,
           "tz": "Europe/Zurich" }
}

11.2 Python#

urllib from the standard library, no key needed
import json, urllib.request

def lookup(ip, key=None, at=None):
    path = "/v1/ip/" + ip
    if at:  # history: Pulse professional tier only, and it needs a key
        path = "/pulse/v1/ip/" + ip + "?at=" + at
    req = urllib.request.Request("https://ip99.com" + path)
    if key:
        req.add_header("X-API-Key", key)  # not needed for the free endpoint
    try:
        with urllib.request.urlopen(req, timeout=5) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        return json.load(e)  # {"error": {"code": ..., "message": ...}}

d = lookup("8.8.8.8")
print(d["ip"], d["risk"]["score"], d["risk"]["level"], d["risk"].get("signals"), d["evidence_state"])

11.3 Go#

net/http from the standard library, no key needed
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

func main() {
	// The free endpoint needs no key. With a key, add one line (required for Pulse):
	//   req.Header.Set("X-API-Key", key)
	req, _ := http.NewRequest("GET", "https://ip99.com/v1/ip/8.8.8.8", nil)
	resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	var out struct {
		IP            string `json:"ip"`
		EvidenceState string `json:"evidence_state"`
		Risk          struct {
			Score   int      `json:"score"`
			Level   string   `json:"level"`
			Signals []string `json:"signals"` // absent when nothing matched
		} `json:"risk"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		panic(err)
	}
	fmt.Println(out.IP, out.Risk.Score, out.Risk.Level, out.Risk.Signals, out.EvidenceState)
}

Note: on error the HTTP status is not 200 and the body is still {"error":{"code","message"}}. Read error.code first and then decide whether to retry (see 10. Error codes). The history branch is only available on the professional tier and only when history is enabled for that access point.

12. 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 the officially published prefix lists of each (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

This allowance covers the free GET /v1/ip/{ip} only. /pulse/v1/ip/{ip} requires the professional data tier, and there is no exception for answer engines.

13. MCP endpoint#

If your agent speaks the Model Context Protocol, call IP99 as a tool rather than scraping a URL:

Endpoint and tool
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.

13.1 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:

Tool result on a quota error
{"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.

13.2 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.

Client setup
# 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.

13.3 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:

Client configuration with a key
# 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.

14. FAQ#

14.1 How do I authenticate?#

Send the key as X-API-Key: YOUR_KEY or Authorization: Bearer YOUR_KEY. Never in the query string: ?key= is rejected with HTTP 400 key_in_query because URLs end up in access logs.

14.2 What are the limits without a key?#

500 lookups per day per client IP and 60 per minute, counted against the same quota as the free lookup on the website. Verified answer-engine crawlers get 10000 per day per crawler family. On exhaustion you get HTTP 429 with a stable error.code and a Retry-After header.

14.3 What does the response look like on an error?#

Always JSON, on every path including 4xx and 5xx: {"error":{"code":"...","message":"..."}}. Branch on error.code; the message is English prose and may change.

14.4 Is there an MCP endpoint?#

Yes. https://ip99.com/mcp exposes one read-only tool, ip_risk_lookup, over Streamable HTTP with no session state. Failures come back as HTTP 200 with isError: true and a JSON error body; signing in from the client is possible at https://ip99.com/en/mcp/account. See 13. MCP endpoint.

14.5 How fresh is the data?#

The risk database is rebuilt through the day and the geolocation database daily; every response carries computed_at, and the score decays with the age of the evidence behind it. Query at decision time rather than caching verdicts.