API 接口文档
给一个 IP 地址,拿到它此刻的风险判断与网络、地理归属。两个接口共用同一套认证、额度与错误格式,响应一律是 JSON,同一个地址在两边只有一个分。
一. 文档说明#
本页是 IP99 IP 风险画像接口的完整参考。全部接口都是 HTTP GET,请求与响应都是 UTF-8 JSON,响应里的地名、网络类型、风险类型一律用标准码或固定英文标识,不含中文,可以直接入库统计。
| 项目 | 说明 |
|---|---|
| 服务基址 | https://ip99.com |
| 传输 | HTTPS,请求与响应均为 UTF-8 JSON |
| 认证 | 基础版不需要 Key;其余接口用请求头带 Key,见 3.1 认证 |
| 额度 | 按自然日(北京时间 00:00 重置),额度与当前余量随响应头返回,见 3.2 额度与响应头 |
| 错误 | 统一形状 {"error":{"code","message"}},请按 error.code 分支,不要按 error.message 判断(后者是给人读的说明,措辞可能调整)。全部错误码见 十. 错误码表 |
| 机器可读描述 | GET /openapi.json(OpenAPI 3.1,带 CORS,可导入 Postman、代码生成器或 GPT Actions) |
| 数据时效 | 分数衡量的是可核验证据的新鲜度与强度,会随证据变旧而下降,所以同一个 IP 隔几小时查两次分数不一样是正常的。没有时间戳的结论无法据以行动 |
两个接口的 risk 由同一段代码出,形态逐字相同:risk 只有 score、level、signals 三个键。
二. 两个接口怎么选#
Pulse 专业版接口是基础版接口的升级版,不是另一套接口。两者用同一份数据、同一套判定,同一个地址的分数同源同值:要「这个 IP 现在有多危险」,两个都够;要「当时为什么这么判」,只有 Pulse 答得出。
同一个 IP,本产品只有一个分。不存在「基础版一个分、专业版另一个分」。差别在证据的详细程度、能不能回溯历史,不在结论。
2.1 一句话选型#
| 你的问题 | 用哪个 |
|---|---|
| 这个 IP 现在有没有风险、归属在哪 | 基础版 GET /v1/ip/{ip},免费、不用 Key、一次调用拿全字段 |
| 当时为什么这么判、命中了哪几条证据、每条多新 | Pulse GET /pulse/v1/ip/{ip},需专业版 |
| 要复现某一历史时刻的判定 | Pulse 带 ?at=,需专业版且单独开通历史回溯 |
| 典型场景 | 基础版用于实时风控、注册登录、下单前校验;Pulse 用于事后复盘、对账、策略回溯 |
2.2 两个接口的差异#
| 对比项 | GET /v1/ip/{ip} | GET /pulse/v1/ip/{ip} |
|---|---|---|
| 谁能调用 | 所有人。不带 Key 也能查 | 只有专业版。匿名或版本不够返回 403 professional_required |
| 风险分数 | risk.score | 同样有,而且同源同值 |
| 风险等级 | risk.level | 同样有,取值也一样 |
| 风险标签 | 不在 risk 里单列;命中的类型看 risk.signals(只有 slug,没有捕获时刻) | 同样不在 risk 里单列;逐条明细在证据行上:evidence[].risk_type,新鲜度在同一行的 freshness |
| 信号 | risk.signals 数组:只列命中的,按字典序;一个都没命中时这个键不出现 | 与基础版同一形态、同一个渲染函数,取值逐个相同 |
| 网络与地理归属 | network + geo | 同样有,形状与取值都一样 |
| 逐条证据 | 不返回 | evidence[],每条证据的类型与新鲜度 |
| 共用度 | 不返回 | share_tier,从 T0 到 T3 |
| 资源池 | 不返回 | resource_pools,该地址命中资源池时才出现 |
| 历史回溯 | 不支持,只答此刻;带 at 返回 400 at_unsupported | 支持 ?at=,可回溯 30 天 |
| 降级标记 | body 专有字段 degraded(有才出) | body 里没有;降级写在响应头 X-Pulse-Degraded |
| 适合用在哪 | 实时风控、注册登录、下单前校验——一次调用就拿到要用的全部字段 | 事后复盘、对账、策略回溯——需要「当时为什么这么判」 |
2.3 谁能调用#
Pulse 是专业版能力。基础版 /v1/ip 对所有人开放,连 Key 都不用;Pulse 只对专业版开放。
| 版本 | 能拿到什么 |
|---|---|
| 公开(不需要 Key) | 基础版 /v1/ip 的全部字段:风险分、风险等级、命中的信号 risk.signals、网络与地理归属(另有基础版专有的降级标记 degraded)。不能调用 Pulse,会返回 403 professional_required。 |
| 专业版 | 开放 Pulse:逐条证据 evidence[]、共用度 share_tier、资源池 resource_pools,以及按 ?at= 回溯 30 天内的历史时刻。 |
版本不够时会明确报错,不会悄悄少给字段:调 Pulse 返回 403 professional_required;用 ?at= 返回 403 history_requires_professional。
证据行上的可选字段看档位
| 字段 | 标准版 | 专业版 / 私有化版 |
|---|---|---|
evidence[].captured_at | 不返回(标准版也调不了 Pulse) | 有值就给,是有值才出的可选字段,取值时按需读取,不要假设一定存在 |
evidence[].age_min | 不返回 | 同上,与 captured_at 同时出现 |
evidence[].obs_days | 不返回 | 固定出现:每条证据行都带这个键 |
三. 认证、额度与请求约束#
3.1 认证#
把 Key 放在请求头里,两种写法等价:X-API-Key: 你的KEY 或 Authorization: Bearer 你的KEY。
不要把 Key 写进 URL。查询串会被访问日志、浏览器历史和代理原样记下来,而 Key 是长期凭据。检测到 ?key= 时请求会被直接拒绝,返回 400 key_in_query,而不是照常处理。
curl -H "X-API-Key: 你的KEY" \
https://ip99.com/v1/ip/8.8.8.8
注:带不带 Key,返回的字段完全一样,区别只在额度。
3.2 额度与响应头#
额度按自然日执行,北京时间 00:00 重置。网页查询与 API 共用同一份每日额度:未登录时按访问来源的 IP 计,登录后按账户计。额度信息随响应头返回,不需要另外查询;当前版本与剩余额度也可以用 GET /v1/me 查。
| 调用方式 | 计在谁头上 | 每分钟上限 | 每日额度 |
|---|---|---|---|
| 未登录 / 不带 Key | 访问来源 IP(X-Quota-Scope: ip) | 60 / 分钟(即 1 QPS) | 500 / 天 |
| 带 Key | 这把 Key(X-Quota-Scope: key)或账户 | 自助创建的 Key 默认 300 / 分钟(5 QPS);企业 Key 按合同配置 | 按账户 / 合同配置,以响应头为准 |
注:上表是默认值,实际以响应头 X-RateLimit-Limit(每分钟上限)、X-Daily-Limit(每日额度)、X-Daily-Remaining(今日剩余)为准。
| 响应头 | 含义 |
|---|---|
X-Daily-Limit | 当前版本的每日额度。 |
X-Daily-Remaining | 今日剩余次数。 |
X-Daily-Reset | 额度重置时间(Unix 秒,北京时间零点)。 |
X-RateLimit-Limit / X-RateLimit-Remaining | 每分钟上限与本分钟剩余。 |
X-Data-Tier | 这次调用实际生效的数据档位(standard / professional)。响应里能拿到哪些字段由它决定。 |
X-Plan | 账户套餐(如 free / pro / enterprise)。档位与套餐是两件事:档位决定字段,套餐决定额度。 |
X-Quota-Scope | 这次额度记在谁头上:ip(未登录,按访问来源 IP 计)、user(按账户计)或 key(按这把 Key 计)。 |
X-Key-Daily-Limit / X-Key-Daily-Remaining | 额度按 Key 计时才有:这把 Key 自己的每日上限与今日剩余。 |
X-Quota-Degraded | 值为 1 时表示额度服务这次读不到,上面几个 X-Daily-* 可能缺席:请求照常返回,这次不计入额度。 |
X-Pulse-Basis | 只在 Pulse 上出现,值为 live 或 history,标明这次返回的是此刻的答案还是历史时刻的答案,不必解 body 就能分辨。 |
X-Pulse-Degraded | 只在 Pulse 上出现,值为 1 时表示单路证据源这次取不到、我们仍然出分:这次的结果要打折看。Pulse 的降级标记只在这里,body 里没有对应字段。 |
Retry-After | 只在 429 和 history_unavailable 时出现,距可以重试的秒数。 |
注:带 Key 调用的额度按账户与合同执行,请以响应头 X-Daily-Limit / X-RateLimit-Limit 为准,或用 GET /v1/me 查询。
3.3 版本、档位与套餐#
| 概念 | 取值示例 | 决定什么 | 从哪读 |
|---|---|---|---|
| 数据档位 | standard / professional | 响应里能拿到哪些字段(含 evidence[].captured_at / age_min 是否出现) | 响应头 X-Data-Tier |
| 账户套餐 | free / pro / enterprise | 额度 | 响应头 X-Plan |
| 能力开关 | 历史回溯单独开通 | 能否使用 ?at=;未开通返回 403 history_requires_professional | 调用结果即知 |
注:档位与套餐互不代管——升级套餐不等于拿到专业档字段,开通专业档也不等于历史回溯可用。需要更高用量或专业能力,见页脚联系方式。
四. 基础版接口#
给一个 IP 地址,返回它此刻的风险判断与归属。Key 可选:不带也能调用,带上则按账户额度计。
4.1 请求说明#
| 项目 | 说明 |
|---|---|
| 请求地址 | https://ip99.com/v1/ip/{ip} |
| 请求方法 | GET |
| 认证 | 不需要。带 Key 则按账户额度计,返回字段完全相同 |
| 每日额度 | 未登录时按访问来源 IP 计;登录或带 Key 后按账户 / Key 计。具体数值见响应头 X-Daily-Limit,或用 GET /v1/me 查询(见 3.2 额度与响应头) |
| 每分钟上限 | 未登录 60 / 分钟;自助创建的 Key 默认 300 / 分钟;企业 Key 按合同配置。实际以响应头 X-RateLimit-Limit 为准;超出返回 429 rate_limited |
| 跨域 | 返回 Access-Control-Allow-Origin: * 并应答 OPTIONS 预检,可直接在浏览器里调用;此时免费额度记在访问者自己的 IP 上 |
注:每分钟上限未登录时与带 Key 时不同(见 3.2 额度与响应头)。
4.2 请求示例#
不需要注册,也不需要先申请 Key,直接调用即可:
curl https://ip99.com/v1/ip/8.8.8.8
curl -H "X-API-Key: 你的KEY" \
https://ip99.com/v1/ip/8.8.8.8
4.3 请求参数说明#
| 参数 | 类型 | 是否必传 | 说明 |
|---|---|---|---|
ip | 字符串 | 必传(路径) | IPv4 或 IPv6 地址。私有地址与保留地址返回 400 reserved_ip;格式不合法返回 400 invalid_ip。本接口不接受 at,带上会返回 400 at_unsupported。 |
4.4 返回示例#
一次真实调用的返回:86.111.136.23,此刻判为高风险的代理出口。字段名与取值和线上一致,键序按阅读顺序整理过;JSON 对象本身是无序的,请按字段名取值。两个接口的 risk 是同一形态:都由同一段代码出,只有 score、level、signals 三个键,一套解析逻辑就够。
{
"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"
}
}
注:risk.signals 里出现的 slug 见 八. 风险信号说明,取值对照见 九. 风险标签说明。
4.5 返回内容字段说明#
两个接口的 risk 由同一段代码出,形态逐字相同:risk 只有 score、level、signals 三个键。
下表是基础版返回的全部字段。risk.signals、network、geo、degraded 四块是「有才出」的:risk.signals 一个信号都没命中时这个键不出现(此时 risk 只剩 score 与 level);其余三块没出现就是没有这项数据。
| 字段 | 类型 | 说明 | 描述 |
|---|---|---|---|
ip | 字符串 | 回显你查询的地址 | 与请求路径里的 {ip} 一致,便于把响应与请求对上号。 |
computed_at | 字符串 | 本次评分对应的时刻 | RFC 3339,带 Z。分数会随证据变旧而衰减,所以这个时刻是结论可复核的一部分。 |
evidence_state | 字符串 | 证据状态 |
|
risk.score | 整数 | 0–100 的风险分 | 衡量可核验证据的新鲜度与强度,不是「这个地址是攻击者」的概率。怎么读它见 七. 风险等级、分数与处置建议。 |
risk.level | 字符串 | 由分数推出的等级 | none / low / medium / high,恒由 score 推出,分档见 7.1。 |
risk.signals | 数组<字符串> | 命中的信号 slug | 只列命中的,按字典序;一个都没命中时这个键不出现。它只有命中的 slug,不带捕获时刻。取值与含义见 八. 风险信号说明。 |
network | 对象 | 地址的网络归属 | 自治域号与接入场景码。运营商名称一律不返回,这条信息的机器身份是 network.asn——拿它去公开注册库反查比我们转发一层译名可靠。逐字段见 六. network 与 geo 逐字段。 |
geo | 对象 | 地址的地理归属 | 洲码、国家码、行政区、坐标与时区。键集合恒定:continent / country / region / city / district / tz 对任何查询成功的地址都在,不随国家变;库里没有的名字给空串(不是 0)。region / city 各国地址都有值;district 只有中国地址有值(英文地名库对中国收录到区县、对其它国家收录到城市)。逐字段见 六. network 与 geo 逐字段。 |
degraded | 布尔 | 基础版专有的降级标记 | 只在部分数据源取不到、我们仍然出分时出现,值为 true。看到它,这次的结果就要打折看。(Pulse 上没有这个 body 字段,它的降级写在响应头 X-Pulse-Degraded 里。) |
五. Pulse 专业版接口#
基础版接口的升级版:返回同源同值的分数,把证据逐条摊开,并可以按历史时刻复现当时的判定。
这个接口需要专业版 Key。匿名调用、或 Key 的版本不够,都会返回 403 professional_required,不扣额度。只想查一个 IP 的风险分,用免费的 /v1/ip 就够了。
5.1 请求说明#
| 项目 | 说明 |
|---|---|
| 请求地址 | https://ip99.com/pulse/v1/ip/{ip} |
| 请求方法 | GET |
| 认证 | 需要专业版 Key:X-API-Key 或 Authorization: Bearer |
| 每日额度 | 与基础版共用同一份每日额度体系,具体数值见响应头 X-Daily-Limit(见 3.2 额度与响应头)。403 professional_required 不扣额度 |
| 每分钟上限 | 自助创建的 Key 默认 300 / 分钟;企业 Key 按合同配置。实际以响应头 X-RateLimit-Limit 为准;超出返回 429 rate_limited |
| 历史回溯 | ?at= 可回溯 30 天,需专业版且单独开通;转出的判定标在响应头 X-Pulse-Basis: history |
注:每分钟上限按 Key 生效,不同 Key 可以不同;以响应头 X-RateLimit-Limit 为准。
5.2 请求示例#
curl -H "X-API-Key: 你的KEY" \
"https://ip99.com/pulse/v1/ip/86.111.136.23"
curl -H "X-API-Key: 你的KEY" \
"https://ip99.com/pulse/v1/ip/86.111.136.23?at=2026-09-14T09:29:23Z"
5.3 请求参数说明#
| 参数 | 类型 | 是否必传 | 说明 |
|---|---|---|---|
ip | 字符串 | 必传(路径) | IPv4 或 IPv6 地址。 |
at | 字符串 | 可选(查询串) | RFC 3339 时刻,例如 2026-09-11T09:31:46Z。可以回溯 30 天,需要专业版及以上。带不带它,返回的字段、键名、形状完全一样,变的只是 computed_at 的含义:不传是此刻,传了就是你指定的那一刻(见 5.7 同一套字段,只有时刻不同)。只有这个接口支持 at;/v1/ip 上带 at 会返回 400 at_unsupported。 |
注:带时区偏移的写法要把 + 转义成 %2B——URL 里裸写 + 等于空格,服务端按格式错退回 invalid_time。时刻写错、写在未来,返回 400 invalid_time;超出可回溯窗口返回 400 at_out_of_window,响应里带 window_from,告诉你最早能问到哪一刻,不必一次次试探。
5.4 返回示例#
不带 at:此刻#
86.111.136.23 的真实返回(2026-09-16 抓取):risk.score、risk.level、risk.signals、network、geo 与 /v1/ip 的字段名和取值完全一致——同一时刻查,两个接口给的就是同一个数,连 risk 这一块的形状(score / level / signals)也逐字相同。逐条风险标签只在证据行 evidence[].risk_type 上,基础版不返回这一层。
多出来的是 share_tier、evidence 和 resource_pools 三项,含义见下面的字段字典。这份响应一共九个顶层键,下一节那份一个不多、一个不少。
{
"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"
}
}
带 at=:历史时刻#
同一个地址,把时刻拨回 09-14 09:29:23Z:字段、键名、形状与上面那份逐项一致,连 resource_pools 句柄都在。变的只有 computed_at 与相对它计算的量:分数 90(此刻 77)、obs_days 15(此刻 14)、captured_at 与 age_min 跟着那一刻的证据走。
network、geo 与上面那份完全相同——它们没有历史版本,两种问法返回的都是当前值。risk.signals 不在此列:两种问法都由本次评分真正落进 evidence[] 的风险类型折出,与同一份响应里的 evidence[] 逐项对应(没有证据行的信号不会出现)。
只有 computed_at 的含义变了。除 network、geo 这两块是当前值以外,其余字段(含 risk.signals 与 evidence[].risk_type)都按你指定的那一刻重建,不会拿今天的结论顶替。
{
"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 返回内容字段说明#
前几项与基础版 /v1/ip 是同一批字段、同一批取值(risk 这一块连形状都一样),后面几项是 Pulse 独有的。这些字段带不带 at= 都在,含义的差别只有一处:computed_at,以及相对它计算的量。
| 字段 | 类型 | 说明 | 描述 |
|---|---|---|---|
ip | 字符串 | 回显你查询的地址 | 与请求路径里的 {ip} 一致。 |
risk.score / risk.level | 整数 / 字符串 | 风险分与风险等级 | 与 /v1/ip 的 risk 是同一份数据,名字、形状、取值都一致。 |
risk.signals | 数组<字符串> | 命中的信号 slug | 只列命中的,按字典序;一个都没命中时这个键不出现。与同一份响应里的 evidence[] 同源:两种问法都由本次评分真正落进 evidence[] 的风险类型折出,两者逐项对应(没有证据行的信号不会出现)。唯一的例外是 hosting / mobile:它们是网络属性、本来就没有证据行,见 八. 风险信号说明。取值与含义也见那一节。基础版完全同形。 |
evidence | 数组<对象> | 逐条证据 | 按强度从高到低排列。没有证据时是空数组。字段见下表。 |
evidence_state | 字符串 | 证据状态 | 与 /v1/ip 相同:active / stale / none。按 computed_at 那一刻判定。 |
share_tier | 字符串 | 共用度 | 取值 T0 到 T3,决定封禁范围;含义与建议的处理方式见 7.2 共用度 share_tier。它也参与评分:共用的人越多,旧证据衰减得越重。 |
resource_pools | 数组<字符串> | 资源池标识 | 该地址命中某个资源池时才出现。同一个账户内稳定,跨账户不可比。它的用途是把「这些 IP 是一伙的」落成自家规则,本身不承载别的含义。 |
computed_at | 字符串 | 本次评分对应的时刻 | RFC 3339。这也是两种问法唯一的区别:不传 at 是此刻,传了 ?at= 就是你指定的那一刻。除 network、geo 之外,其余字段都相对这个时刻解释。 |
network / geo | 对象 | 地址归属 | 与 /v1/ip 同一套形状:键名、出现条件、取值全都一样,逐字段见 六. network 与 geo 逐字段(同一份字典管两个接口)。这是当前值,不随 at= 回退——两块都没有历史版本,带不带 at= 返回的都是查询这一刻的归属。一起返回是为了让「一批 IP 出一份带地区维度的报告」只跑一次接口,不必分数查 Pulse、归属再查一遍基础版。 |
降级出分不写在 body 里,写在响应头里。单路证据源这次取不到、我们仍然出分时,响应头会带 X-Pulse-Degraded: 1。看到它就把这次结果打个折扣看,不要当成正常返回;这个接口的响应体里没有表示降级的字段。
5.6 evidence[] 里每一条#
一条证据行只有这五个键:risk_type、freshness 与 obs_days 每次都带;captured_at 与 age_min 有值才出——没出现就是那条证据没有这一项。
| 字段 | 类型 | 说明 | 描述 |
|---|---|---|---|
risk_type | 字符串 | 这条证据的风险类型 | 也是每条证据的主键,逐条风险标签的载体只有这一行;命中的那批在 risk.signals 里也有一份。取值逐个列在九. 风险标签说明。 |
freshness | 字符串 | 新鲜度 | 四档:
|
captured_at | 字符串 | 这条证据精确的捕获时刻 | RFC 3339。专业版与私有化版有值就给,标准版不返回。是有值才出的可选字段,取值时按需读取,不要假设一定存在。 |
age_min | 数字 | 这条证据距离 computed_at 多少分钟 | 出现条件与 captured_at 相同(两者成对出现)。 |
obs_days | 专业版 | 这条证据被持续报告的去重天数。统计窗口是 computed_at 往前 14 天(带 at= 时按那一刻算)。固定出现:专业版与私有化版的每条证据行都带这个键,不会省略。 |
obs_days 不一样:它固定出现——专业版与私有化版的每条证据行都带这个键;captured_at 与 age_min 有值才出,没出现就是那条证据没有这一项。
5.7 同一套字段,只有时刻不同#
带不带 at,返回的是同一个形状。同一套键名、同一层嵌套、同一批字段——两种问法由同一段代码出结果,不是两套接口。唯一变的是 computed_at 的含义:不带 at 是此刻,带了 at= 就是你指定的那一刻。
凡是「相对某一刻」的量都相对 computed_at 解释:risk.score、risk.level、risk.signals、share_tier、evidence_state、resource_pools,以及 evidence[] 里的 freshness、captured_at、age_min、obs_days。例外只剩 network 与 geo 这两块:它们没有历史版本,两种问法返回的都是当前值。
| 对比项 | 不带 at(此刻) | 带 at=(历史时刻) |
|---|---|---|
| 回答的是 | 此刻的状态 | 你指定的那一刻的状态 |
computed_at | 此刻 | 你指定的那一刻,原样回给你 |
risk.signals 与 evidence[] | 都由此刻的证据折出,两者逐项对应 | 都按那一刻重建,两者逐项对应(hosting / mobile 是网络属性、没有证据行,两种问法都取当前值) |
响应头 X-Pulse-Basis | live | history |
| 版本要求 | 专业版 | 专业版,且历史回溯要单独开通;没开通返回 403 history_requires_professional |
问「那一刻是什么分」,就必须给出那一刻的结果,所以这里只有照常返回和明确报错两种结果,不会当成「今天的结果」返回。全部错误码见 十. 错误码表。
六. network 与 geo 逐字段#
这两块和 risk.signals 一样是「有才出」:某个键没有值就不出现,整块都没值就整块不出现——不会用 0、空字符串或 null 占位。所以「键不存在」只有一个含义:不知道。按字段分组统计时,请把「键不存在」与「值真的是 0」分开处理。
| 字段 | 类型 | 出现条件 | 取值与枚举(含描述) | 未知时 | 示例 |
|---|---|---|---|---|---|
network.asn | 整数 | 取到自治域号时(asn > 0) | 自治域号。描述:运营商 / 机构名不返回,拿 asn 到公开注册库反查即可——那才是这条信息的机器身份。 | 键不出现,不会返回 0 | "asn": 15547 |
network.usage_type | 字符串 | 数据源场景库里有这段地址 | 接入场景码,数据源原码原样透传。我们有本地化名称的是这 20 个:
| 键不出现 | "usage_type": "DYN" |
geo.continent | 字符串 | 恒在 | 两位洲码,闭集、无损:
| 空串(极少数段无洲可言) | "continent": "EU" |
geo.country | 字符串 | 恒在 | ISO 3166-1 alpha-2 国家 / 地区码。描述:要国家名请拿码去查标准表,我们只给码。 | 空串(无国家信息的段) | "country": "CH" |
geo.region | 字符串 | 恒在 | 一级行政区的拉丁名。各国地址都会返回,中国地址也给(例如 Shanghai)。 | 空串 | "region": "Valais" |
geo.city | 字符串 | 恒在 | 城市的拉丁名。各国地址都会返回,中国地址也给(例如 Shanghai)。 | 空串 | "city": "Sitten" |
geo.district | 字符串 | 恒在 | 区县(county / district)的拉丁名,比市细一级、比街道粗。描述:英文地名库对中国收录到区县、对其它国家收录到城市,所以值只有中国地址非空;其它地址这个键也在、值为空串(中国地址例如会给 Xuhui)。 | 空串(非中国地址一律为空串) | "district": "Xuhui" |
geo.lat / geo.lon | 数字 | 成对出现:两个坐标都取到时才出 | 十进制度,取整到小数点后 5 位(约 1 米——IP 地理定位到不了更细,再往后是浮点噪声不是位置信息)。描述:数据源里查不到坐标时两个都是 0,而 0,0 是几内亚湾上一个真实的点、不是「未知」,所以这种情况两个键一起不出现。 | 两个键一起不出现,不会返回 0,0 | "lat": 46.22739"lon": 7.35559 |
geo.tz | 字符串 | 恒在 | IANA 时区名。 | 空串(极少数段无时区) | "tz": "Europe/Zurich" |
注:geo 的键集合恒定:continent / country / region / city / district / tz 六个字符串键对任何查询成功的地址都在,不会因为地址在中国还是别的国家而多一个键、少一个键;库里没有的名字给空串(是「库里没有」,不是 0)。region / city 取自英文地名库,各国地址都有值;district 只有中国地址有值(英文地名库对中国收录到区县、对其它国家收录到城市,所以非中国地址到 city 为止,如 8.8.8.8 只到 California / Mountain View)。运营商名不返回的理由同理:数据源里是本地化译名(非中国行也有中文),只给一部分会让「运营商维度」的统计口径悄悄失真。
七. 风险等级、分数与处置建议#
risk.score(0–100)衡量的是可核验证据的新鲜度与强度,会随证据变旧而下降。
它不是「这个地址是攻击者」的概率。把 0–100 的分直接当概率用,阈值就会设错。
与传统 IP 情报库最大的不同:我们给的是一个会过期的判断,而不是一个长期有效的标签。同一个地址上周是秒拨池、今天还是不是,取决于证据距今多久。所以同一个 IP 隔几小时查两次、分数不一样,是正常的。
7.1 风险分数与风险等级#
| 风险分数 | 风险等级 risk.level | 说明 | 处置建议 |
|---|---|---|---|
| 0 | none | 没有当前可核验的新鲜证据。两种原因完全不同:证据过期(evidence_state: stale),或我们从未见过(none)。0 分不等于安全。 | 不要据此放行后做「干净」标记;先看 evidence_state 再决定:stale 只能当参考,none 是没有信息 |
| 1–29 | low | 有证据,但强度或新鲜度低。 | 可放行,建议只做记录与观察,不要单独触发拦截 |
| 30–69 | medium | 证据中等,通常是捕获时间较久,或证据强度一般。 | 建议验证而非直接封禁:验证码、二次校验、限流、降低权重 |
| 70–100 | high | 证据强或很新鲜。 | 可按业务成本决定拦截;封禁前结合 share_tier 判断误伤面(见 7.2),hijacked_proxy 一类不应直接封号 |
注:上表「风险分数 → 风险等级」是接口口径(risk.level 恒由 score 推出,不接受调用方另给);「处置建议」是通用建议,不构成合同承诺——同一个分数在登录、下单、注册上的答案本就不同,最终阈值请按自家业务、成本与风险偏好定。另外 hijacked_proxy(劫持共用代理)的持有人是受害者,处理方式通常不该是封号。
7.2 共用度 share_tier#
Pulse 会给出这个地址大概被多少人共用,它决定封禁范围:数字越小,误伤到的人越少。它也参与评分——共用的人越多,旧证据衰减得越重,所以 T3 上的分天然比 T0 保守。
| 取值 | 大致共用人数 | 典型网络 | 建议的处理方式 |
|---|---|---|---|
T0 | 约 1–10 | 独占地址、数据中心出口 | 可以直接封,误伤面最小 |
T1 | 约 1–20 | 家庭宽带 NAT | 可以封,但要知道可能误伤一户人家 |
T2 | 约 50–2000 | 企业专线、校园出口 | 谨慎:一封就是一栋楼 |
T3 | 约 1000–10 万以上 | 移动蜂窝网 CGNAT | 不要封 IP,改用验证码、限流或降低权重 |
注:share_tier 只在 Pulse 上返回;基础版拿不到,此时按 network.usage_type 判断大致的网络形态。
7.3 读结果的分流口径#
上面几节列出了每个字段的含义,这里说明该按什么顺序读——分数最容易用错,所以不要一上来只看它。
- 先看
evidence_state,它回答「这个结论可不可信」。值为none时分数一定是 0,但那是「没见过」,不是「干净」。 - 再看
risk.score与risk.level。只有active或stale时它们才有内容。 - 然后看命中了哪些风险类型,它决定怎么处理:代理、秒拨、云手机的风险含义和用户申诉理由都不一样。两个接口都先看
risk.signals(只列命中的 slug);要每条证据的新鲜度,再看 Pulse 的evidence[].risk_type。 - 要决定封禁范围时看
share_tier(Pulse 才有)。同一个分数,独占 IP 和移动大内网上的处理方式不该一样。 - 最后看降级标记:
degraded是基础版专有的 body 字段(有才出),Pulse 用响应头X-Pulse-Degraded。它出现时给这次结果打个折扣,不要当成正常返回。
0 分不等于安全。0 分有两种完全不同的原因:证据过期(stale),或我们从未见过(none)。前者只能当参考,后者是没有信息。
按 evidence_state 分流,不要只看分数放行。
八. 风险信号说明#
risk.signals 里会出现的 slug 一共这 10 个。只列命中的,按字典序;一个都没命中时 risk 里没有 signals 这个键。其中 8 个由风险标签派生,另外 2 个说的是这个地址处在什么样的网络上,不是风险。
这 8 个与同一份响应里的 evidence[].risk_type 逐项对应。signals 里出现的每个风险标签,都能在 evidence[] 里找到那一条;反过来不一定——evidence[] 里还有自采类型(见 九. 风险标签说明)。带不带 at= 都是这条规则:risk.signals 由本次评分真正落进 evidence[] 的风险类型折出,不是照搬画像里的标签。唯一的例外是 hosting / mobile:它们是网络属性、本来就没有证据行,见下面第二段提醒。
| 信号 | 出现在 risk.signals 里是什么意思 | 对应的风险标签 | 来源 |
|---|---|---|---|
proxy | 这个地址正被当作代理出口使用。 | proxy 代理 | 风险标签 |
vpn | 商用 VPN 的静态出口。 | vpn VPN | 风险标签 |
dialup_pool | 秒拨池:同一个地址被反复拨号换 IP 使用。 | dialup_pool 秒拨 | 风险标签 |
hijacked_proxy | 被木马劫持后当成代理用的地址。持有人是受害者,不是黑产,处理方式通常不该是封号。 | hijacked_proxy 劫持共用代理 | 风险标签 |
osint | 被公开情报源收录过。 | osint 开源情报 | 风险标签 |
crawler | 搜索引擎爬虫出口。我们把它当良性:不参与评分,也不会成为一条被打分的证据。没有证据行,所以它不会出现在 risk.signals 里。 | —(良性,不产生证据行) | 风险标签 |
cloud_service | 云函数 / Serverless 出口。 | cloud_service 云服务 | 风险标签 |
cloud_phone | 云手机(机房里的手机设备)。 | cloud_phone 云手机 | 风险标签 |
hosting | 地址在机房、CDN 或 Anycast 上。说的是网络属性,不是它做过什么。 | 没有对应标签 | 由接入类型派生 |
mobile | 地址在移动蜂窝网上。同样是网络属性。 | 没有对应标签 | 由接入类型派生 |
别把后两个信号当风险用。例如 8.8.8.8 的 risk.signals 里有 hosting,而它的 risk.score 是 0。按 hosting 或 mobile 拦流量,会误伤整片公有云和整个移动运营商。
crawler 是良性标签,不参与评分,也不产生证据行——因此 risk.signals 与 evidence[] 里都不会出现它。某个地址只带爬虫标签时,这两个字段都是空的,那就是正确答案。
唯一不对应的是 hosting 与 mobile:它们是网络属性、本来就没有证据行(evidence[] 里不会有 hosting 这一条),所以带不带 at= 都取当前值,与 network / geo 同一口径。风险标签那 8 个没有这个例外:带 at= 时按那一刻重建。
九. 风险标签说明#
标签是固定标识,不是给人读的名字,这里给一份对照。两个接口用的是同一套写法:risk.signals 里列的就是第一列那些 slug,Pulse 的 evidence[].risk_type 也是同一批。
| 取值 | 名称 | 是什么 | 对应的信号 |
|---|---|---|---|
proxy | 代理 | 被当作代理出口使用,多来自代理在售资源。 | proxy |
dialup_pool | 秒拨 | 同一个地址被反复拨号换 IP。 | dialup_pool |
vpn | VPN | 商用 VPN 的静态出口。 | vpn |
hijacked_proxy | 劫持共用代理 | 被木马劫持的正常用户设备,持有人是受害者。 | hijacked_proxy |
osint | 开源情报 | 被公开情报源收录。 | osint |
crawler | 搜索引擎爬虫 | 搜索引擎的抓取出口。良性,不参与评分,也不产生证据行。 | —(良性标签不进 risk.signals) |
cloud_service | 云服务 | 云函数 / Serverless 出口。 | cloud_service |
cloud_phone | 云手机 | 机房里的手机设备。 | cloud_phone |
honeypot_scan | 蜜罐扫描源 | 只在 evidence[].risk_type 上出现,不会出现在 risk.signals 里。 | — |
honeypot_credential | 蜜罐撞库源 | 只在 evidence[].risk_type 上出现。 | — |
ipv6_range | IPv6 区间 | 只在 evidence[].risk_type 上出现。 | — |
evidence[].risk_type 的取值范围比 risk.signals 宽。Pulse 的证据里还会出现 honeypot_scan(蜜罐扫描源)、honeypot_credential(蜜罐撞库源)和 ipv6_range(IPv6 区间)。这三个不会出现在 risk.signals 里。
同一个风险类型在哪一处都是同一个写法。「劫持共用代理」在 risk.signals 与 evidence[].risk_type 里一律拼 hijacked_proxy,可以直接互相比对,不需要再做映射。
crawler 是良性标签:它不会带来分数,不参与评分,也没有证据行,所以 risk.signals 里不会出现它。
十. 错误码表#
所有错误都是同一个结构,便于统一处理。请按 error.code 分支,不要按 error.message 判断——后者是给人读的说明,措辞可能调整。
{
"error": {
"code": "invalid_ip",
"message": "不是合法的 IPv4/IPv6 地址:…"
}
}
10.1 通用错误码#
| HTTP | code | 说明 | 是否可重试 |
|---|---|---|---|
| 400 | invalid_ip | 不是合法的 IP 地址。 | 否。修正参数后重发 |
| 400 | reserved_ip | 私有或保留地址,没有公网风险画像。 | 否 |
| 400 | key_in_query | Key 出现在查询串里,请改用请求头。 | 否。改用 X-API-Key 后重发 |
| 400 | at_unsupported | 基础版 /v1/ip 不支持 at 参数。 | 否。去掉 at,或改调 Pulse |
| 401 | invalid_key | API Key 无效。 | 否。核对或重建 Key |
| 401 | revoked_key | API Key 已被吊销。 | 否。需新建 Key |
| 401 | key_expired | API Key 已过期。 | 否。需新建 Key |
| 403 | professional_required | 这个接口需要专业版,当前版本不够。不扣额度。 | 否。需开通专业版 |
| 404 | not_found | 接口路径不存在。 | 否。核对路径 |
| 429 | rate_limited | 超出每分钟速率,稍后重试。 | 是。按 Retry-After 退避后重试 |
| 429 | quota_exceeded | 当日额度用尽,响应带 Retry-After。 | 是。按 Retry-After 重试,当日无法提额 |
| 429 | quota_exceeded_anon | 未登录的额度用尽——登录并创建 Key 即可提额。 | 是。登录 / 创建 Key 后立即恢复 |
| 429 | key_quota_exceeded | 这把 Key 自己的每日额度用尽(额度按 Key 计时才会出现,看响应头 X-Quota-Scope: key)。 | 是。换用账户额度或次日重试 |
10.2 ?at= 专用错误码#
这几个码含义互不相同,请不要合并处理:看到码就知道该做什么——改参数、开通版本,还是稍后重试。
| HTTP | code | 说明 | 是否可重试 |
|---|---|---|---|
| 400 | invalid_time | 不是 RFC 3339 时刻(时区偏移里的 + 要写成 %2B),或者是未来时刻。 | 否。修正时刻写法后重发 |
| 400 | at_out_of_window | 早于本服务能回答的最早时刻,响应体带 window_from。 | 否。按 window_from 调整时刻 |
| 400 | history_not_enabled | 这个接入点没有开放历史回溯——是配置状态,不是故障。 | 否。需先开通 |
| 400 | history_no_ipv6 | 历史数据只索引 IPv4,该地址没有历史。 | 否 |
| 403 | history_requires_professional | 历史回溯需要专业版及以上。 | 否。需开通专业版 |
| 503 | history_unavailable | 历史数据暂时读不到,带 Retry-After: 10,可以重试。 | 是。按 Retry-After 重试 |
注:其中「参数写错、超出窗口、这个接入点没有历史数据」这几类不消耗额度:请求还没算到分数、也没查过一次数据就被退回了。
十一. DEMO 示例#
同一个查询在三种语言里的最小可用写法,一个语言一块。三段都只调免费接口 GET /v1/ip/{ip}、只用各语言标准库,不需要 Key、没有第三方依赖,把 IP 换成你自己的即可直接运行。
11.1 curl#
curl -s "https://ip99.com/v1/ip/8.8.8.8"
注:要按历史时刻查 Pulse,换成下面这行(需专业版 Key):
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"
返回示例(2026-09-14T09:29:23Z 这一刻的回溯结果)#
上面那行 ?at= 命令的真实返回,HTTP 200,响应头 X-Data-Tier: professional、X-Pulse-Basis: history、X-Quota-Scope: user,响应体 505 字节,数字与字符串未做任何删改,仅按下页面的缩进美化显示(点「复制」得到的就是原样的 505 字节紧凑 JSON):
{
"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" }
}
注:请求里的 ?at=2026-09-14T09:29:23Z 被原样回显在 computed_at 上——这就是「问哪一刻,答哪一刻」。这一刻 risk.score 是 90,比 5.4 那份「此刻」的 77 高:evidence[].age_min 只有 56.53 分钟(within_2h),证据更新鲜,分也更高。字段含义见 5.5 返回内容字段说明。
11.2 Python#
import json, urllib.request
def lookup(ip, key=None, at=None):
path = "/v1/ip/" + ip
if at: # 历史回溯:仅 Pulse 专业版支持,需带 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) # 免费接口不需要
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#
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
func main() {
// 免费接口,不需要 Key。带 Key 时在下面加一行(Pulse 必需):
// req.Header.Set("X-API-Key", os.Getenv("IP99_API_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"` // 一个都没命中时该键不出现
} `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)
}
注:出错时 HTTP 状态码非 200,响应体仍是 {"error":{"code","message"}},请先读 error.code 再决定是否重试(见 十. 错误码表)。历史回溯那一支只有在专业版、且该接入点已开通历史时才可用。
邮箱登录
未注册的邮箱将自动创建账户,即代表同意服务条款。