API Reference
Endpoint reference
Base URL https://geoapi.mcpke.dev/api. The dataset endpoints below — areas, population,
coverage, places, and tiles — require no credentials of any kind. An API key is needed only
for the account endpoints that read your own usage.
Conventions
Response envelope
Every JSON endpoint returns { "results": …, "meta": { "took_ms": … } }. Collection endpoints add total_results, and paginated ones also return total_available, limit, and offset so a dataset can be walked to the end.
GeoJSON is not enveloped
Adding format=geojson to /v1/areas, /v1/areas/{id}, or /v1/places returns a standard GeoJSON FeatureCollection (or Feature) at the top level, with no results/meta wrapper — so the response can be handed straight to a mapping library. Because a FeatureCollection is returned whole rather than paged, /v1/areas bounds it: a request matching more than 10,000 areas is refused with 400 too_many_features rather than silently truncated. Narrow it with country and admin_level, use the vector tiles for map rendering, or page the JSON list instead.
Absent values are omitted
Optional fields such as population, density, and area_km2 disappear from the response entirely when no value has been recorded. They are never emitted as 0 or null. "Not measured" and "measured as zero" are different claims and the API keeps them distinct.
Rate limiting
The public dataset endpoints are limited per client address, 120 requests per minute by default. The limit exists to prevent abuse; there is no paid tier to raise it into.
Areas
An area is one administrative unit at one level of a country's hierarchy. Areas are the addressing system for everything else in the API: population, coverage, and tiles are all keyed on an area identifier.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/areas | Search and list areas |
| GET | /v1/areas/{id} | One area, its lineage, what is inside it, and where its figure came from |
| GET | /v1/extent | Bounding box of everything currently imported |
Query parameters — /v1/areas
| Parameter | Type | Description |
|---|---|---|
| q | string | Free-text name search. Trigram-matched, so partial and misspelled names still rank. |
| country | string | ISO 3166-1 alpha-2 country code. Case-insensitive. |
| admin_level | integer | Restricts results to one level of the administrative hierarchy (1 = the top level a country publishes). |
| parent_id | integer | Returns only the areas directly inside this one. Safe to call on any id, including a leaf: an area with no subdivisions answers with an empty list, never with every area. meta.total_available reflects the filtered set, and the value is echoed back as meta.parent_id. A value that is not a positive integer is a 400 rather than an ignored filter. |
| limit | integer | Page size. Defaults to 50, capped at 500. A larger request clamps down to 500 rather than falling back to the default. |
| offset | integer | Rows to skip. Combine with meta.total_available to page through a level exhaustively. |
| format | string | Set to geojson to receive boundary geometry as a FeatureCollection instead of a JSON list. |
| simplify | number | Geometry simplification tolerance in metres, applied only to GeoJSON responses. Larger values return smaller payloads. |
List areas at one level
curl "https://geoapi.mcpke.dev/api/v1/areas?admin_level=1&limit=1"
{
"results": [
{
"id": 9,
"country_code": "ID",
"admin_level": 1,
"level_name": "Provinsi",
"name": "Daerah Istimewa Yogyakarta",
"slug": "daerah-istimewa-yogyakarta",
"geom_source": "cod-ab",
"area_km2": 3170.65,
"population": 3793052,
"density": 1196.3,
"centroid": { "lng": 110.4, "lat": -7.87 },
"bbox": [110.0, -8.2, 110.85, -7.55]
}
],
"meta": { "took_ms": 6, "total_results": 1, "total_available": 38, "limit": 1, "offset": 0 }
} level_name is the label the country itself uses for that level, so it is
returned in the local administrative vocabulary rather than translated into a generic tier
name. meta.total_available is the row count matching the query before paging,
which is what makes exhaustive iteration possible.
bbox is [min_lng, min_lat, max_lng, max_lat] — GeoJSON order, and
the same order the bbox query parameter accepts, so a response can be fed
straight back into a query or into a map client's fitBounds.
centroid is guaranteed to lie on the area rather than at its centre of
mass, which for an archipelago or a crescent-shaped area is not the same point: use it to
place a label or a marker, and bbox to frame a view. Both are omitted for an
area with no usable geometry.
One area, with its lineage, subdivisions and provenance
curl "https://geoapi.mcpke.dev/api/v1/areas/42"
{
"results": {
"id": 42,
"country_code": "ID",
"admin_level": 2,
"name": "Kota Banda Aceh",
"slug": "kota-banda-aceh",
"geom_source": "cod-ab",
"area_km2": 61.36,
"population": 257635,
"density": 4198.5,
"centroid": { "lng": 95.32, "lat": 5.55 },
"bbox": [95.27, 5.51, 95.38, 5.61],
"ancestors": [
{ "id": 1, "name": "Aceh", "admin_level": 1, "level_name": "Provinsi", "level_name_en": "Province" }
],
"children": {
"count": 9,
"with_population": 9,
"level": 3,
"level_name": "Kecamatan",
"level_name_en": "District"
},
"resolved": { "area_id": 42, "value": 257635, "year": 2024, "method": "projected", "source_id": "geonames" },
"observations": [
{ "id": 501, "area_id": 42, "source_id": "geonames", "value": 257635, "year": 2024, "method": "projected" },
{ "id": 502, "area_id": 42, "source_id": "kontur", "value": 115131, "year": 2023, "method": "modelled" }
]
},
"meta": { "took_ms": 9 }
} observations is every figure on record for the area, unfiltered.
population at the top level is the resolved value produced by the precedence
rule — not an average and not the most recent import, and resolved is the row
that produced it, so a client showing one number can say where the number came from.
source_id values map to a provider, a licence, and a required attribution
string on Data sources.
ancestors runs outermost-first and excludes the area itself, so it renders as
a breadcrumb without slicing; it is an empty array for a top-level area, never
null. children is a summary rather than a list —
count is what lets you decide whether to offer a subdivisions control at all,
instead of requesting a list for every leaf area that will always come back empty, and it
matches the total_available of
/v1/areas?parent_id={id}. level is absent exactly when
count is 0.
resolved is absent for an area with no observation, and so is
population — never 0. "No figure recorded" and "nobody lives
here" are different claims, and this API does not conflate them.
Boundary geometry
curl "https://geoapi.mcpke.dev/api/v1/areas/42?format=geojson&simplify=100"
Returns a bare GeoJSON Feature, with bbox as a top-level member
per RFC 7946. That bbox describes the unsimplified area: simplify
reduces the geometry that ships, never the footprint it is reported to occupy.
Geometry is deliberately excluded from the default JSON response — a single top-level area
can run to megabytes, and the common case wants the figures rather than the shape. If you
only need to know where an area is, read bbox/centroid from the
ordinary response rather than requesting a polygon; for rendering a whole viewport, use the
tile endpoint below.
Population
Two endpoints, two questions. /v1/population answers "what is the figure here";
/v1/coverage answers "where do figures exist at all" — which is the question that
determines whether an empty result means an empty area or an unimported one.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/population | Resolved population by area, coordinate, or bounding box |
| GET | /v1/coverage | How much of a country and level actually holds data |
Query parameters — /v1/population
Exactly one of the three forms below is required.
| Parameter | Type | Description |
|---|---|---|
| area_id | integer | Resolve the figure for one known area. |
| lat, lng | number | Resolve the figure for whichever areas contain the given coordinate. |
| bbox | string | min_lng,min_lat,max_lng,max_lat — resolves every area intersecting the box. |
Resolved figure
curl "https://geoapi.mcpke.dev/api/v1/population?area_id=42"
{
"results": [
{ "area_id": 42, "value": 257635, "year": 2024, "method": "projected", "source_id": "geonames" }
],
"meta": { "took_ms": 3, "total_results": 1 }
}
The resolution order is fixed and identical for every area: method
(census > projected > modelled), then source
priority, then the most recent reference year. An observation that falls far below the
combined total of the area's own sub-divisions is excluded from this figure and flagged,
rather than published or deleted — see how observations are
resolved.
Coverage
curl "https://geoapi.mcpke.dev/api/v1/coverage?country=ID"
{
"results": [
{
"country_code": "ID",
"admin_level": 2,
"source_id": "geonames",
"total_areas": 522,
"covered_areas": 340,
"coverage_pct": 65.13,
"census_count": 0,
"projected_count": 340,
"modelled_count": 0
},
{
"country_code": "ID",
"admin_level": 2,
"source_id": "kontur",
"total_areas": 522,
"covered_areas": 522,
"coverage_pct": 100,
"census_count": 0,
"projected_count": 0,
"modelled_count": 522
}
],
"meta": { "took_ms": 5, "total_results": 2 }
} Coverage is reported per country, per administrative level, and per source, because it varies sharply across all three — the two rows above are the same 522 areas, and one provider covers all of them while the other covers two thirds. Reading a single row as "the" coverage of a level would be wrong in both directions.
coverage_pct measures the presence of a figure, not its accuracy; the
two are independent and should be read separately. A level can be 100% covered entirely by
modelled estimates, which is a different claim from 100% covered by census counts —
census_count, projected_count and modelled_count are
what tell them apart.
Extent
curl "https://geoapi.mcpke.dev/api/v1/extent"
{
"results": {
"min_lng": 95.01, "min_lat": -11.01,
"max_lng": 141.02, "max_lat": 6.08,
"countries": ["ID"],
"area_count": 7616
},
"meta": { "took_ms": 4, "countries": 1 }
}
The bounding box of what is actually imported, optionally narrowed with
?country=. Use it to frame a viewport from the data rather than hardcoding a
region — as coverage expands, a client built on this widens by itself.
Places
Business and point-of-interest records. Coverage is partial and weighted towards particular categories and cities; treat it as a signal of activity in an area, not as a complete business register for it.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/places | Business records within a box, or around a point |
| GET | /v1/places/{id} | One business, with everything recorded about it |
| GET | /v1/places/streets | Streets with businesses in an area, and how many each has |
Query parameters — /v1/places
| Parameter | Type | Description |
|---|---|---|
| area_id | integer | Businesses inside that administrative area and every area beneath it. Takes precedence over bbox and point. This is the exact question the other two only approximate — a bounding box is a rectangle around an area, so it also returns businesses in whichever neighbours fall inside that rectangle. |
| bbox | string | min_lng,min_lat,max_lng,max_lat. Takes precedence over a point query if both are supplied. |
| lat, lng | number | Centre of a radius query. |
| radius_m | number | Radius in metres around lat/lng. |
| limit | integer | Page size. Defaults to 500, capped at 2000. A larger request clamps down to 2000 rather than falling back to the default. |
| offset | integer | Rows to skip. Combine with meta.total_available to page a filter exhaustively — a single regency can hold more businesses than one page. |
| format | string | Set to geojson for a point FeatureCollection. |
Streets in an area — /v1/places/streets
Answers "which businesses are on this street" without any road data — the street comes from
the business record itself. area_id is required: street names are not unique
between areas, and every regency has a Jl. Merdeka.
Names are grouped after normalisation — case folded, punctuation collapsed, the thoroughfare
word canonicalised in both the orders it is written (Jl. Bumbang and
Bumbang St), the house number dropped, and plus codes rejected. So
Jl. Bumbang, JALAN BUMBANG and Jl. Bumbang No.12 are
one street. street returns the source's own spelling for display;
street_key is what to pass back as /v1/places?area_id=…&street=….
Businesses whose street could not be read come back as one explicit group with both fields
null, rather than being dropped. meta.total_businesses is returned so the group
counts can be checked against the area's total.
One business — /v1/places/{id}
The list endpoint returns what a result card needs — name, category, rating, review count,
address, phone, website, city, price range and operating status. This endpoint returns
everything recorded: the address broken into components, plus code, IANA timezone,
description, links, Google's own identifiers, and the categories,
social_links and complete_address objects in the shape the source
supplied them. Licence and attribution text is not returned by any endpoint — it lives on
/sources and only there. source_id is returned as a
system identifier, and /sources is the registry that maps it to a provider.
An unknown id returns 404 not_found.
Fields absent from the source are omitted rather than returned as "" or
0 — "not recorded" and "recorded as empty" are different claims. That applies to
the rich fields too: a business with no photographs has no images key at all,
rather than an empty array.
This endpoint also returns open_hours, images,
user_reviews, reviews_per_rating, about,
menu and phones, each in the shape the source supplied.
open_hours is raw: ranges are en-dash separated, 12-hour, and
often overnight (6 PM–4 AM), so a client rendering "Closed · Opens 08:00" should
parse it together with timezone rather than expect a pre-computed flag. There is
deliberately no open_now field — a stored one would be wrong within the hour.
curl "https://geoapi.mcpke.dev/api/v1/places?bbox=95.30,5.54,95.34,5.58&limit=2"
{
"results": [
{
"id": 1841,
"source_id": "geoapi-places",
"name": "A3 GYM",
"category": "gym",
"address": "Jl. Teuku Umar No. 12",
"latitude": 5.5561,
"longitude": 95.3187,
"rating": 4.6,
"review_count": 128
}
],
"meta": { "took_ms": 14, "total_results": 2 }
}
Every place carries latitude and longitude outright, so the plain
JSON response is already mappable. format=geojson returns the same records as a
point FeatureCollection instead, for handing straight to a mapping library
without a transform step.
area_id is the deepest administrative area whose boundary contains the point —
a cached spatial answer, not an attribution. A business is not a property of a village the
way a population figure is; it is at a coordinate, and this records which polygon that
coordinate fell in. It is omitted when the point lies outside every boundary held. Querying
?area_id= walks downwards, so asking about a regency returns the businesses
attached to its villages.
Vector tiles
For rendering boundaries across a viewport rather than fetching them one at a time.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/tiles/areas.json | TileJSON 3.0.0 descriptor for the tile source |
| GET | /v1/tiles/areas/{z}/{x}/{y}.mvt | Mapbox Vector Tile of area boundaries |
Point a map at the source
map.addSource("areas", {
type: "vector",
url: "https://geoapi.mcpke.dev/api/v1/tiles/areas.json",
}); Prefer this over hardcoding a tile template. The descriptor carries the tile URL, the zoom range, the data's bounds, the property set each feature holds, and the attribution the underlying licences require — all of which are server-side facts that change as coverage expands. A client that copies them locally is a client that silently disagrees with the server the next time they change.
The descriptor
curl "https://geoapi.mcpke.dev/api/v1/tiles/areas.json"
{
"tilejson": "3.0.0",
"name": "geoapi-areas",
"scheme": "xyz",
"format": "pbf",
"tiles": ["https://geoapi.mcpke.dev/api/v1/tiles/areas/{z}/{x}/{y}.mvt"],
"minzoom": 0,
"maxzoom": 16,
"bounds": [95.01, -11.01, 141.02, 6.08],
"center": [118.01, -2.46, 2],
"attribution": "…",
"vector_layers": [
{
"id": "areas",
"minzoom": 0,
"maxzoom": 16,
"fields": {
"id": "Number", "country_code": "String", "name": "String",
"admin_level": "Number", "area_km2": "Number",
"population": "Number", "density": "Number"
}
},
{
"id": "area_labels",
"minzoom": 0,
"maxzoom": 16,
"fields": { "id": "Number", "name": "String", "admin_level": "Number" }
}
]
}
Served bare, with no results/meta wrapper, so it can be handed
directly to a mapping library. bounds reflects what is actually imported, so it
widens by itself as coverage grows.
Tile behaviour
The server picks which administrative level to serve from the requested zoom, so a client
never needs a level switcher — request tiles and draw the layer named areas at
every zoom. A tile with no features returns 204 No Content. Vector tiles have
no null type: an area with no recorded population omits the key entirely, so test for
presence rather than for zero. Tiles are metered on their own, much larger rate-limit
budget than the JSON endpoints, since one viewport is several requests.
Tile features deliberately do not carry bbox, and their geometry is
clipped to the tile. To frame a view on a whole area, read bbox from
/v1/areas/{id} rather than deriving it from a tile feature — an area
larger than one tile would otherwise give you the wrong rectangle, quietly.
Draw name labels from area_labels, not from areas.
Because polygons are clipped per tile, a symbol layer over the polygon layer places one
label in every tile an area crosses — a regency spanning six tiles is labelled six times,
and no client-side setting can deduplicate across tiles. The
area_labels layer holds exactly one point per area, in the tile containing its
centroid.
Errors
Failures return a single error object and never a partial results
payload.
{
"error": {
"code": "bad_request",
"message": "bbox must be min_lng,min_lat,max_lng,max_lat"
}
} | Status | Code | Raised when | What to check | details |
|---|---|---|---|---|
| 400 | bad_request | A parameter is malformed, or a required combination is missing — for example /v1/population called with none of area_id, lat+lng, or bbox. | Read error.message: it names the offending parameter. Retrying unchanged fails identically. | — |
| 400 | too_many_features | format=geojson matched more than 10,000 areas. A FeatureCollection is returned whole rather than paged, so the limit bounds the response instead of truncating it. | Narrow the query — by country, admin_level or bbox — or drop format=geojson and page the JSON form instead. | matched_areas, max_features |
| 401 | unauthorized | A key-authenticated endpoint was called without a valid Authorization header, or with a key that has been revoked. | The header is Authorization: Bearer <key>. A key is shown once at creation and cannot be recovered — issue a new one rather than guessing. | — |
| 403 | account_suspended | The key is valid but the account behind it is suspended. | Nothing client-side will change this. Contact us; retrying wastes your rate limit. | — |
| 403 | quota_exceeded | The account's monthly request quota is used up. Distinct from 429, which is about the rate over seconds rather than the total over a month. | Wait for the month to roll over, or reduce call volume. The quota is fair-use rather than a paywall — there is no paid tier to upgrade to, so ask if you need a higher one. | plan, monthly_limit, current_usage, reset_at |
| 404 | not_found | The requested resource does not exist — for example an area ID that was never imported. | Treat it as a real answer rather than a transient failure. GET /v1/coverage reports which levels of a country are loaded. | — |
| 409 | key_limit_reached | POST /v1/account/keys was called on an account that already holds the maximum number of ACTIVE keys. Revoked keys do not count towards the allowance. | Revoke a key you no longer use and retry. The limit is deliberately small — one live key plus one for rotation — rather than a tier you can raise. | limit, active |
| 410 | verification_token_invalid | POST /v1/auth/verify was given a confirmation token that cannot be redeemed. One code covers four causes — expired, already used, minted for a different purpose, and never issued — because telling them apart would reveal whether a guessed token was a real one. | Not retryable with the same token. Sign in and call POST /v1/auth/verify/resend for a fresh link; issuing one invalidates any earlier link. | — |
| 429 | resend_too_soon | POST /v1/auth/verify/resend was called again inside the cooldown window. | Wait the reported number of seconds. This is per-account, separate from the per-address rate limiter, so a shared address does not consume it. | retry_after_seconds |
| 429 | rate_limit_exceeded | The per-address limit (anonymous callers) or the per-key limit was exceeded. | Back off rather than retrying immediately. The two limiters report differently: a keyed 429 carries details, an anonymous one does not, so read the X-RateLimit-Reset header when details is absent — do not assume details.retry_after_seconds exists. | keyed calls only: limit, remaining, reset_at, retry_after_seconds |
| 500 | internal_error | An unhandled server-side failure. | Safe to retry with exponential backoff. If it persists for one specific request, the request itself is likely triggering it — tell us what it was. | — |
| 501 | elevation_unavailable | An elevation dataset was requested that this deployment does not carry. Elevation is the one domain served from optional on-disk tiles rather than from the database. | Try another dataset — srtm1, srtm3, aster and gtopo30 are configured independently, so one being absent says nothing about the others. | — |
| 503 | verification_unavailable | This deployment was built without email verification wired up. It is a configuration state, not a fault in the request. | Nothing the caller can do — retrying will not help until the deployment is configured. Registration still succeeds; only confirming the address is unavailable. | — |
| 503 | elevation_disabled | Elevation lookups are switched off in this deployment. | Not retryable, and not per-request. Every elevation endpoint returns this until it is configured. | — |
Branch on error.code. It is part of the contract; error.message
is written for a human reading a log and may be reworded at any time.
details is present only for the codes listed above, and is omitted entirely
rather than sent empty — so read it defensively. The anonymous rate limiter is the case
that catches people out: it returns the same rate_limit_exceeded code as the
keyed one but no details at all, and reports its window through the
X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset headers instead. Since every dataset endpoint is keyless,
that is the path most integrations will actually hit.
Account endpoints
These are the only endpoints that require a key. Send it as
Authorization: Bearer geoapi_sk_…. Per-key rate limits and monthly quotas apply
for abuse prevention; they do not gate features, since every dataset endpoint is already open.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/health | Service status — unauthenticated and unmetered |
| GET | /v1/account/keys | List the keys on your account |
| POST | /v1/account/keys | Issue a key — the full value is returned once |
| DELETE | /v1/account/keys/{id} | Revoke a key, effective immediately |
| GET | /v1/account/usage | Request counts for the current month |
Gazetteer endpoints
An earlier generation of place-name endpoints, still served and still key-authenticated. New integrations should prefer the area and population endpoints above.
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/country/info | Country metadata |
| GET | /v1/place/search | Gazetteer name search |
| GET | /v1/postal/lookup | Postal code lookup |
| GET | /v1/timezone | Time zone at a coordinate |
The complete list is in the OpenAPI specification.
Attribution
Data returned by this API carries licence obligations from the providers behind it. Every provider, its licence, and the exact attribution text you must reproduce are listed on Data sources — including how to work out which providers a given response actually drew on.