Public API v1
The Futuros public API is versioned static data. Futuros is a frontend-only platform: there is no query server behind /api/v1/. Everything is baked from public/data/ by scripts/bake-api.ts and served as plain files over the CDN. A post-bake gate (scripts/check-api.ts, run in the same prebuild step) asserts the bake matches the corpus — citation counts line up, every cell is covered, the bulk dumps parse, generated_at is at least as new as the newest data file — and fails the build on any drift, so the served tree cannot go stale. Every endpoint below is a plain HTTP GET of a file — no auth, no API key, no rate limit, fully cacheable.
- Base URL:
https://futuros.xyz/api/v1/ - Discovery (DCAT catalog):
https://futuros.xyz/data.json(at the site root, not under/api/v1/) - Human docs page:
https://futuros.xyz/datos-abiertos?lang=en
Because the responses are static files, an endpoint is just its path. Fetch one with curl, a browser, fetch(), pandas.read_csv, R, or any SDMX/DCAT client.
How the tree is baked
bake-api.ts then check-api.ts run as the final two steps of prebuild (after typecheck, the test suite and the other data gates), so every deploy re-bakes /api/v1 from the current public/data/ and proves the result before vite build ships it:
Bake invariants (scripts/bake-api.ts):
- Atomic promote. The whole tree is written into
public/api/v1.tmp(pluspublic/data.json.tmp) and swapped over the live paths withrenameSyncas the very last step. A crash mid-bake leaves the previous tree intact — the old delete-then-repopulate flow could ship a half-written/api/v1. - Cell source. One cell = one
public/data/parameter-cache/<param>__<ISO3>.jsonenvelope; a cell whose payload has no indicators is skipped (the gate counts those as legitimately absent, not missing). - Row flattening. One CSV/SDMX row per (indicator, year). Duplicate years inside a series are dropped (first occurrence wins), and the latest vintage row is appended only when the series doesn't already contain that year — a value is never emitted twice.
- Formula-injection neutralization (OWASP). Any string cell starting with
=+-@(or a leading tab/CR) is prefixed with'before it reaches CSV or XLSX, so Excel/Sheets/LibreOffice render it as literal text rather than executing it as a formula. Numbers pass through untouched. - Append-only columns. New CSV columns are appended, never inserted (
label_en,label_ptsit aftercitation_id), so positional parsers keep working and header-name parsers pick the additions up. - M49 is baked in. The ISO3→M49 map is a fixed 26-entry table in the baker (25 countries +
LATAM=419), not a runtime lookup.
What check-api.ts asserts (any failure prints a diff and exits 1):
| # | Assertion | Drift it blocks |
|---|---|---|
| 1 | index.json.counts.citations = length of public/data/citations.json | manifest out of step with the citation registry |
| 2 | every non-empty cache cell has its baked observations/<param>__<ISO3>.json | silently dropped cells |
| 3 | bulk CSV/SDMX/XLSX exist, are non-empty and parse (CSV: header + ≥1 row; XLSX: opens) | corrupt bulk dumps |
| 4 | index.json's file mtime ≥ newest public/data mtime − 5 min tolerance | stale bake. Compared mtime-vs-mtime because git checkouts rewrite data-file mtimes — the old generated_at comparison false-failed on fresh checkouts |
| 5 | observation rows < 1,000,000 | overflowing the XLSX single-sheet ceiling (1,048,576 rows) |
| 6 | every search-index persona/case ref resolves to a file on disk | dead retrieval hits (137 dead persona refs shipped in July 2026) |
| 7 | ES/EN/PT labels complete and exact-matching the metrics registry, with a ≥90% instance-coverage floor | the public API regressing to Spanish-only, or a silently desynced label join |
Stability contract
The manifest states it verbatim:
Shapes are additive-only within v1; breaking changes go to
/api/v2/.
Practically:
- Fields may be added to any object within v1. Do not assume a fixed key set — read by key, ignore unknowns.
- Existing field names, types and meanings will not change within v1.
- A breaking change (renaming/removing a field, changing units or semantics) ships as a new
/api/v2/tree;/api/v1/keeps working. index.jsoncarriesapi_version,generated_at, and astabilitystring — readgenerated_atto detect a rebuild.
CORS & caching
Set in vercel.json for the /api/v1/(.*) path:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, OPTIONS
Cache-Control: public, max-age=3600, s-maxage=86400So the read API is cross-origin readable from any browser app (GET/OPTIONS only — it is read-only), edge-cached for a day. There is no write surface here. The dynamic endpoints (/api/chat, /api/mcp, …) set their own headers in code; of those, only /api/mcp is cross-origin (*, POST, OPTIONS) — the rest are same-origin.
License & attribution
From index.json:
license_url:https://creativecommons.org/licenses/by/4.0/(CC BY 4.0)license(verbatim): "Datos redistribuidos de fuentes abiertas; cada observación conserva su fuente primaria (source_name,source_url,citation_id). Al reutilizar: cite la fuente primaria de cada observación y «Futuros» como agregador."
In English: the data is redistributed from open sources; every observation keeps its primary source (name, deep-link URL, and citation_id). When you reuse it, cite each observation's primary source and credit Futuros as the aggregator. Nothing is imputed or synthesized — the honesty contract of the baker is that every value on the wire carries the source it came from.
Interoperability standards
index.json → standards declares the codes the API speaks so UNSD-SDG, World Bank Data360, OECD.AI and CEPALSTAT can ingest it directly:
| Standard | Where |
|---|---|
REF_AREA = UN M49 numeric + ISO 3166-1 alpha-3 | every geography & observation |
| SDG global indicator framework (A/RES/71/313) | sdg block per indicator + sdg-crosswalk.json |
| SDMX-CSV (ISO 17369) | observations.sdmx.csv |
| DCAT (project-open-data v1.1) | /data.json |
The manifest — index.json
Start here. It lists every dataset, live counts, the license, the stability statement, and a machine-readable endpoints map (path templates for the rest).
curl -s https://futuros.xyz/api/v1/index.json{
"name": "Futuros — API de datos abiertos",
"api_version": "1",
"generated_at": "2026-08-29T04:17:28.143Z",
"docs": "/datos-abiertos",
"license": "Datos redistribuidos de fuentes abiertas; …",
"license_url": "https://creativecommons.org/licenses/by/4.0/",
"delta_basis": "latest value vs mean of the prior 3 years (smooths COVID-era volatility)",
"stability": "Shapes are additive-only within v1; breaking changes go to /api/v2/.",
"languages": ["es", "en", "pt"],
"label_fields": { "parameters": ["name_es", "name_en", "name_pt"],
"geographies": ["name_es", "name_en", "name_pt"],
"observations": ["label_es", "label_en", "label_pt"] },
"standards": {
"ref_area": "UN M49 numeric (REF_AREA) + ISO 3166-1 alpha-3",
"sdg": "UN SDG global indicator framework (A/RES/71/313); see /api/v1/sdg-crosswalk.json",
"sdmx": "SDMX-CSV (ISO 17369) at /api/v1/observations.sdmx.csv",
"catalog": "DCAT (project-open-data v1.1) at /data.json"
},
"counts": {
"parameters": 10, "geographies": 26, "cells": 260,
"observations": 397898, "sdg_tagged_indicator_instances": 2848,
"citations": 15098, "signal_files": 260, "governance_datasets": 3, "contributions": 0
},
"bulk": {
"observations_csv": { "path": "/api/v1/observations.csv", "bytes": 170533700, "rows": 397898 },
"observations_sdmx_csv": { "…": "…" }, "workbook_xlsx": { "…": "…" }
},
"endpoints": { "openapi": "/api/v1/openapi.json", "schemas": "/api/v1/schemas/",
"parameters": "/api/v1/parameters.json", "…": "…" }
}counts.geographies is 26 because the 25 countries include the LATAM regional aggregate (M49 419). A cell is one parameter × one geography; an observation is one indicator-year row.
The bulk block carries bytes + row counts for each bulk dump — currently about 171 MB (observations.csv), 111 MB (observations.sdmx.csv) and 91 MB (futuros-data.xlsx), ~398k rows each — so check it before starting a bulk download. languages + label_fields (the machine-readable replacement for the earlier prose language_coverage note) declare that every parameter and geography carries name_es/name_en/name_pt and every observation label_es/label_en/label_pt. label_es is the canonical payload label and is never renamed; the EN/PT siblings are joined from public/data/metrics/registry.json on indicator_id and are null (JSON) / empty (CSV) for ids the registry does not yet cover. Counts and sizes here are illustrative, the live manifest is authoritative.
Machine-readable contract — openapi.json & schemas/
The v1 contract itself ships as data, listed first in index.json → endpoints:
/api/v1/openapi.json— OpenAPI 3.1.0 spec covering 15 core read paths (sdg-crosswalk.json,citations.csvand the DCAT catalog/data.jsonare not yet in the spec)./api/v1/schemas/{manifest,parameter,geography,observation-cell,citation}.schema.json— JSON Schema for each response shape.
Point a codegen or validation tool at these instead of hand-transcribing shapes from this page.
Parameters — parameters.json
The 10 Futuros pillars, in display order, each with its primary/secondary SDG goals.
curl -s https://futuros.xyz/api/v1/parameters.json[
{ "slug": "salud", "name_es": "Salud", "name_en": "Salud",
"display_order": 1, "sdg_primary": [3], "sdg_secondary": [2, 6] },
{ "slug": "educacion", "name_es": "Educación", "…": "…" }
]Use slug (e.g. salud, educacion, cohesion-social-inclusion) as the <param> segment in observation paths.
Geographies — geographies.json
The 25 countries plus the LATAM region, each with ISO3 and M49.
curl -s https://futuros.xyz/api/v1/geographies.json[
{ "iso3": "LATAM", "m49": "419", "slug": "latam",
"name_es": "América Latina y el Caribe", "name_en": "Latin America and the Caribbean",
"level": "region", "sub_region": null, "population_2024": null },
{ "iso3": "ARG", "m49": "032", "slug": "arg", "level": "country", "…": "…" }
]Country slugs are the lowercased ISO3 (arg, mex, bra; the region is latam). Use iso3 (e.g. MEX, ARG, BRA) as the <ISO> segment in observation paths. M49 codes are zero-padded strings ("032", "484").
Observations — one cell
The core dataset. One file per parameter × country, addressed as observations/<param>__<ISO3>.json (note the double underscore separator).
JSON
curl -s https://futuros.xyz/api/v1/observations/salud__MEX.json{
"api_version": "1",
"generated_at": "2026-08-29T04:17:28.143Z",
"parameter": { "slug": "salud", "label_es": "Salud" },
"geography": { "iso3": "MEX", "m49": "484", "name_es": "México",
"name_en": "México", "level": "country" },
"confidence": "high",
"last_refreshed": "2026-06-15T16:20:52.364Z",
"indicators": [
{
"indicator_id": "sp_dyn_le00_in",
"label_es": "Esperanza de vida al nacer",
"label_en": "Life expectancy at birth",
"label_pt": "Expectativa de vida ao nascer",
"unit": "años",
"latest": { "year": 2024, "value": 75.264 },
"delta_pct": 3.199,
"delta_basis": "latest value vs mean of the prior 3 years (smooths COVID-era volatility)",
"good_direction": "up",
"coverage": "primary",
"benchmark": { "oecd": 80.39, "world": 73.48, "sea_peers": 73.87, "oecd_vintage": 2024 },
"sdg": { "code": "3", "level": "goal",
"indicator_name": "Ensure healthy lives and well-being …", "goal": 3 },
"source": {
"name": "World Bank Open Data",
"url": "https://data.worldbank.org/indicator/SP.DYN.LE00.IN?locations=MX",
"citation_id": "wb-sp-dyn-le00-in-mex-2024"
},
"series": [ { "year": 2000, "value": 72.562 }, { "year": 2001, "value": 72.912 }, "…" ]
}
]
}Every indicator carries latest (year + value), the full series back to ~2000, its source (name + primary-source deep link + citation_id), an official sdg code when one exists (level is indicator, target or goal), and delta_pct (see delta_basis: latest vs. the mean of the prior 3 years). sdg is null when no official SDG code applies — never fabricated.
CSV
The same cell flattened to one row per indicator-year:
curl -s https://futuros.xyz/api/v1/observations/salud__MEX.csvHeader:
parameter,iso3,m49,indicator_id,label_es,unit,year,value,delta_pct_latest,
good_direction,coverage,vintage_year,sdg_code,sdg_level,source_name,source_url,citation_id,
label_en,label_ptlabel_en,label_pt sit at the end of the row — column order is part of the CSV contract, so additions are always appended, never inserted.
Observations — bulk
Every cell concatenated into one CSV — the whole corpus in a single download (~164k rows):
curl -s https://futuros.xyz/api/v1/observations.csv -o futuros-observations.csvSame header as the per-cell CSV. Load directly:
import pandas as pd
df = pd.read_csv("https://futuros.xyz/api/v1/observations.csv")Observations — SDMX-CSV
The bulk data as SDMX-CSV (ISO 17369), REF_AREA keyed on UN M49 to match UNSD-SDG / Data360 conventions:
curl -s https://futuros.xyz/api/v1/observations.sdmx.csvDATAFLOW,FREQ,REF_AREA,INDICATOR,TIME_PERIOD,OBS_VALUE,UNIT_MEASURE,
SDG_INDICATOR,REF_AREA_ISO3,FUTUROS_PARAMETER,SOURCE,SOURCE_URL,CITATION_ID
FUTUROS:DF_OBSERVATIONS(1.0),A,032,si_pov_gini,2000,51,índice,10,ARG,
cohesion-social-inclusion,World Bank Open Data,https://data.worldbank.org/…,wb-si-pov-gini-arg-2024DATAFLOW is FUTUROS:DF_OBSERVATIONS(1.0), FREQ is A (annual).
Excel workbook — futuros-data.xlsx
One workbook with five sheets — README (license + delta_pct basis), parameters, geographies, observations (all rows), and citations:
curl -s https://futuros.xyz/api/v1/futuros-data.xlsx -o futuros-data.xlsxCitations — citations.json / citations.csv
The full source registry every citation_id resolves against.
curl -s https://futuros.xyz/api/v1/citations.json
curl -s https://futuros.xyz/api/v1/citations.csvCSV columns: id,source,title,url,year,retrieved_at. Join observations.*'s citation_id to citations.*'s id to attach full source metadata to any value.
Freshness ledger — freshness.json
Per-source refresh cadence — when each indicator was last pulled and when it is next due. A passthrough of the build's refresh-meta.json. Top-level keys: generated_at, total_indicators, indicators, plus a sources block.
curl -s https://futuros.xyz/api/v1/freshness.json{
"generated_at": "2026-06-15T16:20:52.364Z",
"total_indicators": 48,
"indicators": {
"SI.POV.GINI": {
"source": "World Bank Open Data",
"source_url": "https://data.worldbank.org/indicator/SI.POV.GINI",
"cadence_months": 12,
"last_refreshed": "2026-06-15T16:20:52.364Z",
"next_refresh_due": "2027-08-15T20:11:16.364Z",
"countries_with_data": 0, "total_countries": 25
}
}
}SDG crosswalk — sdg-crosswalk.json
The verified map from pillars and indicators to official SDG codes (goals, pillars, indicators). Join on indicator_id.
curl -s https://futuros.xyz/api/v1/sdg-crosswalk.jsonSignals — signals/<param>__<ISO3>.json
LLM-extracted, cited "what's moving" signals per cell (present because the multi-source layer is baked). Same <param>__<ISO3> addressing as observations, and since the 2026-08-05 bake coverage is total: all 260 cells have a file (counts.signal_files in the manifest). The 10 regional-aggregate cells (<param>__LATAM), which the signal harvest never writes, are baked as an empty array []; they previously had no file and answered HTTP 404. Treat a [] as "this cell has no signals", not as an error; a 404 under signals/ now does indicate a mistyped path.
curl -s https://futuros.xyz/api/v1/signals/salud__ARG.jsonContributions — contributions.json
License-gated data contributed through the Data Trust (Pillar 1), published only for postures that permit republishing values. Currently count: 0.
curl -s https://futuros.xyz/api/v1/contributions.jsonGovernance Engine — governance/*.json
The Governance Engine (Pillar 3) datasets are part of the versioned contract since the 2026-08-05 bake (they were previously reachable only via MCP or as an unversioned file). Each is a passthrough of the file the UI consumes, served with the same additive-only stability contract, CORS and caching as the rest of v1:
| Path | What |
|---|---|
/api/v1/governance/resilience.json | Democratic Resilience Index: subindices + composite, cited per country |
/api/v1/governance/regulation-index.json | Regulatory Observatory: instruments per country and pillar |
/api/v1/governance/scores.json | Goalpost composite index per geography (2010-2024 series) |
curl -s https://futuros.xyz/api/v1/governance/resilience.jsonThe MCP path remains available: get_dataset with dataset_id: "democracy-resilience" on the MCP server returns the same resilience index with deterministic compute. The unversioned file GET /data/democracy/resilience.json also still exists, but with no stability contract; prefer the /api/v1/governance/ paths above.
Discovery — DCAT catalog at /data.json
A DCAT / project-open-data v1.1 catalog at the site root (not under /api/v1/) so data portals and crawlers can auto-discover the datasets:
curl -s https://futuros.xyz/data.jsonIt advertises the bulk CSV, SDMX-CSV, XLSX, citations, SDG crosswalk and freshness datasets, each with downloadURL, mediaType and CC BY 4.0 license.
RSS feeds · /feeds/
The cited signals are also published as 267 static RSS 2.0 feeds under /feeds/ (at the site root, not under /api/v1/), built for RSS readers and institutional monitoring. Same regime as the read API: static GET, no auth, cacheable.
| Path | What |
|---|---|
/feeds/futuros.xml | Regional master: every signal region-wide in one feed |
/feeds/<ISO3>.xml | Per country: union of its 10 pillar feeds, max 50 items |
/feeds/<param>__<ISO3>.xml | Per cell (pillar × country): cited signals with source deep-links |
/feeds/index.json | Manifest: total_feeds, cells_written, cells_skipped_empty, countries_written, forecasts_included, forecasts_skipped_unverifiable, and feeds[] with title, path, kind and item count |
curl -s https://futuros.xyz/feeds/salud__BRA.xmlThe feeds are no longer frozen: scripts/bake-feeds.ts runs inside the prebuild chain (alongside bake-data-health, bake-source-ledger and bake-api, right before check-api), so they are re-baked on every deploy from the current signal corpus. The manifest is explicit about what it did not publish: cells_skipped_empty counts cells with no signal — no empty feed is emitted to simulate coverage — and forecasts_skipped_unverifiable counts forecasts left out for not being verifiable.
Data-gap backlog — GET /api/backlog
Unlike everything above, this is not a baked file under /api/v1/: it is a live function (api/backlog.ts), because what it serves is produced at request time rather than at bake time. It is documented here because it is public, unauthenticated and read-only, like the rest of this page.
Every time the assistant queries the corpus and a corpus tool comes back empty, the server files a structured ticket instead of letting the signal die. This endpoint is how that backlog becomes visible — to the SPA (/vacios), to the ingestion lane deciding what to source next, and to anyone who wants to check that "we don't have that figure" is followed by something.
Parameters
| Parameter | Default | What it does |
|---|---|---|
limit | 100 (max 500) | How many tickets to return |
country | — | Filter by ISO3 (upper-cased) |
pillar | — | Filter by axis slug |
Rate limit: 60 requests per minute per IP; over that it returns 429 with Retry-After. Cache: s-maxage=60, stale-while-revalidate=300.
Response
{
"ok": true,
"configured": true,
"count": 2,
"gaps": [
{
"id": "gap-ecu-medio-ambiente-clima-1a2b3c4d",
"created_at": "2026-08-16T09:12:44.031Z",
"lang": "es",
"country": "ECU",
"pillar": "medio-ambiente-clima",
"route": "/atlas",
"empty": ["get_dataset:regulation", "get_dataset:signals"],
"candidate_sources": [
{ "title": "SIMAS", "url": "https://ambiente.gob.ec/simas", "source": "ambiente.gob.ec", "date": "2026" }
],
"missing_indicators": ["Area under mining concession"],
"hits": 7
}
]
}configured: falsemeans the durable store (Upstash) is not wired on that deployment:gapsis then that instance's in-memory ring, neither shared nor persistent. It is declared rather than pretending a shared backlog exists.hitsis how many times that same gap has been raised. The id dedupes on the shape of the gap (country + axis + empty tools), not on the wording, so the same hole asked two ways increments a single ticket. That demand ordering is the ingestion queue.candidate_sourcesonly contains URLs an external tool actually returned in that same turn. Never a source the model named from memory.
No user text, by design
A ticket describes the hole, not the person who found it. It carries the country, the axis, the route, the tools that came back empty and the missing indicators; every one of those fields is drawn from platform-controlled vocabulary (ISO3 codes, axis slugs, tool keys, baked indicator labels).
The ticket neither persists nor serves the user's question. It is read only to establish that a real turn happened, and then dropped. Because this endpoint is public and unauthenticated, anything the ticket kept would be something the asker published without meaning to — and the consent posture of the Data Trust has to hold for the people using the chat, not only for the institutions contributing to it.
The text is kept, but elsewhere and under different rules: the private question log lives under a different key (chat:questions:v1, never backlog:gaps:v1) and is readable only with Authorization: Bearer $QUESTION_LOG_TOKEN — with no token, GET /api/questions refuses everything. No public path reaches it: the backlog rebuilds every ticket field by field, so this endpoint's guarantee is exactly what it was.
The guarantee is enforced on read as well as on write: tickets written before this rule are still in the store, so readGaps rebuilds each row field by field against an allow-list and drops everything else. A newly added field cannot reach the public surface by accident either: it has to be added there on purpose.
There is no write surface. Tickets are written by the chat server, in the same process that observed the empty retrieval; no browser-reachable write path exists.
Endpoint reference
| Path | What |
|---|---|
/api/v1/index.json | Manifest: datasets, counts, bulk sizes, license, standards |
/api/v1/openapi.json | OpenAPI 3.1 spec of the read API (15 core paths) |
/api/v1/schemas/*.schema.json | JSON Schema: manifest, parameter, geography, observation-cell, citation |
/api/v1/parameters.json | 10 pillars + primary/secondary SDGs |
/api/v1/geographies.json | 25 countries + LATAM, ISO3 + M49 |
/api/v1/observations/<param>__<ISO3>.json | One cell: indicators, series, citations, SDG |
/api/v1/observations/<param>__<ISO3>.csv | Same cell, one row per indicator-year |
/api/v1/observations.csv | Bulk: every cell concatenated |
/api/v1/observations.sdmx.csv | Bulk, SDMX-CSV (ISO 17369), REF_AREA = M49 |
/api/v1/futuros-data.xlsx | Workbook (README/parameters/geographies/observations/citations) |
/api/v1/citations.json · .csv | Full citation registry |
/api/v1/freshness.json | Per-source refresh ledger |
/api/v1/sdg-crosswalk.json | Pillar → SDG + indicator → official SDG code |
/api/v1/signals/<param>__<ISO3>.json | Cited signals per cell (260/260 cells; the 10 regional __LATAM cells are []) |
/api/v1/governance/{resilience,regulation-index,scores}.json | Governance Engine: democratic resilience, regulatory observatory, composite index |
/api/v1/contributions.json | License-gated contributed data |
/data.json | DCAT catalog (site root) |
/feeds/futuros.xml · /feeds/<ISO3>.xml · /feeds/<param>__<ISO3>.xml | Static RSS 2.0 signal feeds, re-baked on every deploy by bake-feeds inside prebuild |
/feeds/index.json | Manifest of the 267 RSS feeds, with the counts of what was omitted |
GET /api/backlog | Data-gap backlog (live function, not baked) — see above |
GET /api/env-levers | Public posture of the 15 fail-closed production levers: per lever, its id, the variables that compose it, opens (what it enables) and probe (how to verify it from outside), plus a set boolean. Never returns secret values; Cache-Control: no-store. It is the machine-readable surface behind /confianza — see self-hosting |
For agentic, tool-based access to the same corpus (with deterministic compute and citations), see the MCP server.
Crawling policy
The data is open by design, so crawling is not blocked — but it is declared and observed:
robots.txt(https://futuros.xyz/robots.txt) allows everything except/admin/and an internal trap path. AI-training crawlers (GPTBot, ClaudeBot, CCBot, Bytespider, etc.) are explicitly allowed today; per-bot policy is a one-line change in that file (the token list is maintained inserver/scrape/known-agents.ts).- Scraping the HTML is pointless: every app route returns the same SPA shell with no content. The machine surfaces are this API, the MCP server and the DCAT catalog (
/data.json) — use those. - Access is observed, never blocked: an observation-only middleware (
middleware.ts+server/scrape/, runbook atserver/scrape/README.md) flags bulk-scrape and AI-copy patterns as internal events. No request is slowed or refused by this layer.