# Bella Ciao Partner Data API

> Read-only market-intelligence API for Coinmania partners: five Layer-2 composite indices (0–100), El Profesor's verdict with a deterministic crew consensus, per-agent reads and bilingual narratives, an embed-ready Market Lens snapshot, and attributed public time-series. Base URL `https://bellaciao.live`. Every success is `{data, meta}`; every error is `{detail, error_code, request_id}`. Agent verdicts follow the trading cycle (read `cycle.cadence_minutes` from `/health`); sources and on-demand composites have separate update schedules. Poll no faster than every 60 s and use `ETag`/`If-None-Match`. This is decision input, not financial advice; read the returned `mode` rather than assuming paper or live.

## Start here

- Index of everything below: `GET /api/v1/data` (JSON links)
- Machine-readable contract (partner routes only, `X-API-Key` security scheme): `GET /api/v1/data/openapi.json`
- Error-code registry with retry guidance: `GET /api/v1/data/errors`
- Product catalog with entitlements, cadence, licence class, attribution, methodology: `GET /api/v1/data/catalog` and `GET /api/v1/data/catalog/{product_id}`
- Liveness and per-product status: `GET /api/v1/data/health`
- What changed: `GET /api/v1/data/changelog?since=YYYY-MM-DD`
- Human docs: https://bellaciao.live/api-docs

## Authentication and tiers

- Anonymous = Tier 0: allowed on every route below except `/me`. Limits: 60 requests/min per IP on `/api/v1/data/*` plus a global 120/min per IP. Response headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`.
- Keyed = Tier 1: header `X-API-Key: <key>`. Per-key `rate_limit_per_min` and `quota_per_day` (see `/me`), exempt from the per-IP bucket. Headers add `X-RateLimit-Policy`, `X-Quota-Limit`, `X-Quota-Remaining`, `X-Quota-Reset`.
- Scopes are implied by tier for now: tier0 `data:read`; tier1 adds `data:components`, `data:agents`, `data:narratives`, `data:history`, `data:series`.
- A presented key must be valid: unknown or revoked keys answer `401 API_KEY_INVALID`. Missing scope answers `403 SCOPE_MISSING`; anonymous range queries answer `403 RANGE_EXCEEDS_TIER`.
- If every route answers `503 DATA_API_DISABLED`, the surface is switched off on this deployment; retry later, nothing is wrong with your request.

## Routes

| Route | Auth | Returns |
|---|---|---|
| `GET /api/v1/data/health` | none | `status ok/degraded/down`, `cycle{last_cycle_number,last_cycle_at,cadence_minutes,stale}`, `stores`, `products[{id,status}]` |
| `GET /api/v1/data/catalog?kind=` | none | `products[]` with `id, path, tier_required, scope_required, entitled, cadence_seconds, history_depth_days, license_class, attribution[], known_gaps[]` |
| `GET /api/v1/data/catalog/{product_id}` | none | catalog entry + `fields[]`, `dimensions{}`, `methodology{version,components[{name,weight,anchor_range}]}`, `sample_url` |
| `GET /api/v1/data/sources?provider=&product=&status=&include_paused=` | none | freshness board of all raw sources (80 today; deliberately paused ones hidden unless `include_paused=true` — `total_sources`, `listed`, `excluded_paused` give the counts): `status`, `effective_cadence_seconds`, `ts_semantics`, `degraded_reason`, `feeds_products[]` (metadata only) |
| `GET /api/v1/data/sources/{source_id}` | none | one source + `exposure_rule`, `attribution[]`, `metric_id` when row-readable |
| `GET /api/v1/data/changelog?since=&type=` | none | `entries[{date,type,scope[],summary,sunset_at}]` |
| `GET /api/v1/data/composites/latest?asset=BTC,ETH,SOL&index=sentiment,onchain,derivatives,macro,fundamental` | none (components: Tier 1) | `cycle_number, cycle_at, assets[{asset, indices[{index, score, label, methodology_version, contributing[], inputs_freshness{status,stale_inputs[],delayed_inputs[]}, degraded, warnings[]}]}]` |
| `GET /api/v1/data/verdicts/latest` | none (agent summaries: Tier 1) | `mode, symbols[], verdict{signal,confidence,confidence_pct}, consensus{signal,score,confidence,contributing_weight,abstained}, votes{bullish,bearish,neutral,monitoring}, agents[{agent,signal,confidence}], narrative{available,path}` |
| `GET /api/v1/data/verdicts/history?from=&to=&signal=&limit=&cursor=` | none = last 24 cycles; ranges: Tier 1 `data:history` | `cycles[{cycle_number, cycle_at, verdict, consensus, votes, backfilled}]`, `meta.next_cursor`, `meta.gaps[]` |
| `GET /api/v1/data/agents/{agent}/latest?lang=` | none | `agent, signal, confidence, summary_short, summary_lang, headline, market_regime, risk_flags[], data_categories[], decided_at` — agent ∈ tokio, berlin, nairobi, helsinki, denver, marseille, profesor. `summary_short` is the narrator's interpretation in `lang` when archived (else the agent's English reasoning — read `summary_lang`); profesor's card comes from his cycle synthesis |
| `GET /api/v1/data/narratives/cycle/{cycle_number}/agents?lang=&include_prose=` | headlines: none; prose: Tier 1 `data:narratives` | `agents[{agent, signal, confidence_pct, headline, headline_lang, narrative?, narrative_lang, data_categories[], key_metrics_labels[], risk_flags[]}]` — `profesor` (synthesised from the cycle synthesis, entry-plan / risk-calculation sections omitted) is listed first; `*_lang` tells you which language was actually served when the requested one is missing |
| `GET /api/v1/data/market-lens/snapshot?lang=` | none | `verdict, agents[], composites[{index,score,label,degraded}], context[{id: fear_greed|fed_funds_rate_pct|ls_ratio|price, value, event_at, status, attribution}]` |
| `GET /api/v1/data/latest?ids=composite.derivatives:ETH,verdict,series:fred.fed_rate` | none | `items[{id, kind, value|score|signal, event_at, age_seconds, status}]`, `missing[]`; 1–25 ids (missing param → 422, blank → 400, > 25 → 400 TOO_MANY_IDS) |
| `GET /api/v1/data/series/{metric_id}?asset=&symbol=&dimension=&from=&to=&limit=&cursor=&order=&dedupe=` | none = trailing 7 d / ≤ 100 rows; ranges: Tier 1 `data:series`+`data:history` | `metric_id, unit, ts_semantics, points[{event_at, value}]`, `meta.attribution[]` (must be displayed) |
| `GET /api/v1/data/me?days=` | Tier 1 key | your entitlements, limits, today's usage, daily aggregates |

### Public series you can read (`/series/{metric_id}`)

`fred.fed_rate`, `fred.m2`, `fred.cpi`, `fred.unemployment` (FRED) · `alt.fear_greed` (Alternative.me) · `defillama.tvl_chain?dimension=ethereum|solana`, `defillama.stablecoins` (DefiLlama, aggregates) · `bci.hash_rate`, `bci.difficulty`, `bci.n_unique_addresses`, `bci.miners_revenue` (Blockchain.com) · `mempool.fees`, `mempool.hashrate_3m`, `mempool.difficulty_adjustment` (mempool.space) · `github.commits_btc|eth|sol` (weekly) · `exchangerate.usd_gel` · `binance.ls_ratio?symbol=BTCUSDT|ETHUSDT|SOLUSDT` (last 24 h) · `binance.price?symbol=` (latest only). Every other id in the catalog is licence class B (paid or restricted provider) and answers `403 SERIES_NOT_LICENSED` — those inputs reach you only inside the composites.

## The envelope

```json
{"data": {...}, "meta": {"api_version": "v1", "schema_version": "composites/1", "request_id": "…", "generated_at": "2026-09-04T12:03:41+00:00", "as_of": "2026-09-04T12:00:07+00:00", "freshness": {"status": "fresh", "age_seconds": 214, "cadence_seconds": 3600, "stale": false, "degraded_reason": null, "source": "redis"}, "attribution": [{"source": "Binance", "text": "Market data: Binance", "url": "https://www.binance.com"}], "next_cursor": null, "gaps": [], "mode": "paper", "generated_by": null, "tier_served": "tier0", "disclaimer": "…", "unavailable": []}}
```

- `meta.as_of` is EVENT time (when the fact was true); `meta.freshness.age_seconds` is operational age (seconds since Bella fetched/produced it). Daily series legitimately have large event ages.
- `meta.freshness.status`: `fresh` (age ≤ 2× cadence) · `aging` (≤ 5×) · `stale` (> 5×) · `degraded` (source known-broken, see `degraded_reason`: quota_exhausted, plan_gated, provider_unreachable, ingest_gap, worker_disabled, db_outage) · `unavailable` (feature off) · `never` · `paused` (ingestion deliberately off). Composites also carry `inputs_freshness` and flip `degraded: true` when > 50 % of their weight is stale — the score is still served.
- `meta.unavailable[]` lists things you asked for that were not served, with a reason. Nothing is silently omitted.
- `meta.gaps[]` lists known outages overlapping a history window (e.g. 2026-08-30 → 2026-09-04 `db_outage`).
- Timestamps are ISO-8601 UTC with `+00:00`. Numbers are JSON numbers: scores 1 decimal, confidences 4 decimals.

## Errors and retries

`{detail: string, error_code: string, request_id: string}`; `X-Request-ID` on every response, `X-Error-Id` on 5xx, `Retry-After` on 429/503.

| HTTP | error_code | What to do |
|---|---|---|
| 400 | BAD_REQUEST, UNKNOWN_ASSET, UNKNOWN_INDEX, INVALID_CURSOR, INVALID_RANGE, RANGE_TOO_LARGE, TOO_MANY_IDS | fix the request; for INVALID_CURSOR restart with an explicit `from` |
| 401 | API_KEY_REQUIRED, API_KEY_INVALID | add or fix `X-API-Key` |
| 403 | SCOPE_MISSING, TIER_INSUFFICIENT, RANGE_EXCEEDS_TIER, SERIES_NOT_LICENSED | do not retry; use a Tier-1 key or a licensed id |
| 404 | PRODUCT_NOT_FOUND, SOURCE_NOT_FOUND, METRIC_NOT_FOUND, CYCLE_NOT_FOUND, NARRATIVE_NOT_FOUND, AGENT_NOT_FOUND | check `/catalog` |
| 422 | VALIDATION_ERROR | `detail` is the first message; `errors[]` has all |
| 429 | RATE_LIMITED, QUOTA_EXCEEDED | wait `Retry-After`; back off 100 ms → 500 ms → 2 s → 5 s → 15 s |
| 500 | INTERNAL_ERROR | retry once, then report `request_id` |
| 503 | DATA_API_DISABLED, STORE_UNAVAILABLE, NARRATIVE_STORE_UNAVAILABLE | wait `Retry-After` |

## Examples

```bash
curl -s https://bellaciao.live/api/v1/data/health | jq '.data.status, .data.cycle'
curl -s 'https://bellaciao.live/api/v1/data/composites/latest?asset=ETH' | jq '.data.assets[0].indices[] | {index, score, label, degraded}'
curl -s https://bellaciao.live/api/v1/data/verdicts/latest | jq '.data.verdict, .data.consensus, .data.votes'
curl -s 'https://bellaciao.live/api/v1/data/agents/helsinki/latest?lang=en' | jq '.data | {signal, confidence, summary_short, risk_flags}'
curl -s 'https://bellaciao.live/api/v1/data/series/fred.fed_rate' | jq '.data.points[0], .meta.attribution'
curl -s -H 'X-API-Key: <key>' https://bellaciao.live/api/v1/data/me | jq '.data.tier, .data.scopes, .data.today'
```

## Obligations and limits

- Display `meta.attribution[].text` next to any public-fact series you show (FRED, Alternative.me, DefiLlama, Blockchain.com, mempool.space, GitHub, ExchangeRate-API, Binance).
- Composite indices, verdicts, agent reads and narratives are Bella Ciao intellectual property licensed to you; the underlying paid-provider values are never included and must not be inferred or re-sold.
- Outputs are decision inputs, not investment advice. Read the returned `mode`; past signals do not predict future performance. Show `meta.disclaimer` where end users see the data.
- Feedback: report problems with `meta.request_id` (also the `X-Request-ID` header), endpoint, UTC timestamp and expected vs actual — template `FEEDBACK_TEMPLATE.md` in the handover pack; channel as agreed with the Bella Ciao operator. Never include your key.

## For AI assistants

- An MCP server is available: `bellaciao-mcp` (source `integrations/mcp/` in the BellaCiao repository; ask your Bella Ciao contact for the package). One tool per route, tool names equal the OpenAPI operationIds (`data_health`, `data_catalog`, `data_composites_latest`, …), same limits and licences. Otherwise read `/api/v1/data/openapi.json` and this file.
- Cache `/catalog` for 5 minutes and `/*/latest` for 60 seconds; always read `meta.freshness.status` and `degraded` before quoting a number.
- `/sources` lists provider names and status for every feed, including paid ones; it never contains their values. `403 SERIES_NOT_LICENSED` on a paid id is expected behaviour, not an outage.
- Never fabricate values for `unavailable`, `stale` or `degraded` items; surface the status instead.
- History cursors pin the original from/to, filters, order and dedupe setting. Keep these unchanged across pages; v2 supports ascending and descending series. On `INVALID_CURSOR`, restart with explicit bounds. Old v1 cursors are not accepted.
- A freshly downloaded input can still represent delayed observations (for example a 30-day source delay). Such inputs are listed in `inputs_freshness.delayed_inputs` with a warning; `status` and `degraded` describe our ingestion age, not the provider's publication lag. Read warnings. Stale values may remain included in a composite; do not claim they were excluded.
- Composite `cycle_number`/`cycle_at` identify the latest recorded cycle for context, not a durable index snapshot. Complete immutable composite history is not yet offered.
