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:
- You cannot mix resolutions in one request. All cells in an
h3=list must be at the same resolution, because merging two would double-count the area a coarse cell shares with the fine cells inside it. res=is refused alongsideh3=. The cell already answered that question, and accepting a second opinion would only create a way to disagree.
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¤cy=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:
- Every entry carries a
geo_idyou can spend directly. If a search lands you one level too fine, you do not need another lookup — the parent's id is already in your hand. Send it to/stats/current, or back to/geo?parent=. - Never match on
namealone. Match ongeo_id. A name is a label for a person to read;geo_idis the identity. Deduplicating a result set by name silently merges two different markets into one.
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¤cy=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.