Geography

This API addresses the world in two ways: by H3 cell and by named place. Most work starts with a place and ends with figures.

Everything under /geo is free and uncounted. Everything under /stats is metered. That line is deliberate, and it decides how this page is organised: use the free calls to find out exactly what you can ask for, then spend one metered call on the right question.

Discovery never returns a price

No /geo response contains a median, an average or any other figure. It tells you whether a place can be answered, and how far its data reaches. The numbers come from /stats/current and nowhere else.

What an H3 cell is

H3 divides the globe into hexagons. Each hexagon has an id, and each id encodes its own size — its resolution. A coarse cell covers a region; a fine one covers a few streets.

Hexagons rather than squares, because every neighbour of a hexagon is the same distance away. That matters here: when a cell has too few listings, its figures are pooled from its six neighbours, and with squares those neighbours would sit at two different distances.

This API holds data at resolutions 4 to 8. Outside that range a request is refused rather than answered emptily.

Resolution Average cell area Roughly
4 ~1,770 km² a province
5 ~253 km² a large city and its surroundings
6 ~36 km² a city district
7 ~5.2 km² a neighbourhood
8 ~0.74 km² a few streets

Resolution 8 is the default. Two things follow from an id encoding its own resolution:

Three ways in

You have Use
nothing — no name at all /geo, and walk down
a name /geo/search
coordinates or a cell id /geo/lookup

The first one is the important one, and it is the one most clients skip. If you do not have a name, do not guess one. A guessed name that misses returns unknown_place, and unknown_place looks the same whether the place does not exist, the spelling is wrong, or the whole country is not covered. Walking down the tree cannot produce any of those three, because every id you send came out of the previous response.

Nothing to search for: walk down the tree

/geo with no parameters is the root. From there, each result carries a geo_id you pass back as parent to go one level deeper.

The whole walk, from nothing to a metered call:

# Call Answers
1 GET /geo which countries have a named place tree
2 GET /geo?parent=es that country's top level
3 GET /geo?parent=R349043&level=city its cities
4 GET /geo?parent=R344953 what sits under that city
5 GET /stats/current?geo_id=… the figures. This one is metered

1. The root

curl -s https://api.investviews.ai/public/v1/geo \
  -H "Authorization: Bearer {TOKEN}"
{
  "results": [
    { "kind": "country", "geo_id": "es", "name": "ES", "level": "country",
      "country": "es", "prices_available": true },
    { "kind": "country", "geo_id": "pl", "name": "PL", "level": "country",
      "country": "pl", "prices_available": true }
  ]
}

A country row is deliberately thin: an id, a name, and whether there is anything behind it. There is no ancestors and no hexes_url, because a country has no ancestors and its cell list would run to millions of entries.

2. One country down

The geo_id of a country is its ISO 3166-1 alpha-2 code, lower case. Pass it as parent.

curl -s "https://api.investviews.ai/public/v1/geo?parent=es" \
  -H "Authorization: Bearer {TOKEN}"
{
  "parent": { "geo_id": "es", "name": "ES", "level": "country", "country": "es" },
  "results": [
    {
      "kind": "zone",
      "geo_id": "R349043",
      "name": "Comunitat Valenciana",
      "level": "region",
      "country": "es",
      "ancestors": [
        { "geo_id": "es", "name": "es", "level": "country" }
      ],
      "h3_res": 8,
      "hexes_url": "/public/v1/geo/R349043/hexes",
      "current_period": "2026-07-01",
      "prices_available": true,
      "reports_available": true
    }
  ]
}

Every response to a parent= request echoes a parent object. You never have to keep your own record of where you are in the tree.

3. Filter the step to one level

level= filters the children you get back. It does not change how far down you go.

curl -s "https://api.investviews.ai/public/v1/geo?parent=R349043&level=city" \
  -H "Authorization: Bearer {TOKEN}"
#   → geo_id "R344953", name "València", level "city"

The five levels, coarse to fine, are region, province, city, macrozone, microzone. A macrozone is a district; a microzone is a neighbourhood.

Children are the next non-empty level, which differs by branch

Levels are skipped, not shifted. A city whose province is blank hangs directly off its region. So parent=<region>&level=province can be empty while parent=<region> returns cities. Ask for children and read the level you got back, rather than assuming which one you landed on.

4. Keep going until the results are empty

curl -s "https://api.investviews.ai/public/v1/geo?parent=R344953" \
  -H "Authorization: Bearer {TOKEN}"
#   → geo_id "R4231821", name "Eixample", level "macrozone"

curl -s "https://api.investviews.ai/public/v1/geo?parent=R4231821" \
  -H "Authorization: Bearer {TOKEN}"
#   → geo_id "R14727511", name "Russafa", level "microzone"

An empty results array means you have reached a leaf. That is a real answer, not a failure — stop there and use the id you already hold.

5. Spend the metered call

curl -s "https://api.investviews.ai/public/v1/stats/current?geo_id=R14727511&currency=EUR" \
  -H "Authorization: Bearer {TOKEN}"

Steps 1 to 4 cost nothing. Only step 5 draws down quota.

Browsing never errors on a branch that is not there

An unresolvable parent, a page past the end, or a malformed parameter all return an empty results array with 200. Exploring is meant to be safe, so a wrong turn costs one free call and tells you to turn back.

Paging is page= (1-based) with limit= (default 50, maximum 100). A page beyond 10,000 returns [] rather than repeating the last page.

You have a name: search

/geo/search resolves it.

curl -s "https://api.investviews.ai/public/v1/geo/search?q=russafa&country=es" \
  -H "Authorization: Bearer {TOKEN}"

Matching is trigram-ranked, so a near miss still resolves, and diacritics and casing are handled. A query under two characters returns an empty result rather than scanning the whole tree. limit defaults to 10 and is capped at 50.

Results are ranked zones, in the same shape the cascade returns. If the tree misses entirely, the request falls back to a geocoder and returns a point instead, which is a different shape — check kind and handle both.

country= is a filter that is honoured, not a hint

An unknown country code is refused with invalid_parameter. A valid code that is simply the wrong one returns nothing, because the filter is applied rather than dropped. If a search you expected to work comes back empty, drop country and level before concluding the place does not exist — a contradicting filter is the commonest reason a real place misses.

Names are not unique

13.6% of neighbourhood names repeat inside their own country. That is not an edge case; it is one name in seven. Measured across the tree: 12,233 of 89,965 (country, name) pairs at microzone level cover two or more different parent cities. At macrozone level it is 10.0%. Names repeat across borders too, though far less often: 344 city names and 554 microzone names are in use in two or more countries.

So a search for a common name returning several results is normal and correct. The API is not being vague. It is telling you that the name alone does not identify a place.

ancestors is how you tell them apart. It is an array of objects, ordered fine to coarse, ending at the country:

"ancestors": [
  { "geo_id": "R344953", "name": "València",             "level": "city" },
  { "geo_id": "R349043",   "name": "Comunitat Valenciana", "level": "region" },
  { "geo_id": "es",      "name": "es",                   "level": "country" }
]

Two rules for using it:

When a search for centro returns eleven results, the answer is not "take the first one". Read ancestors on each, find the city you meant, and use that row's geo_id.

prices_available: false means stop

Every zone, country and point result carries prices_available. It is a boolean, and it is the cheapest thing in this API.

Do not spend a metered call on a place that reports prices_available: false

There is nothing there. /stats/current will answer 200 with an empty stats array, you will have paid for it, and an empty array reads exactly like "this market has collapsed" to anything downstream. Check the boolean first; it costs nothing.

prices_available: true is necessary, not sufficient. It says listings exist in that place. Each period still applies its own minimum-listing floor on top, so a very thin place can pass this check and still return no rows. It is a filter that saves wasted calls, not a guarantee.

The same field name means two things, in two places

On a place (/geo, /geo/search, /geo/lookup) it means this place holds listings. On a country row from /coverage it means some source ingested listings for this market in the last 365 days. Both are honest; they answer at different grains. Do not carry a value from one into the other.

reports_available is the same idea for the report pipeline: whether that country's reports can be generated at all.

current_period — whether it is worth asking right now

current_period names the window /stats/current would answer from for this place. It is null when that endpoint holds nothing for the place in the window it is currently serving.

null means nothing in the current period — NOT nothing ever

A place reporting current_period: null may still hold older windows that /stats/history will serve happily. The field is called current_period and not latest_period for exactly this reason: answering "how far back does this place go" means confirming absence across every stored period, which costs roughly thirty times what a metered call costs — far too much for a free endpoint. So this field answers the cheap question only, and answers it honestly.

Read it beside prices_available and the two give you a three-state signal:

prices_available current_period What to do
true a date Ask /stats/current — this is the window it will answer from
true null Listings exist, but too few cleared this period's floor. /stats/history may still answer
false null Dead end. Do not spend a metered call

It is a date label, not a price. Nothing on /geo is a price, a median or a listing count — every figure lives behind the metered /stats endpoints. What discovery tells you is whether asking is worth it, never what the answer is.

A period label is the first day of the month the period is named for. What that label covers — one calendar month at resolutions 4–6, a rolling three-month window at 7–8 — is on the Data page, and every /stats response restates it in its own snapshot block.

Your country is not in /geo

GET /geo lists countries that have a named place tree. A country where this API holds listings but has never imported geographic names is absent from that list entirely.

That is the one gap the cascade cannot express, because absence looks the same as "we have never heard of that country". /coverage is where the difference is answered. It is free, and it is the call to make before concluding a market does not exist.

curl -s "https://api.investviews.ai/public/v1/coverage?country=ae" \
  -H "Authorization: Bearer {TOKEN}"
{
  "window_days": 365,
  "freshness": "These flags are computed live. …",
  "coverage": "This list is the markets this API serves. …",
  "countries": [
    {
      "country": "ae",
      "h3_resolutions": [4, 5, 6, 7, 8],
      "last_ad_parsed_at": "2026-09-08T04:11:07Z",
      "searchable": false,
      "prices_available": true,
      "reports_available": false
    }
  ]
}

Two booleans, four states, and each one calls for a different next move:

prices_available searchable What it means What to do
true true the normal case search or browse by name
true false listings exist, but no name in that country resolves address it by lat/lng/radius_km, or by h3=
false true names resolve, no recent listings pick another market
false false nothing usable here pick another market

The second row is the one worth knowing about. The United Arab Emirates is the standing example: around 1.86 million listings and zero named zones. /geo/search?q=dubai cannot resolve, GET /geo does not list ae, and the market is there all the same.

A country missing from /coverage too is a market this API does not serve. Listings from it may still exist behind the scenes — portals list across borders — but no name will resolve there. Omitting it is the honest answer; enumerating every country on earth to say "not this one" is not an option.

last_ad_parsed_at is when that market last ingested listings, and it is null when no source targets the market directly. It is about the raw inventory, not about the built statistics — see Data for the difference.

Three failures sit on this same distinction, and they are worth reading together:

Code Says Next move
unknown_place that name did not resolve inside a market we do cover check did_you_mean, or browse with /geo
not_covered we hold no geographic data for that country pick another market — retrying cannot succeed
invalid_parameter on country that is not an ISO 3166-1 alpha-2 code fix the code

You have a cell: look it up

/geo/lookup is the reverse: given a cell or a coordinate, say what it is called.

curl -s "https://api.investviews.ai/public/v1/geo/lookup?h3=8839540ad1fffff" \
  -H "Authorization: Bearer {TOKEN}"

curl -s "https://api.investviews.ai/public/v1/geo/lookup?lat=39.46&lng=-0.37&res=8" \
  -H "Authorization: Bearer {TOKEN}"

The cell id may be decimal (613498079267520511) or a canonical H3 string (8839540ad1fffff) — both name the same cell. With h3= the resolution comes from the cell itself, never from a parameter. With lat/lng there is no cell yet, so res= picks one, and it defaults to 8.

This returns the point object directly, not wrapped in results: there is exactly one answer for a cell. Its zones array holds the named zones containing that cell, each with its own geo_id and ancestors — that is what turns a point into something you can query. A bare point is a dead end.

A place is not its cells

A search or cascade result gives you hexes_url, not the hex list. That is deliberate. A city is thousands of cells at resolution 8, and inlining them would spend an agent's entire context on a discovery call.

So there are two paths, and most of the time you want the first:

You want Do this
figures for a place pass geo_id straight to /stats/current
the actual cell ids follow hexes_url

/geo/{geo_id}/hexes is paginated. Follow the cursor the response gives you rather than constructing one — a cursor the API did not issue for that query is refused with 400 invalid_cursor.

A country geo_id is refused here, with invalid_parameter and a message naming the two things to do instead. A country is millions of cells; query it directly with /stats/current?geo_id=es, or drill into it with /geo?parent=es.

Below a zone's own resolution, cells extend past the boundary

Ask for a place at a resolution coarser than the registry stores it at, and the cells you get are parent roll-ups that reach beyond the place boundary. The response says so in territory.rolled_up_from and territory.rollup_note. Read those before reporting the figures as belonging to that place alone.

Worked example

"I want prices for a neighbourhood in Valencia." You do not know its name.

# 1. which countries have a tree  (free)
curl -s https://api.investviews.ai/public/v1/geo \
  -H "Authorization: Bearer {TOKEN}"
#    → "es"

# 2. Spain's top level  (free)
curl -s "https://api.investviews.ai/public/v1/geo?parent=es" \
  -H "Authorization: Bearer {TOKEN}"
#    → R349043  "Comunitat Valenciana"  region

# 3. its cities  (free)
curl -s "https://api.investviews.ai/public/v1/geo?parent=R349043&level=city" \
  -H "Authorization: Bearer {TOKEN}"
#    → R344953  "València"  city

# 4. down to the neighbourhoods  (free)
curl -s "https://api.investviews.ai/public/v1/geo?parent=R344953" \
  -H "Authorization: Bearer {TOKEN}"
#    → R4231821  "Eixample"  macrozone
curl -s "https://api.investviews.ai/public/v1/geo?parent=R4231821" \
  -H "Authorization: Bearer {TOKEN}"
#    → R14727511  "Russafa"  microzone
#      prices_available true, current_period "2026-07-01"

# 5. ask for the market  (metered — the only call that costs)
curl -s "https://api.investviews.ai/public/v1/stats/current?geo_id=R14727511&currency=EUR" \
  -H "Authorization: Bearer {TOKEN}"

Every id in that sequence came out of the previous response. Nothing was guessed, nothing was built by hand, and the only call that cost anything was the last one.

If you do already know the name, steps 1 to 4 collapse into one /geo/search?q=russafa&country=es — but read ancestors on each result before picking one, because "Russafa" is exactly the kind of name that repeats.

Addressing a point instead

When you have coordinates rather than a name, /stats/current takes a circle:

curl -s "https://api.investviews.ai/public/v1/stats/current?lat=39.46&lng=-0.37&radius_km=3" \
  -H "Authorization: Bearer {TOKEN}"

All three of lat, lng and radius_km are required together, and the radius is in kilometres, at most 500. A radius question is a local question — address a whole country with geo_id, which knows the real boundary.

This is also the way into a market whose searchable flag is false: the names are missing, the listings are not.

Next