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,而不是照常处理。

请求:带 Key(推荐写法)
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调用结果即知

注:档位与套餐互不代管——升级套餐不等于拿到专业档字段,开通专业档也不等于历史回溯可用。需要更高用量或专业能力,见页脚联系方式。

四. 基础版接口#

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

给一个 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,直接调用即可:

请求:不带 Key
curl https://ip99.com/v1/ip/8.8.8.8
请求:带 Key
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 三个键,一套解析逻辑就够。

响应 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"
  }
}

注: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字符串证据状态
  • active:有新鲜证据并在计分
  • stale:见过,但证据已过时效(分数已衰减到 0)
  • none:从未见过
按它分流,不要只看分数。
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 专业版接口#

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

基础版接口的升级版:返回同源同值的分数,把证据逐条摊开,并可以按历史时刻复现当时的判定。

这个接口需要专业版 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 请求示例#

请求:不带 at,问此刻
curl -H "X-API-Key: 你的KEY" \
  "https://ip99.com/pulse/v1/ip/86.111.136.23"
请求:带 at=,问历史时刻
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 三项,含义见下面的字段字典。这份响应一共九个顶层键,下一节那份一个不多、一个不少。

响应 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"
  }
}

带 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)都按你指定的那一刻重建,不会拿今天的结论顶替。

响应 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 返回内容字段说明#

前几项与基础版 /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字符串新鲜度四档:
  • within_2h:2 小时内捕获
  • within_4h:4 小时内捕获
  • within_12h:12 小时内捕获
  • older:距上次捕获超过 12 小时
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-Basislivehistory
版本要求专业版专业版,且历史回溯要单独开通;没开通返回 403 history_requires_professional

问「那一刻是什么分」,就必须给出那一刻的结果,所以这里只有照常返回和明确报错两种结果,不会当成「今天的结果」返回。全部错误码见 十. 错误码表。

六. network 与 geo 逐字段#

这两块和 risk.signals 一样是「有才出」:某个键没有值就不出现,整块都没值就整块不出现——不会用 0、空字符串或 null 占位。所以「键不存在」只有一个含义:不知道。按字段分组统计时,请把「键不存在」与「值真的是 0」分开处理。

字段类型出现条件取值与枚举(含描述)未知时示例
network.asn整数取到自治域号时(asn > 0)自治域号。描述:运营商 / 机构名不返回,拿 asn 到公开注册库反查即可——那才是这条信息的机器身份。键不出现,不会返回 0"asn": 15547
network.usage_type字符串数据源场景库里有这段地址接入场景码,数据源原码原样透传。我们有本地化名称的是这 20 个:
  • ANY 任播网络
  • CDN 内容分发
  • DNS 域名解析
  • EDU 教育机构
  • GTW 企业专线
  • GOV 政府机构
  • DYN 家庭宽带
  • IDC 数据中心
  • IXP 交换中心
  • MOB 移动网络
  • NET 基础设施
  • ORG 组织机构
  • SAT 卫星通信
  • BOGON 保留 IP
  • AUR 已分配未路由
  • RNU 已路由未使用
  • UND 未分配
  • WLA WLAN 热点
  • DDT 专用出口
  • USE 已使用
这张表不等于「全部可能取值」:这是数据源场景码、原样透传,出现本表之外的码时只是我方没有对应译名——不是错误。请按「不认识的码」处理,不要因为不认识就丢掉整条记录。
键不出现"usage_type": "DYN"
geo.continent字符串恒在两位洲码,闭集、无损:
  • AS 亚洲
  • EU 欧洲
  • NA 北美洲
  • SA 南美洲
  • AF 非洲
  • OC 大洋洲
  • AN 南极洲
空串(极少数段无洲可言)"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说明处置建议
0none没有当前可核验的新鲜证据。两种原因完全不同:证据过期(evidence_state: stale),或我们从未见过(none)。0 分不等于安全。不要据此放行后做「干净」标记;先看 evidence_state 再决定:stale 只能当参考,none 是没有信息
1–29low有证据,但强度或新鲜度低。可放行,建议只做记录与观察,不要单独触发拦截
30–69medium证据中等,通常是捕获时间较久,或证据强度一般。建议验证而非直接封禁:验证码、二次校验、限流、降低权重
70–100high证据强或很新鲜。可按业务成本决定拦截;封禁前结合 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 读结果的分流口径#

上面几节列出了每个字段的含义,这里说明该按什么顺序读——分数最容易用错,所以不要一上来只看它。

  1. 先看 evidence_state,它回答「这个结论可不可信」。值为 none 时分数一定是 0,但那是「没见过」,不是「干净」。
  2. 再看 risk.score 与 risk.level。只有 active 或 stale 时它们才有内容。
  3. 然后看命中了哪些风险类型,它决定怎么处理:代理、秒拨、云手机的风险含义和用户申诉理由都不一样。两个接口都先看 risk.signals(只列命中的 slug);要每条证据的新鲜度,再看 Pulse 的 evidence[].risk_type。
  4. 要决定封禁范围时看 share_tier(Pulse 才有)。同一个分数,独占 IP 和移动大内网上的处理方式不该一样。
  5. 最后看降级标记: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
vpnVPN商用 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_rangeIPv6 区间只在 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 判断——后者是给人读的说明,措辞可能调整。

响应 400
{
  "error": {
    "code": "invalid_ip",
    "message": "不是合法的 IPv4/IPv6 地址:…"
  }
}

10.1 通用错误码#

HTTPcode说明是否可重试
400invalid_ip不是合法的 IP 地址。否。修正参数后重发
400reserved_ip私有或保留地址,没有公网风险画像。否
400key_in_queryKey 出现在查询串里,请改用请求头。否。改用 X-API-Key 后重发
400at_unsupported基础版 /v1/ip 不支持 at 参数。否。去掉 at,或改调 Pulse
401invalid_keyAPI Key 无效。否。核对或重建 Key
401revoked_keyAPI Key 已被吊销。否。需新建 Key
401key_expiredAPI Key 已过期。否。需新建 Key
403professional_required这个接口需要专业版,当前版本不够。不扣额度。否。需开通专业版
404not_found接口路径不存在。否。核对路径
429rate_limited超出每分钟速率,稍后重试。是。按 Retry-After 退避后重试
429quota_exceeded当日额度用尽,响应带 Retry-After。是。按 Retry-After 重试,当日无法提额
429quota_exceeded_anon未登录的额度用尽——登录并创建 Key 即可提额。是。登录 / 创建 Key 后立即恢复
429key_quota_exceeded这把 Key 自己的每日额度用尽(额度按 Key 计时才会出现,看响应头 X-Quota-Scope: key)。是。换用账户额度或次日重试

10.2 ?at= 专用错误码#

这几个码含义互不相同,请不要合并处理:看到码就知道该做什么——改参数、开通版本,还是稍后重试。

HTTPcode说明是否可重试
400invalid_time不是 RFC 3339 时刻(时区偏移里的 + 要写成 %2B),或者是未来时刻。否。修正时刻写法后重发
400at_out_of_window早于本服务能回答的最早时刻,响应体带 window_from。否。按 window_from 调整时刻
400history_not_enabled这个接入点没有开放历史回溯——是配置状态,不是故障。否。需先开通
400history_no_ipv6历史数据只索引 IPv4,该地址没有历史。否
403history_requires_professional历史回溯需要专业版及以上。否。需开通专业版
503history_unavailable历史数据暂时读不到,带 Retry-After: 10,可以重试。是。按 Retry-After 重试

注:其中「参数写错、超出窗口、这个接入点没有历史数据」这几类不消耗额度:请求还没算到分数、也没查过一次数据就被退回了。

十一. DEMO 示例#

同一个查询在三种语言里的最小可用写法,一个语言一块。三段都只调免费接口 GET /v1/ip/{ip}、只用各语言标准库,不需要 Key、没有第三方依赖,把 IP 换成你自己的即可直接运行。

11.1 curl#

免费接口,不需要 Key
curl -s "https://ip99.com/v1/ip/8.8.8.8"

注:要按历史时刻查 Pulse,换成下面这行(需专业版 Key):

需 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):

响应 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" }
}

注:请求里的 ?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#

urllib,标准库,不需要 Key
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#

net/http,标准库,不需要 Key
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 再决定是否重试(见 十. 错误码表)。历史回溯那一支只有在专业版、且该接入点已开通历史时才可用。