# CountryAtlas API — reference Base URL: `https://www.countryatlas.co/api/v1` (proxied by the Next.js app to the FastAPI process on `127.0.0.1:8291`). Interactive docs: [`/api/v1/docs`](https://www.countryatlas.co/api/v1/docs) (Swagger) · [`/api/v1/redoc`](https://www.countryatlas.co/api/v1/redoc) · spec `/api/v1/openapi.json`. * All endpoints are `GET` (except `POST /admin/refresh`, `POST /admin/cache/clear`) and return JSON (gzip when accepted). * Identifiers: countries by **ISO3 or slug** (case-insensitive: `CAN`, `can`, `canada`); indicators, topics and groups by **slug**. * Every response carries `meta: {built_at, run_id, generated_at}`; the header `X-CountryAtlas-Run` repeats the snapshot run id and `X-Cache: HIT|MISS` tells whether the in-process cache served it (cache keys include the run id, so a new snapshot invalidates everything). * Errors are RFC 7807 `application/problem+json`: `{"type","title","status","detail","instance",…}`. `404` unknown country/indicator/topic/group (with a helpful `detail`), `400` bad combination, `422` invalid parameter (with `errors[]`), `429` rate limit (120 req/min/IP, `Retry-After`), `503 {"title":"Data not built yet"}` while no snapshot exists. * Run locally: `CA_DATA_DIR=~/countryatlas-data ca-api` (or `python -m uvicorn countryatlas.api.main:app --port 8291`). ## The provenance object Every value the API returns (headline metrics, series points, ranking rows, map values, curated lists, changes…) carries a `provenance` object built from `observations` → `indicator_sources` → `sources`: ```json { "value": 55697.66, "period": "2025-01-01", "year": 2025, "unit": "current US$", "is_estimate": false, "is_forecast": false, "status": "imported", "formatted": "55.7k", "provenance": { "source": "worldbank", "source_name": "World Bank", "dataset": "WDI", "series_code": "NY.GDP.PCAP.CD", "retrieved_at": "2026-09-11T06:49:30Z", "source_updated_at": "2026-07-13T00:00:00Z", "url": "https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA", "transform": null, "licence": "CC BY 4.0" } } ``` | field | meaning | |---|---| | `source` / `source_name` | connector id (`worldbank`, `imf`, `oecd`, `eurostat`, `who`, `fred`, `owid`, `bis`, `ilo`) and display name | | `dataset`, `series_code` | dataset and series inside the source (WDI `NY.GDP.PCAP.CD`, WEO `NGDPDPC`, OWID `co2_per_capita`, FRED `FEDFUNDS`…) | | `retrieved_at` | when the pipeline fetched the raw payload (UTC) | | `source_updated_at` | last update advertised by the source (may be `null` when the source does not publish it) | | `url` | deep link: World Bank `…/indicator/{code}?locations={iso2}`, OWID grapher `ourworldindata.org/grapher/{slug}` or the `owid/co2-data` / `owid/energy-data` repos, Eurostat databrowser, WHO GHO indicator page, FRED series page, IMF / OECD / BIS / ILO data portals | | `transform` | expression applied at normalisation (e.g. `x*1e9`), `null` if none | | `licence` | licence of the source | Series responses also list **all** sources used (`sources[]`, with `n_values`) and the dominant one as `provenance`. ## Endpoints ### Health ``` GET /health → {status: "ok"|"empty", run_id, built_at, observations, countries, indicators, version, cache} ``` `/health` never returns 503; `status: "empty"` means the pipeline has not produced `atlas.duckdb` yet. ### Countries ``` GET /countries?region=&income=&q=&sort=name|population|gdp|gdp_per_capita|coverage&kind=&limit=&offset= GET /countries/{id} overview: country header, groups, coverage, freshness, headline metrics, topics summary, neighbours GET /countries/{id}/topics/{topic} indicators of a topic grouped by subtopic (registry order); indicators without data → has_data:false GET /countries/{id}/series/{indicator}?from=&to=&freq=A|Q|M&include_forecast=true&include_alt=false GET /countries/{id}/changes?limit=&kind= recent detected changes (by severity) GET /countries/{id}/events?limit=&kind=&indicator= whole-history timeline GET /countries/{id}/similar?mode=overall|economic|demographic|energy|social&limit= GET /countries/{id}/insights GET /countries/{id}/dna GET /countries/{id}/download.csv|json?include_forecast=&topic= ``` `region` accepts any group id or slug (`ecs`, `europe-central-asia`, `oecd`, `g7`…); `income` accepts `HIC|UMC|LMC|LIC` or the slug. ```bash curl -s https://www.countryatlas.co/api/v1/countries/canada | jq '.headline[] | {indicator, formatted, year, rank_world, n_world, source: .provenance.source}' # {"indicator":"gdp-per-capita","formatted":"55.7k","year":2025,"rank_world":21,"n_world":188,"source":"worldbank"} … curl -s "https://www.countryatlas.co/api/v1/countries/CAN/series/gdp-per-capita?from=2000" | jq '.stats, .provenance' ``` Headline metric object (`MetricValue`): `value, formatted, period, year, unit, is_estimate, is_forecast, status, prev{period,value}, change{abs,pct,formatted}, change_10y{…}, rank_world/n_world, rank_region/n_region (World Bank region), rank_income/n_income, rank_year, rank_is_stale (rank computed on a year > 2 years older than the indicator's latest year), higher_is_better, sparkline [[year, value]…] (last 30 non-forecast points), provenance`. ### Indicators ``` GET /indicators?topic=&q=&featured=&source=&with_data= GET /indicators/{slug} definition, sources (priority order, deep links), coverage (+ by_year), world_latest, freshness, top5/bottom5, years, topics GET /indicators/{slug}/map?year=&nearest=false&classes=6 GET /indicators/{slug}/trend?group=world&from=&to=&min_n=5 GET /indicators/{slug}/download.csv|json?from=&to=&include_forecast= ``` * `world_latest` is **computed across countries** (World Bank aggregates such as WLD are not stored): `kind` is `sum` for additive indicators, `weighted_mean` (population-weighted) for per-capita / share indicators, `median` otherwise; `median`, `mean`, `weighted_mean` and `n` are always returned. * `map`: without `year`, the latest year with ≥ 50 countries is used (`year_used`). With `nearest=true` each country's latest value within 3 years of the reference year is used and `years{ISO3: year}` says which. `legend.breaks` are quantile breaks (5–7 classes). * `trend`: per year `median`, `mean`, `weighted_mean` (population) for per-capita/percent indicators, `sum` for additive ones, `n`; `preferred` says which to plot. ```bash curl -s "https://www.countryatlas.co/api/v1/indicators/life-expectancy/map?year=2023" | jq '{year_used, n, legend, CAN: .values.CAN}' curl -s "https://www.countryatlas.co/api/v1/indicators/gdp/trend?group=oecd" | jq '.points[-1]' ``` ### Series bundle ``` GET /series?country=CAN,FRA&indicator=gdp-per-capita,inflation&from=1990&to=2026&freq=A&include_forecast=true ``` Returns `series[]` (one per indicator × country), each with `values[]` (per-point provenance), `sources[]`, `provenance`, `stats{min,max,first,last,cagr}`. ### Rankings ``` GET /rankings?topic= rankable indicators (featured first) with ranking_year / ranking_n GET /rankings/{indicator}?year=&group=world&sort=asc|desc&limit=50&offset=0&sparkline=true GET /rankings/{indicator}/history?countries=CAN,USA&from=&to= ``` Rows: `rank` (within the requested group), `rank_world/n_world` (from the snapshot), `pct_rank`, `country{id,slug,name,flag,region,income}`, `value`, `formatted`, `year`, `change_1y`, `change_10y`, `sparkline`, `provenance`. Default `sort` is `asc` when `higher_is_better=false` (e.g. infant mortality), otherwise `desc`. `year_used` falls back to the nearest available year; `years_available` lists them. ```bash curl -s "https://www.countryatlas.co/api/v1/rankings/gdp-per-capita?limit=3" | jq '.rows[] | [.rank, .country.name, .formatted, .change_1y.formatted]' # [1,"Luxembourg","147.3k","+6.9 %"] [2,"Ireland","131.6k","+16.6 %"] [3,"Switzerland","114.8k","+6.6 %"] ``` ### Compare ``` GET /compare?countries=CAN,USA,FRA&indicators=gdp,gdp-per-capita&from=&to=&mode=absolute|per-capita|index100|pct&include_forecast= GET /compare/snapshot?countries=CAN,USA&topic=economy (or &indicators=a,b; default = headline indicators) GET /compare/download.csv|json?countries=&indicators=&from=&to= ``` Modes: `index100` rebases each series to 100 at the first available year ≥ `from`; `per-capita` divides additive (`aggregation=sum`) indicators by the `population` series of the same year (already per-capita indicators are left unchanged, `transform.applied=false`); `pct` = % change vs previous period. `snapshot` returns one row per indicator with a `values{ISO3: MetricValue}` map and `best` (when `higher_is_better` is known). ### Regions / groups ``` GET /regions?kind=world|region|continent|income|org GET /regions/{slug}?indicator=gdp-per-capita&sort=asc|desc ``` Group page: members with headline values (+ provenance), `aggregates` (sum population/GDP, population-weighted GDP per capita, median life expectancy…, each with `kind`), and a member ranking on the chosen indicator. ### Search ``` GET /search?q=&limit=10&type=country|indicator|topic|region|source ``` Typed hits `{type, id, slug, name, hint, score, url}` from `search_index` (exact → prefix → word → substring → Jaro-Winkler fuzzy). Hints: `Country · North America`, `Indicator · Economy · annual %`, `Topic · 12 indicators`, `Region · Organisation · 38 members`. Two-word combos such as `housing canada` or `canada gdp` also return `country_topic` / `country_indicator` hits with a ready URL (`/countries/canada/housing`). ### Home ``` GET /home ``` `snapshot` (world population / GDP sums, median life expectancy, counts, built_at), `lists` (largest economies, fastest population growth among countries > 1 M, highest life expectancy, energy transition leaders, highest GDP per capita PPP, lowest unemployment among countries > 5 M; 8 rows each with provenance), `recent_changes` (12 most severe), `recently_updated`, `featured_indicators`, `trending`. ### Changes, sources, methodology ``` GET /changes?limit=&offset=&kind=&indicator=&country=&topic=&min_severity= GET /sources n_observations, n_indicators, licence, last_retrieved_at GET /sources/{id} indicators mapped, datasets, import_runs, freshness GET /methodology registry-derived: topics, units/formats, source priority rule + URL patterns, validation rules, derived computations, DNA dimensions ``` ### Downloads CSV is streamed with a leading `# CountryAtlas export · run … · built …` comment line and the columns `country_id, country_name, indicator_id, indicator_name, period, year, frequency, value, unit, is_estimate, is_forecast, status, source, source_name, dataset, series_code, retrieved_at, source_updated_at, url, licence`. JSON returns `{meta, n, columns, rows}`. ### Admin (header `X-Admin-Token: $CA_ADMIN_TOKEN`) ``` GET /admin/overview db, meta, table counts, connectors health (import_runs), sources freshness/stale counts, scheduler heartbeat (data_dir/scheduler.json + scheduler.pid), cache stats GET /admin/runs?limit=&connector=&status= GET /admin/issues?severity=&connector=&indicator=&code=&run_id=&limit= GET /admin/coverage indicator × n_countries / last_year matrix + per-country coverage GET /admin/raw?run_id= raw files stored for a run POST /admin/refresh sends SIGUSR1 to the scheduler pid (409 when no scheduler) POST /admin/cache/clear ``` `403` on a wrong/missing token, `503 {"title":"Admin disabled"}` when `CA_ADMIN_TOKEN` is not set. ## Operations notes * **Snapshot swap**: the API opens `~/countryatlas-data/atlas.duckdb` read-only and compares `st_ino`/`st_mtime_ns` on every request; when the pipeline `os.replace()`s a new file, the old connection is closed and the new one opened (DuckDB caches instances per path, so the close must happen first). Responses are cached in-process per `(run_id, path, query)`; nothing survives a new run id. * **Performance** (real snapshot, 2.0 M observations): country overview ≈ 12 ms warm (≈ 170 ms on the very first request while static lookups load), rankings ≈ 20 ms, home ≈ 33 ms, map ≈ 4 ms, cached hits ≈ 1 ms. * **Formatting**: `formatted` strings use the indicator's `format`: currency/number compact (`53.4k`, `1.2T`), percent `3.4 %`, years `82.1 yrs`, tonnes `5.2 t`, per-1000 `3.2 per 1,000`, per-100k `1.2 per 100k`.