# 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 GDP growth, fastest population growth, highest life expectancy and energy transition leaders among countries ≥ 1 M; highest GDP per capita PPP; lowest unemployment among countries ≥ 5 M — 8 rows each with provenance, `filter_note`), `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 compact with the registry `unit_short` prefix (`US$53.4k`, `US$1.2T`, `intl $45.3B`), number compact (`41.7M`), percent `3.4 %`, years `82.1 yrs`, tonnes `5.2 t`, per-1000 `3.2 per 1,000`, per-100k `1.2 per 100k`. `formatted_short` is the same value without prefix/unit (`53.4k`, `3.4`) for tight cells. * **Registry reloads**: the headline list (`topics.yaml`), indicator metadata and groups are re-read from the YAML registry each time a new snapshot run id is opened (the loaders' `lru_cache` is cleared), so registry edits take effect with the next build. * **Home lists**: each curated list carries `min_population` and `filter_note` (e.g. `"Countries above 1M inhabitants"`) when a population floor applies (fastest GDP growth, fastest population growth, highest life expectancy, energy transition leaders: ≥ 1M; lowest unemployment: ≥ 5M). ## Analytics endpoints (API 1.1 — contract, 2026-09-11) All additive, read-only, GET, cached per snapshot like the rest. Paths stay under `/api/v1`. Country-only pools (`kind='country'`), annual canonical frequency, non-forecast, non-quarantined. Every value object still carries `provenance` where a single source applies; aggregates carry `provenance[]` (one per contributing source, with `n_values`). Neutral wording everywhere: a change is an *increase* / *decrease*; it is called an *improvement* / *deterioration* only when the indicator declares `higher_is_better`. ``` GET /pulse World Pulse — what is changing globally (latest year vs previous) GET /movers?window=1|5|10&category=&kind=&limit=&min_population= Biggest movers by window / category / kind GET /extremes?window=1|5|10|25|since1990&topic=&min_population= Curated extremes facets (fastest ageing, urbanising…) GET /scatter?x=&y=&size=&year=&group=&log_x=&log_y= Cross-section scatter + Pearson / Spearman / OLS GET /trajectory?x=&y=&size=&from=&to=&group= Gapminder-style frames (compact arrays per country) GET /finder?f=slug:op:value&f=…&mode=and|or®ion=&income=&sort=&limit= Structured country finder over `latest` GET /peers?y=&x=&year=&method=theil-sen|ols&log_x= Above / below expected (robust cross-sectional fit) GET /indicators/{slug}/related?limit=&min_n= Statistically related indicators (descriptive) GET /indicators/{slug}/distribution?year=&highlight=&bins= Histogram + medians (world / region / income) + percentile GET /indicators/{slug}/frames?from=&to=&step=&group= Multi-year map frames for the time machine GET /indicators/{slug}/quality Coverage / freshness / continuity summary GET /rankings/{indicator}/race?from=&to=&top=&group= Rank race frames (top N per year) GET /regions/compare?a=&b=&indicators= Group vs group aggregates + history GET /countries/{id}/story "How X changed": long-run indicators, templated text GET /countries/{id}/dna?reference=world|region|income| DNA + reference profile GET /countries/{id}/quality Per-indicator data quality for a country GET /updates Freshness dashboard (sources, runs, changed values) GET /search?q=compare canada usa Intent hits (type action) — see below ``` ### Shapes `MoverItem` — `{country: CountryCard, indicator: IndicatorCard, kind, year, ref_year, value, ref_value, delta, delta_pct, formatted, formatted_ref, severity (0–1), direction: "up"|"down", interpretation: "improvement"|"deterioration"|null, headline, provenance}`. `kind` ∈ `yoy_jump | yoy_drop | record_high | record_low | n_year_high | n_year_low | sign_flip | accelerating | decelerating | structural_break | trend_reversal | volatility_spike | change_5y | change_10y`. Categories: `economic` = economy+government+trade+income, `demographic` = population, `health`, `energy`, `climate` = climate+environment, `digital` = digital+innovation, `housing`, `labor`. Kinds filter: `improvement | deterioration | increase | decrease | record | reversal | acceleration | structural | all`. `/pulse` → `{meta, year_reference, summary: {n_indicators, n_countries_reporting, n_record_highs, n_record_lows, n_changes}, items: [{indicator, year, n, n_up, n_down, n_flat, share_up, share_down, median_change_abs, median_change_pct, direction_semantics: "higher_is_better"|"lower_is_better"|"neutral", record_highs, record_lows, headline, top_up: MoverLite, top_down: MoverLite, convergence: {direction, cv_start, cv_end, n, from_year} | null, provenance}]}` where `MoverLite = {country, value, ref_value, delta, delta_pct, formatted, year}`. Only headline + featured indicators whose latest year is ≥ reference − 1. Headlines are templates, e.g. "Inflation fell in 73 % of 176 reporting countries" (share_down ≥ 60), "Population is shrinking in 31 countries", "Renewable electricity hit a record high in 42 countries". `/extremes` → `{meta, window, from_year, to_year, min_population, filter_note, facets: [{id, title, indicator, direction: "up"|"down", metric: "abs"|"pct"|"points", rows: [{country, value_start, value_end, year_start, year_end, delta, delta_pct, formatted_start, formatted_end}], n, provenance}]}`. Facets (fixed, in this order, skipped when the indicator has < 30 countries in the window): `aging` median-age ↑ · `urbanizing` urban-population-share ↑ · `fertility-decline` fertility-rate ↓ · `life-expectancy-gains` ↑ · `gdp-transformations` gdp-per-capita-ppp ↑ (pct) · `digital-adoption` internet-users ↑ · `renewable-transitions` renewable-electricity-share ↑ · `co2-reductions` co2-per-capita ↓ (pct) · `population-decline` population ↓ (pct) · `population-boom` population ↑ (pct) · `inflation-surges` inflation ↑ (points) · `debt-buildup` general-government-gross-debt-pct-gdp ↑ (points) · `unemployment-falls` ↓ (points). `topic` filters facets by their indicator topic. `window=since1990` → from_year 1990. `/scatter` → `{meta, x, y, size, year, year_used, nearest_years: 3, group, n, points: [{id, slug, name, flag, region, income, x, y, size, year_x, year_y}], stats: {n, pearson, spearman, ols: {slope, intercept, r2} | null, log_x, log_y}, note}`. `log_x/log_y` accept `true|false|auto` (auto = true when the indicator's format is currency/number with bounds ≥ 0 and max/min > 50). `size` defaults to `population`; `size=none` disables. Each axis uses the country's value in `year_used` or the nearest within 3 years. `/trajectory` → `{meta, x, y, size, group, years: [int…], countries: [{id, slug, name, flag, region, income}], series: {ISO3: {x: [num|null…], y: [...], size: [...]}}, domains: {x: [min,max], y: [min,max], size: [min,max]}, log_x, log_y, provenance: [...]}` — arrays aligned on `years`, no interpolation. Default `from` = first year with ≥ 50 countries on both axes, `to` = last year with ≥ 50. Countries with < 30 % of frames on both axes are dropped. `/finder` → filters `f=slug:op:value` (repeatable; ops `gt gte lt lte eq between` — between uses `a..b`), `mode=and|or` (default and), `region`/`income` group slug, `sort=slug:asc|desc` (default: first filter, desc), `limit` ≤ 218. Response `{meta, mode, filters: [{indicator, op, value, value2, year_used}], n_matching, n_evaluated, items: [{country: CountryCard, matched: [slug…], values: {slug: {value, year, formatted, provenance}}}]}`. `year_used` = latest year of each country (mixed years, honest). `/peers` → `{meta, x, y, year_used, n, method, fit: {slope, intercept, r2, log_x, residual_scale}, points: [{id, slug, name, flag, region, income, x, y, expected, residual, residual_z}], above: [top 12 by residual_z], below: [bottom 12], pairs: [{x, y, label}], note, methodology}`. `residual_z` = residual / (1.4826·MAD of residuals). Default pair y=life-expectancy, x=gdp-per-capita-ppp, log_x=true. Suggested pairs: (life-expectancy, gdp-per-capita-ppp), (expected-years-of-schooling, gdp-per-capita-ppp), (co2-per-capita, gdp-per-capita-ppp), (life-expectancy, health-expenditure-per-capita), (internet-users, gdp-per-capita-ppp), (infant-mortality-rate, gdp-per-capita-ppp). Wording must stay descriptive: "above the fitted line", never "outperforms because". `/indicators/{slug}/related` → `{meta, indicator, year_used, n_candidates, items: [{indicator, pearson, spearman, n, year, log_x, log_y, direction: "positive"|"negative"}], note: "Correlation does not imply causation."}` — cross-section on `latest` (values within 3 years of the indicator's max year), pairs need `n ≥ min_n` (default 40), sorted by |Spearman| desc, self and per-capita twins of the same quantity excluded when both are present (e.g. gdp vs gdp-ppp is allowed; gdp-per-capita vs gdp-per-capita-ppp is allowed — no hand rules beyond self). `/indicators/{slug}/distribution` → `{meta, indicator, year, year_used, n, log, histogram: {edges, counts, log}, stats: {min, p10, p25, median, mean, p75, p90, max}, highlight: {country: CountryCard, value, percentile, rank, n, region: GroupCard|null, region_median, income: GroupCard|null, income_median} | null, by_region: [{group: GroupCard, median, n}], by_income: [{group, median, n}], provenance}`. `/indicators/{slug}/frames` → `{meta, indicator, group, years: [...], values: {ISO3: [num|null…]}, legend: {min, max, breaks: [...], n_classes}, n_by_year: [...], provenance}` — breaks are quantiles over the pooled values of all years (a stable legend while scrubbing). Years limited to those with ≥ 20 countries; ≤ 80 frames. `/indicators/{slug}/quality` → `{meta, indicator, n_countries, n_countries_total, coverage_pct, first_year, last_year, latest_common_year, n_years, years_with_50plus, median_points_per_country, sparse_countries, stale_countries, flagged_values, sources: [...], badges: [...]}`. Badges vocabulary (shared with the country endpoint): `fresh` (latest year ≥ reference − 1), `historical` (first year ≤ 1970), `sparse` (median points per country < 10), `limited-coverage` (< 50 % of countries), `stale` (latest year ≤ reference − 3), `flagged` (> 5 % of values with status warning), `forecast` (chosen source publishes projections). `/rankings/{indicator}/race` → `{meta, indicator, group, top, years: [...], frames: [{year, rows: [{id, value, rank}]}], countries: {ISO3: CountryCard}, max_value, provenance}`. Union of countries that appear in the top N in any frame; only years with ≥ 20 ranked countries. `/regions/compare` → `{meta, groups: [GroupCard, GroupCard], rows: [{indicator, kind: "sum"|"median"|"weighted_mean", label, values: {: {value, formatted, n, year}}}], shares: {: {population_share_pct, gdp_share_pct}}, history: {: {years: [...], : [num|null…]}}}` for indicators (default) population, gdp, gdp-per-capita, life-expectancy, co2-per-capita, internet-users, gdp-growth, inflation; history for the first four (sum/weighted/median per year, ≥ 60 % of members reporting). `/countries/{id}/story` → `{meta, country, since, items: [{indicator, first: {year, value, formatted}, last: {year, value, formatted}, change_abs, change_pct, cagr, peak: {year, value}, trough: {year, value}, rank_first: {rank, n, year} | null, rank_last: {...} | null, series: [[year, value]…], text, provenance}]}` — indicators in this order when ≥ 10 annual points: population, gdp-per-capita-ppp (fallback gdp-per-capita), life-expectancy, fertility-rate, urban-population-share, co2-per-capita, internet-users, renewable-electricity-share, general-government-gross-debt-pct-gdp, median-age, energy-use-per-capita, unemployment-rate; max 10 items. `text` template: "{country}'s {indicator} {rose|fell} from {first} in {y0} to {last} in {y1} ({signed pct} / {signed points})." `/countries/{id}/dna` (extended) → adds `reference: {kind: "world"|"region"|"income"|"country", id, label, dims: {...}} | null` (`world` → 50 on every dimension; region/income → median of the members' dims; country → that country's dims). `/countries/{id}/quality` → `{meta, country, summary: {n_indicators, n_with_data, coverage_pct, latest_year, n_fresh, n_stale, n_sparse, n_flagged}, items: [{indicator, latest_year, first_year, n_points, expected_points, missing_years, continuity_pct, status, source, source_updated_at, retrieved_at, badges: [...]}]}`. `/updates` → `{meta, snapshot: {run_id, built_at, observations, indicators, countries, values_changed, values_changed_by_source: {...}}, sources: [{source: Source, status: "ok"|"partial"|"failed"|"stale"|"unknown", last_success_at, last_retrieved_at, source_updated_at, n_datasets, n_indicators, n_observations, latest_year, values_changed, countries_affected}], recent_runs: [{run_id, connector, dataset, started_at, finished_at, status, rows_valid, warnings, errors, message}], indicators_recently_updated: [IndicatorSummary…]}`. No file paths, hosts or secrets in the payload. `/search` intents — deterministic parsing before the index lookup; each intent yields a hit `{type: "action", action, id, name, hint, url, score: 1.0}`: `compare […]` → `/compare/`; `rank[ing] ` → `/rankings/`; ` ` (already `country_indicator`); ` ` → `/rankings/?group=`; ` map|explore` → `/explore?indicator=`; ` vs ` → compare. ### Change detection 2.0 kinds (pipeline) `structural_break` (single mean shift with gain ≥ 0.5 and shift ≥ 1.5 × series sd, segments ≥ 5 years, break within the last 10 years for `changes`), `trend_reversal` (three consecutive yearly moves of one sign after three of the opposite sign), `volatility_spike` (sd of the last 5 yearly differences ≥ 3 × the sd of the previous 15). Same row shape; `detail` documents the parameters. ### Implementation notes (as built, 2026-09-12 — deviations from the contract above) * `/pulse` takes `min_population` (default 1 000 000, `0` disables) that filters the **top movers only** (`top_up`/`top_down`); counts and shares use every reporting country. The response echoes `min_population` and `filter_note`. Pools need ≥ 30 reporting countries (not 20) so a thin indicator (Gini, 25 countries) never headlines; `year_reference` is the latest year of the annual pillars population / GDP / life expectancy (monthly/quarterly series already carry the current year). Convergence compares max-year vs max-year − 10. * `/movers` windows 5/10 report kinds `change_5y` / `change_10y` (direction in `direction`), keep moves with robust |z| ≥ 1.5 and rank by severity = min(1, |z|/4) × importance; `kind=record|reversal|acceleration|structural` are only meaningful for `window=1` and return an empty list otherwise. Window 1 reads the `changes` table (any kind, including the pipeline's structural_break / trend_reversal / volatility_spike). * `/extremes` facets each carry their own `from_year` / `to_year` (the indicator's latest common year and its window start); the top-level pair is the min/max across facets. Start values use the nearest observation within ±2 years of the window start. * `/indicators/{slug}/related`: `min_n` accepts ≥ 5 (default 40). Pearson is computed on the association scale (log10 for level series, flagged `log_x`/`log_y`), Spearman on raw values. * `/indicators/{slug}/distribution`: `highlight` also returns `year`; group medians need ≥ 2 members with data. * `/regions/compare` `history[slug]` also carries `kind`; `/updates` sources carry `runs {n, n_failed}` and `snapshot.reference_year`. * `/countries/{id}/story` text template is `"{Country}: {Indicator} rose|fell from {first} in {y0} to {last} in {y1} ({signed change}[, ±x % a year])."` (colon form keeps acronyms such as GDP / PPP intact); `/countries/{id}/dna` adds `note`. * `/search` intents: countries are resolved by ISO2/ISO3 code first, then exact single word, then (near-)exact 2–3-word names, then fuzzy.