{
  "openapi": "3.1.0",
  "info": {
    "title": "IP99 IP risk and geolocation API",
    "version": "1.0.0",
    "summary": "IP risk verdicts with the age of their evidence, plus geolocation and network facts, for IPv4 and IPv6.",
    "description": "One lookup returns whether an address is currently proxy, VPN, dial-up pool, cloud phone or datacenter infrastructure, how fresh that evidence is, and the network and district-level location it belongs to.\n\n**Free without a key**: `GET /v1/ip/{ip}` — 1000 lookups per day per client IP and 60 per minute. A free account raises the daily allowance; keys go in the `X-API-Key` header (or `Authorization: Bearer`), never in the URL. `/v1/ip/{ip}` sends `Access-Control-Allow-Origin: *` and answers `OPTIONS` preflights, so it can be called from a browser.\n\n**Read `evidence_state` before `risk.score`**: a score of 0 with `evidence_state: none` means no evidence, not safe; `stale` means evidence existed and expired. The score measures the freshness and strength of verifiable evidence, not a probability. Query at decision time; do not cache verdicts.\n\nThe same lookup is available to AI agents as a Model Context Protocol tool (`ip_risk_lookup`) at `https://ip99.com/mcp`, returning exactly the fields of `/v1/ip/{ip}`. Human-readable documentation: https://ip99.com/api · guides: https://ip99.com/llms.txt",
    "termsOfService": "https://ip99.com/en/terms",
    "contact": {
      "name": "IP99",
      "url": "https://ip99.com/en/"
    }
  },
  "servers": [
    {
      "url": "https://ip99.com"
    }
  ],
  "externalDocs": {
    "description": "API documentation, MCP endpoint and phone-number capability",
    "url": "https://ip99.com/api"
  },
  "tags": [
    {
      "name": "lookup",
      "description": "IP risk and geolocation lookups"
    },
    {
      "name": "account",
      "description": "Your tier and quota"
    }
  ],
  "paths": {
    "/v1/ip/{ip}": {
      "get": {
        "tags": [
          "lookup"
        ],
        "operationId": "lookupIP",
        "summary": "Risk verdict, network and location for one IP address",
        "description": "Free without a key (per-client-IP allowance) or with a key (your own allowance). Never cache the verdict across decisions: `computed_at` says when it was computed and `risk.score` decays with the age of the evidence. The `risk` object has the same shape as on `/pulse/v1/ip/{ip}` (`score` / `level` / `signals`), rendered by the same function: this endpoint just omits the professional extras `evidence[]`, `share_tier` and `resource_pools`, and adds one field Pulse does not have — `degraded`, a basic-tier-only marker that appears (and is true) only when one evidence source was unavailable and the verdict was computed without it.\n\nThe hit signals are carried by `risk.signals` alone, and an address that hit nothing omits that key entirely.",
        "security": [
          {},
          {
            "apiKey": []
          },
          {
            "bearer": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ip"
          }
        ],
        "responses": {
          "200": {
            "description": "Verdict for the address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IPRisk"
                },
                "examples": {
                  "activeProxyExit": {
                    "summary": "Residential address currently sold as a proxy exit (captured 2026-09-13)",
                    "value": {
                      "ip": "122.99.101.50",
                      "risk": {
                        "score": 78,
                        "level": "high",
                        "signals": [
                          "proxy"
                        ]
                      },
                      "evidence_state": "active",
                      "computed_at": "2026-09-13T15:41:18Z",
                      "network": {
                        "asn": 17471,
                        "usage_type": "DYN"
                      },
                      "geo": {
                        "continent": "AS",
                        "country": "BD",
                        "region": "Dhaka Division",
                        "city": "Bhairab Bāzār",
                        "lat": 24.0524,
                        "lon": 90.9764,
                        "tz": "Asia/Dhaka"
                      }
                    }
                  },
                  "noEvidence": {
                    "summary": "Datacenter address with no risk evidence; the only signal is the network-derived `hosting` (captured 2026-09-13)",
                    "value": {
                      "ip": "8.8.8.8",
                      "risk": {
                        "score": 0,
                        "level": "none",
                        "signals": [
                          "hosting"
                        ]
                      },
                      "evidence_state": "none",
                      "computed_at": "2026-09-13T15:40:41Z",
                      "network": {
                        "asn": 15169,
                        "usage_type": "IDC"
                      },
                      "geo": {
                        "continent": "NA",
                        "country": "US",
                        "region": "California",
                        "city": "Mountain View",
                        "lat": 37.386,
                        "lon": -122.084,
                        "tz": "America/Los_Angeles"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Not a valid public IPv4/IPv6 address, or `?at=` used on this endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "invalid_ip",
                    "message": "not a valid IPv4/IPv6 address"
                  }
                }
              }
            }
          },
          "404": {
            "description": "No record for this address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "not_found",
                    "message": "no record for this IP"
                  }
                }
              }
            }
          },
          "429": {
            "description": "Per-minute rate limit (`rate_limited`) or daily allowance (`quota_exceeded_anon` / `key_quota_exceeded`) exhausted. `Retry-After` carries the wait in seconds; the body may add `retry_after`, `resets_at` and `next_step`.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "quota_exceeded_anon",
                    "message": "anonymous daily allowance for this client IP is used up",
                    "retry_after": 3600,
                    "next_step": "sign up for a free account for a larger allowance",
                    "signup_url": "https://ip99.com/en/console"
                  }
                }
              }
            }
          },
          "503": {
            "description": "Data still loading after a restart; retry after `Retry-After`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "not_ready",
                    "message": "data is initialising, retry shortly"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/me": {
      "get": {
        "tags": [
          "account"
        ],
        "operationId": "me",
        "summary": "Your current tier and remaining quota",
        "security": [
          {
            "apiKey": []
          },
          {
            "bearer": []
          }
        ],
        "responses": {
          "200": {
            "description": "Tier and quota for the caller.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Me"
                },
                "example": {
                  "tier": "key",
                  "quota_scope": "key",
                  "plan": "pro",
                  "per_min": 600,
                  "per_day": 100000,
                  "overage_pct": 0,
                  "quota_day_tz": "Asia/Shanghai",
                  "used_today": 1234,
                  "remaining": 98766,
                  "reset_unix": 1789401600,
                  "key_prefix": "sk_live_ab12",
                  "created_at": "2026-09-01T08:00:00Z",
                  "sales_contact": "https://ip99.com/#contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "key_required",
                    "message": "send X-API-Key: <key> or Authorization: Bearer <key>"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/pulse/v1/ip/{ip}": {
      "get": {
        "tags": [
          "lookup"
        ],
        "operationId": "lookupIPProfessional",
        "summary": "Same verdict with per-evidence detail (professional tier)",
        "description": "Same engine, the same score and the same `risk` object as `/v1/ip/{ip}` (both render `score` / `level` / `signals` with the same function), plus per-evidence detail, sharing tier and resource pools. `risk.signals` lists only the signals that fired, sorted alphabetically; an address that hit nothing omits that key. Requires a key on the professional data tier; without it the API answers HTTP 403 `professional_required`. `?at=` returns the verdict as it stood at a past moment (history window per contract) and the response has exactly the same fields and shape as a live one; only `computed_at` changes meaning, while `risk.signals`, `network` and `geo` stay current. The risk labels ride on the evidence rows as `evidence[].risk_type` (the ones that are also signals come back again in `risk.signals`), and every row of `evidence[]` carries exactly five keys: `risk_type`, `freshness` and `obs_days` always, plus `captured_at` and `age_min` when they have a value. There is no degraded flag in the body — that marker is basic-tier-only and sits on `/v1/ip/{ip}`: here a verdict computed with one evidence source missing is signalled by the `X-Pulse-Degraded: 1` response header.",
        "security": [
          {
            "apiKey": []
          },
          {
            "bearer": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ip"
          },
          {
            "name": "at",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "RFC 3339 instant; verdict as of that moment. A response with `at` has exactly the same fields and shape as one without it — the only difference is what `computed_at` means (the moment the verdict is for; `risk.score`, `risk.level`, `share_tier`, `evidence_state`, `evidence[]` and `resource_pools` are all relative to it). `risk.signals`, `network` and `geo` are current values in both cases. A numeric UTC offset is accepted, but its `+` must be percent-encoded as `%2B` — a bare `+` in a query string means a space and is rejected with `invalid_time`. Both forms are normalized to UTC in `computed_at`."
          }
        ],
        "responses": {
          "200": {
            "description": "Professional-tier verdict: the same `risk` object as `/v1/ip/{ip}` (`score` / `level` / `signals`, from the same rendering function), plus per-evidence detail, `share_tier` and `resource_pools`.",
            "headers": {
              "X-Pulse-Degraded": {
                "schema": {
                  "type": "string",
                  "enum": [
                    "1"
                  ]
                },
                "description": "Sent as `1` when a single evidence source was unavailable and the verdict was computed without it. This flag is never part of the response body."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PulseIPRisk"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "key_required",
                    "message": "send X-API-Key: <key> or Authorization: Bearer <key>"
                  }
                }
              }
            }
          },
          "403": {
            "description": "The key is not on the professional data tier.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "professional_required",
                    "message": "this endpoint requires the professional data tier"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Not a valid public IPv4/IPv6 address; `key_in_query` (the key was sent in the query string); or a rejected `?at=` (`invalid_time`, `at_out_of_window`, `history_not_enabled`, `history_no_ipv6`). None of these consume daily allowance.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "at_out_of_window",
                    "message": "requested instant is outside the window this deployment can answer",
                    "window_from": "2026-08-17T00:00:00Z"
                  }
                }
              }
            }
          },
          "429": {
            "description": "Per-minute rate limit (`rate_limited`) or daily allowance (`quota_exceeded` / `key_quota_exceeded`) exhausted. `Retry-After` carries the wait in seconds.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "rate_limited",
                    "message": "per-minute rate limit reached",
                    "retry_after": 3
                  }
                }
              }
            }
          },
          "503": {
            "description": "History store unavailable (`history_unavailable`) or data still loading after a restart; retry after `Retry-After`.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait before retrying."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "history_unavailable",
                    "message": "history store is temporarily unavailable"
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "API key from https://ip99.com/en/console. Never put it in the query string (`key_in_query`, HTTP 400)."
      },
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "description": "Same key as `Authorization: Bearer <key>`."
      }
    },
    "parameters": {
      "ip": {
        "name": "ip",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Public IPv4 or IPv6 address.",
        "examples": {
          "v4": {
            "value": "8.8.8.8"
          },
          "v6": {
            "value": "2001:4860:4860::8888"
          }
        }
      }
    },
    "schemas": {
      "IPRisk": {
        "type": "object",
        "description": "Response body of `/v1/ip/{ip}`. Its `risk` object is the same one `/pulse/v1/ip/{ip}` returns — `score` / `level` / `signals`, rendered by the same function; this endpoint just omits the professional extras `evidence[]`, `share_tier` and `resource_pools`, and carries one field Pulse does not: `degraded`, the basic-tier-only marker that says the verdict had to be computed without one evidence source.",
        "required": [
          "ip",
          "risk",
          "evidence_state",
          "computed_at"
        ],
        "properties": {
          "ip": {
            "type": "string",
            "description": "The address looked up, canonical form."
          },
          "risk": {
            "$ref": "#/components/schemas/PulseRisk"
          },
          "evidence_state": {
            "type": "string",
            "enum": [
              "active",
              "stale",
              "none"
            ],
            "description": "`active`: fresh evidence is scoring. `stale`: evidence existed and expired (score 0, weak prior). `none`: never seen (score 0, no information — not safe)."
          },
          "computed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When this verdict was computed (UTC)."
          },
          "network": {
            "$ref": "#/components/schemas/Network"
          },
          "geo": {
            "$ref": "#/components/schemas/Geo"
          },
          "degraded": {
            "type": "boolean",
            "description": "Present and true when one evidence source was unavailable and the verdict was computed without it (the `X-Pulse-Degraded: 1` header is sent as well). Reporting degradation in the body is basic-tier-only: `/pulse/v1/ip/{ip}` has no such field and uses that header alone."
          }
        }
      },
      "Evidence": {
        "type": "object",
        "required": [
          "risk_type",
          "freshness"
        ],
        "description": "One piece of evidence behind the verdict. Every row of `evidence[]` carries exactly five keys: `risk_type`, `freshness` and `obs_days` always, plus `captured_at` and `age_min` when they have a value. No other key is ever sent.",
        "properties": {
          "risk_type": {
            "type": "string",
            "enum": [
              "proxy",
              "vpn",
              "dialup_pool",
              "hijacked_proxy",
              "cloud_service",
              "cloud_phone",
              "osint",
              "honeypot_scan",
              "honeypot_credential",
              "ipv6_range"
            ],
            "description": "Risk type slug. `/v1/ip/{ip}` carries no evidence rows, so this only ever appears on `/pulse/v1/ip/{ip}`, as the risk label of that evidence row; the labels that fired are also listed as slugs in `risk.signals`. `honeypot_scan` / `honeypot_credential` / `ipv6_range` are produced by our own collection and have no counterpart on the profile side; `crawler` only exists there."
          },
          "freshness": {
            "type": "string",
            "enum": [
              "within_2h",
              "within_4h",
              "within_12h",
              "older"
            ],
            "description": "Coarse age bucket of the observation."
          },
          "captured_at": {
            "type": "string",
            "format": "date-time",
            "description": "Exact capture time, RFC 3339. With `?at=` it is the capture reconstructed for that moment. Sent on the professional and private-deployment tiers whenever a value exists; the standard tier never receives it."
          },
          "age_min": {
            "type": "number",
            "description": "Minutes between this evidence and `computed_at`. Same tier rule as `captured_at`."
          },
          "obs_days": {
            "type": "integer",
            "description": "Always present on the professional and private-deployment tiers. Number of distinct days on which this risk type was continuously reported within `(at - 14d, at]` — the 14 days ending at the query moment."
          }
        }
      },
      "Network": {
        "type": "object",
        "description": "Present when the address is in the geolocation database; each key inside it is omitted when unknown, and the block itself is omitted when neither key is known (a zero or an empty string is never sent in place of a missing value). Operator names are not published; the ASN is the identity.",
        "properties": {
          "asn": {
            "type": "integer",
            "description": "Autonomous system number of the address. Omitted when unknown (0 is never returned). Network page: https://ip99.com/en/asn/{asn}"
          },
          "usage_type": {
            "type": "string",
            "description": "Usage type of the segment, passed through unchanged — we do not rewrite it. The 20 scene codes this service has 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. This list is not the set of all possible values: a code outside this table is not an error — it is a code we have no localised name for yet. Returned as-is; treat an unrecognised code as unknown rather than dropping the record."
          }
        }
      },
      "Geo": {
        "type": "object",
        "description": "Where the address is. Every key is omitted when its value is unknown, and the whole block is omitted when nothing is known — 0, an empty string or null is never sent in place of a missing value, so \"the key is absent\" always means \"unknown\". region, city and district come from an English-language place-name database and are returned for addresses in every country; addresses in China additionally carry subdivision and admin_code.",
        "properties": {
          "continent": {
            "type": "string",
            "description": "Two-letter continent code, a closed set: AS / EU / NA / SA / AF / OC / AN."
          },
          "country": {
            "type": "string",
            "description": "ISO 3166-1 alpha-2 code. Country page: https://ip99.com/en/geo/{country}"
          },
          "subdivision": {
            "type": "string",
            "description": "ISO 3166-2 first-level subdivision code, for example CN-ZJ (Zhejiang). Currently only addresses in China carry it: for other countries the geolocation database holds Latin place names but no codes."
          },
          "admin_code": {
            "type": "string",
            "description": "PRC administrative division code (GB/T 2260), six digits, precise down to the district/county, for example 440113 (Panyu, Guangzhou). Present for addresses in China, alongside subdivision."
          },
          "region": {
            "type": "string",
            "description": "Latin name of the first-level administrative area, for example California or Zhejiang. Returned for addresses in every country; for China it sits alongside subdivision and admin_code rather than replacing them. A name the database does not have makes the key absent, not 0."
          },
          "city": {
            "type": "string",
            "description": "Latin name of the city, for example Mountain View or Hangzhou. Returned for addresses in every country. A name the database does not have makes the key absent, not 0."
          },
          "district": {
            "type": "string",
            "description": "Latin name of the district or county, for example Xihu (Hangzhou) or Santa Clara. Returned for addresses in every country, China included. Finer than city, coarser than street. A name the database does not have makes the key absent, not 0."
          },
          "tz": {
            "type": "string",
            "description": "IANA time zone, for example Europe/Zurich."
          },
          "lat": {
            "type": "number",
            "description": "Decimal degrees. lat and lon are always sent as a pair: when the geolocation database has no coordinates for the address 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."
          },
          "lon": {
            "type": "number",
            "description": "Decimal degrees. Paired with lat — see lat."
          }
        }
      },
      "Me": {
        "type": "object",
        "required": [
          "tier",
          "quota_scope",
          "plan",
          "per_min",
          "per_day",
          "quota_day_tz"
        ],
        "properties": {
          "tier": {
            "type": "string",
            "enum": [
              "ip",
              "user",
              "key"
            ]
          },
          "quota_scope": {
            "type": "string"
          },
          "plan": {
            "type": "string"
          },
          "per_min": {
            "type": "integer"
          },
          "per_day": {
            "type": "integer"
          },
          "overage_pct": {
            "type": "integer"
          },
          "quota_day_tz": {
            "type": "string"
          },
          "used_today": {
            "type": "integer"
          },
          "remaining": {
            "type": "integer"
          },
          "over_today": {
            "type": "integer"
          },
          "reset_unix": {
            "type": "integer"
          },
          "key_prefix": {
            "type": "string"
          },
          "created_at": {
            "type": "string"
          },
          "key_per_day": {
            "type": "integer"
          },
          "capabilities": {
            "type": "object",
            "additionalProperties": true
          },
          "sales_contact": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable lowercase identifier — branch on this, never on `message`."
              },
              "message": {
                "type": "string",
                "description": "English prose; may change."
              },
              "retry_after": {
                "type": "integer"
              },
              "resets_at": {
                "type": "string"
              },
              "next_step": {
                "type": "string"
              },
              "signup_url": {
                "type": "string"
              },
              "pricing_url": {
                "type": "string"
              },
              "console_url": {
                "type": "string"
              }
            },
            "additionalProperties": true
          }
        }
      },
      "PulseRisk": {
        "type": "object",
        "description": "The `risk` object returned by both `/v1/ip/{ip}` and `/pulse/v1/ip/{ip}`: same keys, rendered by the same function, so one parser reads either endpoint. Risk labels are not repeated here; on the professional endpoint they ride on the evidence rows as `evidence[].risk_type`, and the ones that are also signals come back again in `signals`.",
        "required": [
          "score",
          "level"
        ],
        "properties": {
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "Freshness and strength of verifiable risk evidence, decaying with its age."
          },
          "level": {
            "type": "string",
            "enum": [
              "none",
              "low",
              "medium",
              "high"
            ],
            "description": "none (0) · low (1–29) · medium (30–69) · high (70–100)."
          },
          "signals": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Risk signal slugs that are currently flagged, sorted alphabetically. Only the ones that fire are listed, so when nothing fires the key is absent entirely. The eight risk labels are `proxy` (currently observed in proxy supply: an open proxy, a rotating pool or a resold exit), `vpn` (observed VPN infrastructure, usually a fixed exit on hosting space), `dialup_pool` (broadband lines that redial to pick up a fresh address per connection), `hijacked_proxy` (a real subscriber's device relaying traffic without its owner's knowledge), `osint` (listed in public threat-intelligence sources), `crawler` (search-engine or known crawler infrastructure), `cloud_service` (cloud function / serverless egress) and `cloud_phone` (cloud-phone, i.e. virtual device farm, egress); `hosting` (datacenter or cloud address space) and `mobile` (mobile carrier address space) describe the network and are not a risk verdict on their own. Both endpoints return the same slugs, from the same rendering function. Replaces the former boolean `signals` object that sat at the top level."
          }
        }
      },
      "PulseIPRisk": {
        "type": "object",
        "description": "Response body of `/pulse/v1/ip/{ip}` (professional tier). Same engine, same score and the same `risk` object (`score` / `level` / `signals`) as `/v1/ip/{ip}`; this endpoint adds the professional extras `evidence[]`, `share_tier` and `resource_pools`. Risk labels live on the evidence rows as `evidence[].risk_type`, and the ones that are also signals come back again in `risk.signals`.",
        "required": [
          "ip",
          "risk",
          "evidence_state",
          "computed_at",
          "share_tier",
          "evidence"
        ],
        "properties": {
          "ip": {
            "type": "string",
            "description": "The address looked up, canonical form."
          },
          "risk": {
            "$ref": "#/components/schemas/PulseRisk"
          },
          "evidence_state": {
            "type": "string",
            "enum": [
              "active",
              "stale",
              "none"
            ],
            "description": "`active`: fresh evidence is scoring. `stale`: evidence existed and expired (score 0, weak prior). `none`: never seen (score 0, no information — not safe)."
          },
          "computed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When this verdict was computed (UTC)."
          },
          "network": {
            "$ref": "#/components/schemas/Network"
          },
          "geo": {
            "$ref": "#/components/schemas/Geo"
          },
          "evidence": {
            "type": "array",
            "description": "Per-evidence detail, professional tier only. Sorted strongest first; empty array when there is no evidence. `risk_type` is the risk label of that row; the labels that fired also appear as slugs in `risk.signals`, which lists only the ones that fire.",
            "items": {
              "$ref": "#/components/schemas/Evidence"
            }
          },
          "share_tier": {
            "type": "string",
            "description": "Address sharing tier, `T0` to `T3`."
          },
          "resource_pools": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Opaque resource-pool handles this address belongs to. Present only when it hits a pool."
          }
        }
      }
    }
  }
}
