Embeds & white-label
Any newspaper, NGO, or government microsite can carry a real, cited Futuros figure (value plus a source chip that deep-links to the primary source) with a single <iframe>. The honesty contract travels with the embed: an embed never shows a number without its provenance.
Three widget routes plus a builder:
| Route | Renders |
|---|---|
/embed/stat | One cited stat — big value + unit, signed delta chip, source chip, wordmark |
/embed/chart | The same indicator as a small line chart (latest point labeled) |
/embed/board | A responsive grid of several stat/chart panels — a whole dashboard in one iframe |
/incrustar | The ES builder UI — pick options, preview live, copy the snippet |
All embed routes are frameable cross-origin: vercel.json serves /embed/(.*) the same enforced Content-Security-Policy as the rest of the app (default-src 'self', scoped script-src and connect-src, object-src 'none', base-uri 'self', form-action 'self'), relaxing only frame-ancestors to *. The rest of the site keeps frame-ancestors 'self' plus X-Frame-Options: SAMEORIGIN. App chrome (nav, onboarding) no-ops under /embed, so the widget paints only itself.
/embed/stat and /embed/chart
Shared query parameters:
| Param | Values | Default | Notes |
|---|---|---|---|
param | pillar slug (salud, educacion, …) | salud | which pillar |
iso | ISO3 (MEX, BRA, …) or LATAM | MEX | uppercased automatically; LATAM renders the regional aggregate ("América Latina y el Caribe") |
indicator | indicator id | first indicator of the cell | pick a specific metric |
theme | light | dark | dark | |
lang | es | en | pt | es | full trilingual strings + localized metric labels/units |
accent | hex color #RGB–#RRGGBBAA | Oxford blue (#11457e light / #6496d5 dark) | white-label accent (must match /^#[0-9a-fA-F]{3,8}$/); overrides both the accent and the positive-delta color |
The value, delta (vs. the 3-year mean), unit and source chip come from the same static cell payload the app uses; the source chip links to the indicator's primary source and the wordmark links back to the live atlas cell.
Four mechanics behind that paragraph (all in src/routes/_embed-shared.tsx):
- Static-only gate.
fetchParameter()loads the baked cell/data/parameter-cache/<param>__<ISO>.jsonand reportssource: "static" | "mock"plusstatus: ready | not_generated | quarantined. On a cache miss the main app paints a deterministic mock to keep its layout alive; the embed treats anything that is notsource:"static": or isnot_generated: as no data, because a mock value inside a third-party iframe would be a fabricated number wearing a real source chip. - Source chip resolution order. Three steps, first hit wins: inline fields on the indicator (
source,source_url,vintage_year) → the payload's owncitations[]row matched bycitation_id→ the source registry (inferSourceKey(citation_id)→ institution metadata →deepLinkFor()URL template). The registry step is the same machinery assyntheticCitation(): the chip can be rebuilt from the id alone, and only an id that maps to no known institution yields nothing. - Labels. Baked payloads carry canonical ES labels/units only;
?lang=en|ptoverlays them from the baked metrics registry, loaded async and cached module-wide. The ES text paints immediately and stays if that fetch never resolves, and the embed honours?lang=exactly, unlike the main app it never falls back to the visitor's stored language preference. - Delta colour is direction-aware. The chip colours by the indicator's
good_direction(up/down/neutral): a falling value of a down-is-good metric renders as good;neutralrenders grey.
/embed/chart additionally requires a non-empty series on the indicator, a cited value with no history renders the no-data state, never an empty chart. The latest point is marked with a reference dot and repeated as the headline value.
Minimal stat embed:
<iframe
src="https://futuros.xyz/embed/stat?param=salud&iso=MEX&theme=light&lang=en&et=ft_embed_YOUR_TOKEN"
width="320" height="180" style="border:0" loading="lazy"
title="Futuros — Health, Mexico"></iframe>White-labeled chart embed (custom accent, English, specific indicator):
<iframe
src="https://futuros.xyz/embed/chart?param=educacion&iso=BRA&indicator=se_xpd_totl_gd_zs&theme=dark&lang=en&accent=%23004b87&et=ft_embed_YOUR_TOKEN"
width="360" height="240" style="border:0" loading="lazy"
title="Futuros — Education spending, Brazil"></iframe>Note the URL-encoded # (%23). Suggested sizes match the /incrustar builder: stat 320×180, chart 360×240.
/embed/board — multi-panel dashboard
One iframe renders a themed, responsive grid of stat/chart panels. A partner (IDB / CAF / CELAC) drops a single iframe and gets a whole dashboard, white-labeled with no code change.
Parameters:
| Param | Values | Default | Notes |
|---|---|---|---|
panels | comma-separated kind:param:iso[:indicator] | — | kind = stat | chart; up to 12 panels |
theme | light | dark | dark | themes the shell and every panel |
accent | hex color | — | passes through to every panel |
brand | free text | Futuros | replaces the wordmark; with a custom brand the footer attribution reads "powered by Futuros ↗", otherwise it reads "futuros.xyz ↗" |
lang | es | en | pt | es | forwarded to every panel |
Each panel spec is kind:param:iso[:indicator]: e.g. stat:salud:MEX or chart:educacion:BRA:se_xpd_totl_gd_zs. The board mounts each panel as a same-origin /embed/stat or /embed/chart iframe, forwarding theme, lang and accent.
Parsing degrades per field, never whole-board: an unknown kind becomes stat, an empty param becomes salud, an empty iso becomes MEX (always uppercased), and anything past 12 panels is silently truncated. Panels render as fixed-height iframes (stat 160 px, chart 220 px) in an auto-fill grid with a 260 px minimum column, so the board reflows with the host page's width.
A white-labeled country dashboard:
<iframe
src="https://futuros.xyz/embed/board?panels=stat:salud:MEX,chart:educacion:MEX,stat:seguridad:MEX,chart:trabajo-economia:MEX&theme=light&accent=%23004b87&brand=IDB&lang=en&et=ft_embed_YOUR_TOKEN"
width="100%" height="640" style="border:0" loading="lazy"
title="Futuros dashboard — Mexico"></iframe>If panels is empty the board shows a hint (?panels=stat:salud:MEX,chart:educacion:BRA) rather than a blank frame.
Embed tokens
Since the accounts release, futuros.xyz requires a session on every app route, and an iframe on someone else's site cannot carry that cookie. An embed token stands in for it:
- Create the token at
/cuenta/embeds: a label, the allowed routes (/embed/stat,/embed/chart,/embed/board; the board includes stat and chart because it mounts them as children) and an expiry (default 365 days, maximum 730). Shown once; formatft_embed_<43 characters>. - Add
&et=<token>to the iframe URL (the/incrustarbuilder does it for you when you paste the token). The widget forwards the same token as anX-Embed-Tokenheader on its data reads, and the board propagates it to every panel. - Revoke from the same page. The edge caches a positive verdict for 60 s, so a revoked iframe stops loading within a minute. An expired, revoked, unknown or out-of-scope token answers
401with a one-line text that links to/cuenta/embeds.
The token opens only the /embed/* routes in its scope and the data files those widgets read (/data/parameter-cache/<parameter>__<country>.json and /data/metrics/registry.json), not the rest of the corpus.
| Situation | Edge response |
|---|---|
Valid et on an in-scope route | the widget, as before |
No et | 401 (text) |
et for another route (e.g. a stat-only token on /embed/board) | 401 |
| A data file outside the two above | 401 |
| Verifier unavailable | 503 + Retry-After: 30 |
The token identifies the account that created it; every creation and revocation is written to the audit log, and a deleted account's tokens stop working. Only its hash is stored (peppered SHA-256): losing it means creating another. The /embed/* CSP is unchanged: still frame-ancestors *.
/incrustar — the builder
https://futuros.xyz/incrustar?lang=en is the no-code builder for the single-widget embeds. Pick pillar, country, indicator, type (stat/chart) and theme; it shows a live iframe preview of the real cited value and gives you a copy-paste one-line snippet. The indicator dropdown is populated from the selected cell's actual indicators, so you only ever embed a metric that exists.
Theming — themeTokens override semantics
Each widget inlines a complete CSS-variable set on its own root (themeTokens() in _embed-shared.tsx), there is no global theme class, so host-page styles cannot bleed in and the widget cannot leak out:
| Token | Role | ?accent= overrides it? |
|---|---|---|
--e-bg / --e-panel / --e-line | Canvas, card, border | No |
--e-ink / --e-ink-2 / --e-ink-3 | Text hierarchy | No |
--e-accent | Wordmark, chart line stroke | Yes |
--e-good | Good-direction delta | Yes — same hex as the accent |
--e-bad | Bad-direction delta, quarantine caveat | No — stays red |
--e-cite | Citation chip | No |
The override is deliberately partial: accent replaces exactly --e-accent and --e-good, never --e-bad or --e-cite. A partner can brand the widget and its positive deltas; the warning colour and the citation chip are not brandable: you can white-label the number, not the caveat.
Honesty guarantees
The provenance contract has teeth inside the widget itself:
- No fabricated numbers. An embed refuses to render mock/fallback payloads, if the cell's data source isn't the static corpus, it shows "Data not available" instead of a made-up figure.
- Quarantine is visible. A cell flagged by the validators renders with an explicit "under review" caveat rather than silently presenting a doubted number.
- The citation travels. The source chip is reconstructed from the
citation_idalone (see Provenance), so the deep link works even though the embed never loads the full citation registry.
Framing & security
- Embed routes send the same full CSP as the app, with
frame-ancestors *as the only difference:default-src 'self',script-srcandconnect-srclimited to self plus the PostHog, jsDelivr and Google Fonts origins,object-src 'none',base-uri 'self',form-action 'self'. Nothing changes for an embedder (the iframe still works in any host page), but the embed surface no longer runs without content restrictions. - All other routes send
frame-ancestors 'self'andX-Frame-Options: SAMEORIGIN, so the main app is not frameable off-origin. - Embeds require an embed token (
?et=or theX-Embed-Tokenheader); see Embed tokens. They remain GETs of static data: there is nothing to submit. - Widgets are self-contained: theme is applied via inline CSS-variable overrides on the embed root (not a global class), so the same number reads correctly inside any host page.
The figures behind every embed are the same cited observations exposed by the Public API v1: the embed is just a rendered, linkable view of one cell.