Guide
Using the API
A walkthrough in the order you will actually need it — first request, then the core workflow, then the things that only matter once you ship. Nothing here requires an account: every dataset endpoint used below is open, and you can paste each command into a terminal as it appears.
1. Make your first request
The base URL is https://geoapi.mcpke.dev/api. The dataset endpoints need no key, no
header, and no sign-in — a per-address rate limit of 120 requests per minute is the only
constraint. So the first call is a real one: how many people live around a point.
curl "https://geoapi.mcpke.dev/api/v1/population?lat=-6.2088&lng=106.8456"
const BASE = "https://geoapi.mcpke.dev/api";
const res = await fetch(`${BASE}/v1/population?lat=-6.2088&lng=106.8456`);
if (!res.ok) {
// Errors carry a code you can branch on; see step 9.
const { error } = await res.json();
throw new Error(`GeoAPI ${res.status} ${error?.code}: ${error?.message}`);
}
const { results } = await res.json();
console.log(results); import requests
BASE = "https://geoapi.mcpke.dev/api"
res = requests.get(f"{BASE}/v1/population", params={"lat": -6.2088, "lng": 106.8456}, timeout=10)
if res.status_code != 200:
err = res.json().get("error", {})
raise RuntimeError(f"GeoAPI {res.status_code} {err.get('code')}: {err.get('message')}")
print(res.json()["results"]) You should get back something shaped like this:
{
"meta": { "took_ms": 83, "total_results": 1 },
"results": [
{
"area_id": 65828,
"value": 13125,
"year": 2023,
"method": "modelled",
"source_id": "kontur"
}
]
}
That is the whole product in one response: a figure, the area it belongs to, the year it
describes, how it was arrived at, and which dataset it came from. Nothing here is
an average of sources — see step 5. If you got an empty results array instead,
the point is outside the areas imported so far, which step 6 covers.
Need only a liveness check rather than data? GET /v1/health answers that, and is
neither authenticated nor metered.
Successful responses are always wrapped:
{ "results": …, "meta": { "took_ms": … } }. The one exception is
format=geojson, which returns bare GeoJSON so it can be passed straight to a
mapping library. Read the payload from results rather than from the root object.
2. The core workflow
Almost every integration is the same three calls. The first turns a human input into an area identifier; the second gets the number; the third explains where the number came from.
- 01
Find the area
Everything in the API is keyed on an area identifier. Search by name, or resolve one from a coordinate.
GET /v1/areas?q= - 02
Read the figure
Ask for the resolved population of that area — a single value chosen by a published precedence rule.
GET /v1/population?area_id= - 03
Inspect the provenance
Pull the full observation record when you need to know who measured it, in which year, and by what method.
GET /v1/areas/{id}
3. Resolve an area
Name search is trigram-matched, so partial input and minor misspellings still rank. Narrow it
with country (ISO 3166-1 alpha-2) and admin_level whenever you know
them — an unqualified search spans every country loaded.
curl "https://geoapi.mcpke.dev/api/v1/areas?q=banda&country=ID&admin_level=2&limit=5"
{
"results": [
{
"id": 42,
"country_code": "ID",
"admin_level": 2,
"name": "Kota Banda Aceh",
"population": 257635,
"density": 4198.5,
"area_km2": 61.36,
"centroid": { "lng": 95.32, "lat": 5.55 },
"bbox": [95.27, 5.51, 95.38, 5.61]
}
],
"meta": { "took_ms": 6, "total_results": 1, "total_available": 1, "limit": 5, "offset": 0 }
}
Hold on to id — it is the key for every subsequent call. If you are starting from
a coordinate instead of a name, skip this step entirely and pass
lat and lng to /v1/population, which resolves the
containing areas for you.
Every area arrives with its size (area_km2), its position
(centroid), and its footprint (bbox, as
[min_lng, min_lat, max_lng, max_lat]) — so a search result is enough to put a
marker down or frame a map without a second request. See
putting it on a map.
4. Read population
/v1/population accepts one of three forms: an area_id, a
lat+lng pair, or a bbox. All three return the same
shape, so a client can switch between them without changing its parser.
curl "https://geoapi.mcpke.dev/api/v1/population?area_id=42"
curl "https://geoapi.mcpke.dev/api/v1/population?lat=5.5483&lng=95.3238"
curl "https://geoapi.mcpke.dev/api/v1/population?bbox=95.30,5.54,95.34,5.58"
{
"results": [
{ "area_id": 42, "value": 257635, "year": 2024, "method": "projected", "source_id": "geonames" }
],
"meta": { "took_ms": 3, "total_results": 1 }
} results is always an array, including for a single area_id — a
coordinate can fall inside several nested areas and a bounding box can intersect many, so the
shape does not change with the query form.
5. Inspect the provenance
A resolved figure is one of several on record. When you need to justify a number — in a report, a model, or a conversation with someone who has a different figure — fetch the area itself and read its observations.
curl "https://geoapi.mcpke.dev/api/v1/areas/42"
{
"results": {
"id": 42,
"name": "Kota Banda Aceh",
"population": 257635,
"density": 4198.5,
"observations": [
{ "source_id": "geonames", "value": 257635, "year": 2024, "method": "projected" },
{ "source_id": "kontur", "value": 115131, "year": 2023, "method": "modelled" }
]
},
"meta": { "took_ms": 9 }
}
Two providers, two methods, a 2.2× disagreement — which is normal, not a defect, and exactly
why the API returns both. The top-level population is the one selected by the
precedence rule: method first (census > projected >
modelled), then source priority, then reference year. Full detail on
how observations are resolved.
Choosing a method yourself
If your use case has a strong preference — modelled figures for even spatial coverage,
census figures for defensibility — do not rely on the resolved value. Read
observations and select on method in your own code. The resolution
rule is a sensible default, not a substitute for a domain decision.
6. Handle missing data correctly
This is the mistake most likely to produce a quietly wrong result. Optional fields are
omitted when no value exists — never returned as 0 or
null, because "not measured" and "measured as zero" are different claims.
// Wrong — an unmeasured area silently becomes an empty one const pop = area.population || 0; // Right — absence stays distinguishable from zero const pop = area.population ?? null; if (pop === null) renderUnknown();
The same rule applies to vector tiles, which have no null type at all: an area with no figure
omits the population key, so test with has rather than comparing to
zero. To find out what exists before you query it, use the coverage endpoint.
curl "https://geoapi.mcpke.dev/api/v1/coverage?country=ID"
Coverage is reported per country, per administrative level, and per source, because it varies sharply across all three. It measures the presence of a figure, not its accuracy — a fully covered level can still be built on modelled estimates that diverge for individual areas.
7. Page through a full level
/v1/areas returns 50 rows by default and at most 500. A request above the cap
clamps down to 500 rather than falling back to the default, so asking for more never silently
returns less. Use meta.total_available to know when to stop.
const BASE = "https://geoapi.mcpke.dev/api";
async function fetchLevel(country, adminLevel) {
const out = [];
const limit = 500;
let offset = 0;
for (;;) {
const url = new URL(`${BASE}/v1/areas`);
url.searchParams.set("country", country);
url.searchParams.set("admin_level", adminLevel);
url.searchParams.set("limit", limit);
url.searchParams.set("offset", offset);
const res = await fetch(url);
if (!res.ok) throw new Error(`GeoAPI: HTTP ${res.status}`);
const body = await res.json();
out.push(...body.results);
offset += limit;
if (offset >= body.meta.total_available) return out;
}
}
Do not page by "keep going until a short page comes back" — read
meta.total_available, which is the count matching your filter before paging is
applied. Keep concurrent requests modest; the per-address limit is 120 per minute and a
parallel crawl will hit it quickly.
8. Put it on a map
For a whole viewport, request vector tiles rather than fetching boundaries area by area. Point the source at the TileJSON descriptor and let the library read the rest from it — the tile URL, the zoom range, the data's bounds, and the attribution the underlying licences require are all server-side facts that change as coverage expands.
map.addSource("areas", {
type: "vector",
url: "https://geoapi.mcpke.dev/api/v1/tiles/areas.json", // NOT a hardcoded tile template
});
map.addLayer({
id: "choropleth",
type: "fill",
source: "areas",
"source-layer": "areas", // the layer name INSIDE each tile — required
paint: {
"fill-color": [
"case",
["!", ["has", "density"]], "#EDEAE1", // no observation: its own colour, not the ramp floor
["interpolate", ["linear"], ["get", "density"],
0, "#EDEAE1", 5500, "#C4BFB4", 11200, "#2A2823"],
],
"fill-opacity": 0.45,
},
});
map.addLayer({
id: "area-label",
type: "symbol",
source: "areas",
"source-layer": "area_labels", // the POINT layer, never the polygon layer
layout: { "text-field": ["get", "name"], "text-size": 12 },
});
Three things bite here. "source-layer" is the layer name inside the tile, distinct
from the source id, and omitting it renders nothing without any warning. Shade by
density, not by raw population — colouring a choropleth by a count
makes large areas dominate regardless of how many people are in them.
And label from area_labels, never from areas. Polygons are clipped
per tile, so a symbol layer over them places one label in every tile an area crosses — a
regency spanning six tiles gets labelled six times, and no client-side setting deduplicates
across tile boundaries. area_labels carries exactly one point per area.
Framing the camera on an area
Use the bbox that comes back with the area itself. There is no need to fetch a
boundary polygon to work out where something is.
const res = await fetch(`${BASE}/v1/areas/${id}`);
const area = (await res.json()).results;
if (area.bbox) {
// [min_lng, min_lat, max_lng, max_lat] -> [[sw], [ne]]
const [w, s, e, n] = area.bbox;
map.fitBounds([[w, s], [e, n]], { padding: 48, maxZoom: 13 });
} else if (area.centroid) {
map.flyTo({ center: [area.centroid.lng, area.centroid.lat], zoom: 12 });
}
Prefer fitBounds over flyTo: a centre point plus a guessed zoom
either clips a large area or renders a small one as a dot, while the bbox frames it correctly
at whatever size the viewport happens to be. Use centroid for placing a label or
a marker — it is guaranteed to sit on the area, which its centre of mass is not when the area
is an archipelago or a crescent.
Do not derive bounds from a tile feature. Tile geometry is clipped to the tile it arrived in,
so for any area larger than one tile you would get the clipped slice rather than the area —
and it fails quietly, by framing the wrong rectangle rather than erroring. For a single full
boundary, /v1/areas/{id}?format=geojson&simplify=100 returns one
Feature; geometry is excluded from the default response because a single
top-level area can run to megabytes.
9. Fail predictably
Errors return a single error object and never a partial results
payload, so a response either parsed cleanly or did not.
{
"error": {
"code": "bad_request",
"message": "bbox must be min_lng,min_lat,max_lng,max_lat"
}
} - Branch on
error.code, not on the message text — codes are stable, messages are not. - Retry
500 internal_errorwith exponential backoff; do not retry400 bad_request, which will fail identically every time. - On
429 rate_limit_exceeded, back off rather than retrying immediately —detailscarries the applicable window. - Treat
404 not_foundas a real answer: the area was never imported, and asking again will not change that.
10. Get an API key (only if you need one)
Everything above works without an account. A key is required only for the endpoints that read your own account — usage figures and key management. It is free, and there is no paid tier above it.
- Register an account. The first key is issued immediately.
- Copy it at once. The full value is shown a single time and only its hash is stored, so a lost key must be replaced rather than recovered.
- Send it as a bearer token on account requests.
curl -H "Authorization: Bearer geoapi_sk_…" \ "https://geoapi.mcpke.dev/api/v1/account/usage"
Keep the key server-side. Anything shipped to a browser is public, and a key in front-end code buys nothing anyway — the dataset endpoints a browser needs are already open. Revoke from the dashboard; revocation takes effect immediately.
11. Before you ship: attribution
The data behind these endpoints is licensed, and those licences follow it into your product. Reproducing each provider's attribution text is a condition of use, not a courtesy.
- Collect the
source_idof every observation you actually consumed. - Look each one up on Data sources and copy its attribution string verbatim — do not shorten, translate, or replace it with a link.
- Credit the providers, not this API. Naming GeoAPI alone satisfies none of the underlying licences.