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.
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#
2.2 How the two differ#
2.3 Who can call 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.
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.
3.2 Quota and response headers#
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#
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#
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#
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.
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#
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#
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.
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.
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.
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.
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#
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.
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.
- 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".
- Then read
risk.score and risk.level. They only carry content when the state is active or stale.
- 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.
- 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.
- 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.
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.
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#
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.
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.