API changelog

Every change to the API, newest first. The same history is available as JSON at /api/v2/meta/changelog, and /api/v2/meta/version reports what this deployment answers as. See the reference for every field, and the guides for how to use them.

Versions follow semver, read from your side. A minor adds a field or a resource and leaves every existing one alone, so ignoring a field you do not recognise is always safe. A major renames a field, removes one, or changes what one means, and arrives at a new URL prefix. /api/v2/ is canonical, and the unversioned paths and /api/v1/ still resolve and answer, so nothing pointed at them returns a 404. They serve the same data as v2: one response model serves every prefix, so an older prefix cannot be frozen.

Outside the contract

Two groups of fields move without a version bump. Both are stated here so nothing is built on them as though they were stable.

  • Score valuesscores.abuse.value and scores.concealment.value move whenever the evidence behind them is weighed differently. Pin the band or the factors, since an exact number pins a judgement.
  • Proseanalysis.text, every summary, every warning, possibilities[].text and insights[].text are written for a reader and get rewritten when the wording can be improved. Every figure they mention is also a structured field, so read those.

2.3.02026-08-31current

Batch lookups, and paid plans. `POST /lookup/batch` answers up to 100 addresses in one request on the plans that carry it.

  • AddedPOST /lookup/batch takes {"ips": [...]} and returns one entry per address, in the order you sent them. Each entry carries either a result or an error, so one malformed address fails alone instead of failing the request. Repeats are looked up once and charged once.
  • Added**One token per distinct valid address in a batch.** charged in the response says exactly what it cost, and rejected addresses are not charged. X-RateLimit-Cost carries the same number.
  • Changed**A route can now be refused for the plan you are on**, with a 403 and a sentence naming the plans that carry it. Nothing is charged against your allowance for a 403. Batch is the first route this applies to; every existing route is unaffected and stays available on every plan, free included.
  • Changedenrich is not accepted on batch. It makes a live registry query per address, and a hundred of those in one request is a minute spent inside somebody else's service. Use GET /lookup/{ip}?enrich=true for the addresses that need it.

2.2.02026-08-27

Accounts. Keys are issued from a signup form instead of by email, the allowance belongs to the account rather than to each key, and the unauthenticated allowance drops from 300 requests a day to 50.

  • AddedSelf-service keys. Create an account at /signup, confirm the address, and issue or revoke keys from /account. Previously a key was requested by email and issued by hand.
  • Changed**The unauthenticated allowance is now 50 requests a day**, down from 300. A confirmed free account is 1,000 a day and 60 a minute. A 429 now says what would raise your limit, and Retry-After is still the only number you need to act on.
  • Changed**An account's allowance is one pool, shared by every key it holds.** Quota was previously counted per key, so holding three keys meant holding three allowances. Use a key per environment freely: it changes what the usage figures on /account can tell you apart and not what you are charged.
  • ChangedKeys issued before this release keep the allowance they were issued with, 600 a minute and 20,000 a day, written explicitly against each key rather than inherited from a default.

2.1.02026-08-27

Reverse DNS is served from a cache and resolved in the background, so a lookup no longer waits on it, and `reverse_dns.warning` now says when the field is empty because we have not looked rather than because there is no hostname.

  • Addedreverse_dns=true on GET /lookup/{ip} and GET /lookup/self waits for the PTR lookup instead of taking whatever is cached. Costs up to half a second more latency and is the way to guarantee the field reflects a resolution attempt made for this request.
  • Changedreverse_dns.value is now served from a cache with a seven-day life, and an address we have never resolved is queued and answered on a later request. Previously every uncached lookup resolved inline, which cost 79 ms of a 285 ms response to return a hostname about a quarter of the time.
  • Changedreverse_dns.warning distinguishes the three reasons the field can be empty: the address has no PTR record (no warning, it is a real answer), we have not looked yet, the resolver did not answer, or reverse DNS was busy. These were previously indistinguishable, so a client making many concurrent requests received hostnames on 4% of addresses where a slower client received them on 25%, with nothing in the response saying so.

2.0.02026-08-26

Geolocation moves to two independent sources that must agree before a city is published, coordinates are checked against the country named beside them, and the accuracy radius is removed with the source that supplied it.

  • Changedlocation.lat and location.lon are withheld when the point falls outside the country the answer names. The agreement rule validated the country and never looked at the point, so one block published country: GB, city: London with coordinates in Madison, Wisconsin, under a confirmed grade. Boxes are derived from the geolocation source's own distribution per country and widened by five degrees, so 247 IPv4 blocks are affected and territory like Kaliningrad, Chukotka and Hawaii stays inside its own country. The country, region and city are unchanged: the country is what the two sources agreed on and the city is consistent with it. country.alternative.lat and .lon get the same check against the country that reading names, where before they carried none at all.
  • Addedlocation.precision and location.block_prefix. country.evidence grades whether two sources agree on the country and says nothing about how much address space one city label is being stretched across, so 8.8.8.8 (a /24) and a city read off a /8 covering 16,777,216 addresses produced byte-identical output. precision is block, area or region; block_prefix is the smallest prefix that actually contains the block, so the grade can be checked rather than trusted. Measured: 26.6% of city-bearing IPv4 space sits in blocks wider than a /16, and 59 blocks carry a city across 348 million addresses between them.
  • ChangedThe announcing AS and the operator name beside it come from a different upstream. The previous one was retired over its licence, which restricted disclosing the data to third parties and therefore could not support a paid API. Measured against it before the switch: 12.6% more IPv4 address space covered, 85,219 distinct networks against 78,545, and agreement on the announcing AS for 98.81% of the 3.12 billion IPv4 addresses both cover. So asn and isp change on roughly one address in a hundred, almost always where the old source had no answer.
  • Addedshare_of_covered_v4 and share_of_covered_v6 on every breakdown row. share_v4 divides the whole scope, which on a scope where a category covers 1% of the space makes every row read as a rounding error. These divide the covered part instead, which is what the site's own tables now show, so a caller comparing the two finds them agreeing. Null where the labels overlap, since dividing by the union there pushes every row towards 100%, and null where coverage is unknown.
  • FixedA breakdown with no rows said "No data for this breakdown" on the site, which reads as a failure. It now says what is absent, in the same words the remainder row uses when something is present.
  • RemovedGET /meta/freshness is gone. It published, per dataset, when each source last refreshed, which is a map of the ingestion pipeline and of which vendors sit behind which answers. GET /meta/ready replaces the one use that mattered: it runs a query, so an unreachable database shows as an unhealthy container rather than a green one serving 500s, and it publishes nothing but whether that query worked. /meta/health is unchanged and stays outside the rate limiter.
  • Removedlocation.accuracy_radius_km is gone from the response. The geolocation source that published it was retired, no remaining source publishes a confidence radius, and fitting one from the old values was measured and rejected: it understated the radius by 5x or more on 12.8% of blocks, always in the direction of claiming more precision than the data supports. country.evidence answers the same question with evidence behind it. Removed rather than kept as a permanent null, because a key that is always null invites a caller to build on it and then breaks them quietly. It is gone from /api/v1/ and the unversioned paths too, which is why those can no longer serve v1's original contract.
  • Addedcountry.evidence: confirmed when two independent sources name the same country, contested when they disagree, single_source when only one has an answer. Validated against a third source held out of the decision: on confirmed space it agrees with the published country 99.6% of the time, on contested space 61.2%. The withheld reading in alternative is the one it backs 36.1% of the time.
  • Changedcity, region and location are populated only when country.evidence is confirmed. A city belongs to the country it sits in, so publishing one source's city beside another's country would render a false answer rather than a vague one. This affects 19.72% of requests, well above the 5.53% of address space it covers, because the addresses people look up most are exactly the contested ones.
  • Addedcountry.alternative carries the reading that was not published when evidence is contested, with its country, region, city and coordinates. The prose warning says the same thing, and a client cannot parse prose.
  • Changedcountry.code now resolves from whichever of two independent sources proves more reliable where they differ, rather than from a single vendor. Coverage is unchanged: the two describe 99.99% of the same IPv4 space. Which one wins depends on the space: across IPv4 as a whole one is right 61.2% of the time against 36.1%, and on IPv6 91.4% against 5.7%, but that reverses on hosting and anonymiser ranges where the winner falls back to the registry's delegation country. Roughly 340 million IPv4 addresses, 9.2% of routable space, resolve by the second rule. Neither source is named in a response.

1.3.02026-08-24

The full distribution behind every breakdown, rather than the top rows the pages show.

  • AddedGET /directory/{scope_kind}/{scope_key}/{breakdown} returns every label a scope holds, 100 to a page: countries, organisations, flags, vpn-providers, networks, operators or hosts. NordVPN reported 149 exit countries and could previously return 23 of them.
  • ChangedNothing is truncated when the aggregates are written. 2,104 of 304,305 breakdowns were capped and hid 305,584 label rows, so this changes nothing for 99.3% of the directory and everything for the scopes that matter most.
  • Addedtotal and distinct_total are both reported on a breakdown page: what can be paged through, and the count from the database. They agree now that every label is stored, and a caller can see it rather than take a paged-through length for a count.
  • ChangedThe profile resources are unchanged. They still carry a top-N with the true count beside it, because a country profile that inlined its 17,343 networks would be a multi-megabyte document for a caller who wanted its user population.
  • AddedEvery query and path parameter carries a description. q and page on /directory/search had none, so pagination was visible in the schema with no statement of what a page held or how many there were. Both are now documented on every paged route.

1.2.02026-08-24

The directory becomes part of the API. Five entity resources publish the aggregates that previously existed only as web pages.

  • AddedGET /directory/asn/{asn}, /directory/isp/{slug}, /directory/org/{slug}, /directory/country/{code} and /directory/vpn/{slug}. Each returns the entity's own facts plus breakdowns of its address space by country, by registered organisation, by anonymizer signal and by VPN operator.
  • AddedEvery breakdown arrives as a BreakdownTable carrying rows, distinct_total, truncated, coverage, residual and overlaps. The rows are a top-N, so distinct_total is the count to quote, and coverage is the fraction of the whole scope the table divides up.
  • AddedRows and insight links carry api_url beside page_url, so a response can be walked without rebuilding URLs from display labels.
  • AddedGET /meta/version and GET /meta/changelog publish this history, so a client can check what it is talking to.
  • Added/api/v1/ is now the canonical prefix. Unversioned paths continue to answer as v1 and will keep pointing at v1.
  • ChangedGET /directory/search now spends rate limit quota. It was the one JSON route with no limit on it while carrying the most expensive read on the API.
  • AddedEvery field in every response now carries a description in the schema. None did before, so the reference rendered names and types and nothing that said what a field meant.
  • AddedX-API-Key is declared as a security scheme instead of an undocumented header, so the reference names the credential and offers a way to send one.
  • AddedEvery metered operation documents its 429, with the rate limit headers it carries. One of eight did before.
  • ChangedThe API reference is rendered by Scalar, served from this deployment. Nothing on the page is fetched from anywhere else: its font CDN and its request proxy are both switched off, and its AI assistant, which uploads the document to a third party, is removed.
  • AddedGET /directory/search returns a documented shape. It answered with an untyped object, so none of its fields were described. hits[] also gained api_url beside href.
  • FixedGET /directory/search answers with one shape. An empty query used to omit scope and truncated while every other query returned them.
  • AddedKeys are issued by email while there is no signup or billing. The reference says so, and says that a key is never required.
  • ChangedEvery route is now rate limited, including /meta/version, /meta/changelog and /meta/freshness, which were unmetered. /meta/health stays exempt on purpose: the limiter reads and writes a bucket row, so metering a liveness probe would make it fail whenever the database is unreachable.
  • AddedRequests are weighted. X-RateLimit-Cost reports what a call was charged, so X-RateLimit-Remaining dropping by more than one is explained rather than surprising. GET /directory/search costs 2, being a ranked window across every category with no precomputed answer behind it; everything else costs 1.

1.1.02026-08-23

Context around an address: what surrounds it, how it is routed, and how good the evidence is.

  • Addedanalysis, describing the announcing network, its operator and its country, as structured fields and as prose.
  • Addedrouting, the BGP view: the most specific announced prefix, every AS originating it, whether more than one does, and how widely it is seen. announced: false is a finding: no collector sees a route to the address.
  • Addedpossibilities, operators or activity the surroundings suggest, each with the count behind it. Every entry is an inference drawn from neighbouring addresses.
  • Addedanonymizer.vpn_evidence, vpn_evidence_label and anonymizer.evidence, grading each flag as confirmed, listed or inferred.
  • ChangedConcealment scores move with the evidence grade and with the network. A flag resting on a wide block counts for less than one resting on a provider's own exit list.
  • ChangedCorrelated flags from one wide block count once. Summing them scored 72 out of 100 on ordinary residential addresses.
  • Changedhosting.is_hosting requires better than an inference, because it is a flat claim about a connection.

1.0.02026-08-21

First published version.

  • AddedLocation, network, operator, registered holder, reverse DNS, network type, hosting and anonymiser flags, reputation, and the two scores.