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"

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.

  1. 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=
  2. 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=
  3. 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"
{
  "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"
  }
}

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.

  1. Register an account. The first key is issued immediately.
  2. 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.
  3. 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.

Where to go next