Response envelope
Every successful 2xx response from the Blockchain Academics platform (the REST API, the MCP servers in TypeScript and Python, and the bca CLI) returns the same three-key envelope. Your agent never branches on transport or tool identity to find the payload, the citations, or the request id.
Canonical shape
{
"data": { "...": "domain payload: tool- or endpoint-specific" },
"attribution": {
"citations": [
{
"cite_url": "https://blockchainacademics.com/article/bitcoin-price-history?src=claude",
"as_of": "2026-04-22T10:12:00Z",
"source_hash": "sha256:abc123..."
}
]
},
"meta": {
"status": "complete",
"request_id": "req_01HABCDEF0123456789ABCDEFG",
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": null,
"endCursor": null
}
}
}Three top-level keys. Always the same three. Always in that order.
Fields
data
The domain payload. Schema is endpoint- or tool-specific: see the REST reference or Tool reference.
data is pure payload. It never contains an envelope-style status field, never contains citations, never contains a request id. Those belong in meta and attribution respectively. If you find yourself writing response.data.status, you are reading a pre-0.3 client: upgrade.
On list endpoints, data typically looks like { items: [...], total?: number }. On singleton endpoints, data is the object directly.
Over MCP, the server serialises data as a string inside an <untrusted_content> fence so the host model treats upstream text as data, not instructions; attribution and meta stay structured. See MCP quickstart → Parse the envelope. Over HTTP, data is the JSON object itself.
attribution.citations[]
Always an array. Always present on 2xx responses. Always non-empty on 200: a successful response without at least one citation is a server bug, file an issue.
| Field | Type | Meaning |
|---|---|---|
cite_url | string | The page the data came from. Always HTTPS. UTM-tagged on our own pages. |
as_of | string | RFC3339 UTC timestamp: when the upstream snapshot was reconciled. |
source_hash | string | sha256:<64-hex> of the upstream snapshot. For caching and audit. |
citations[0] is always the primary source. Since 0.4.0, a tool that reads a third-party upstream (a CoinGecko coin page, an Etherscan transaction, an SEC filing) puts that upstream page first and the Blockchain Academics editorial link second at citations[1]; a tool built on our own corpus returns a single citation. See Citations for the rules.
meta.status
Enum. One of four values. Drives client behavior: see Status decision tree.
| Value | Meaning |
|---|---|
complete | Fully reconciled data from the canonical upstream. The default, expected 99%+ of the time on live tools. |
unseeded | Tool is scaffolded but upstream is not yet wired in. data will be null or a typed stub. diagnostic set. |
partial | Runtime degradation: upstream responded but with partial or stale data. data is usable but flagged. |
stale | Cache served because upstream was unavailable at request time. as_of lags normal freshness. |
Errors do not use meta.status. Errors use HTTP 4xx/5xx and an error body: see Errors.
meta.request_id
ULID, prefixed req_. Echoed in the X-Request-ID response header (Stripe pattern). Every support ticket, every bug report, every audit log references this id. Persist it with your agent's turn log.
meta.pageInfo
Linear / GraphQL-style cursor pagination. Present on list endpoints; all four fields are null on singleton endpoints.
| Field | Type | Meaning |
|---|---|---|
hasNextPage | boolean | true if another page of results exists forward. |
hasPreviousPage | boolean | true if the cursor can walk backward. |
startCursor | string | null | Opaque cursor for the first item on this page. |
endCursor | string | null | Opaque cursor for the last item: pass it as after. |
Cursors are server-internal: never parse or construct them client-side. Pass endCursor back as the after query parameter to fetch the next page.
meta.diagnostic (optional)
Present only when meta.status is unseeded or partial. Absent otherwise.
When status: "unseeded": pre-launch stub, integration scaffolded:
"diagnostic": {
"reason": "Twitter live feed integration scheduled for 0.4.",
"eta": "2026-05-15"
}When status: "partial": runtime upstream degradation:
"diagnostic": {
"reason": "CoinGecko OHLC endpoint returned 502 for 3 of 7 candles.",
"vendor": "coingecko",
"upstream_status": 502
}Rate limits
Rate-limit state lives exclusively in HTTP response headers. It is not in the envelope body:
X-RateLimit-Limit: 1200
X-RateLimit-Remaining: 1187
X-RateLimit-Reset: 1713787200X-RateLimit-Reset is a Unix epoch second. When you hit 0 remaining, the next request returns HTTP 429.
Errors
Errors break envelope shape intentionally. They return HTTP 4xx/5xx and an error body:
{
"error": {
"code": "BCA_BAD_REQUEST",
"message": "`limit` must be between 1 and 200.",
"request_id": "req_01HABCDEF0123456789ABCDEFG"
}
}| HTTP | Canonical error.code | When |
|---|---|---|
| 400 | BCA_BAD_REQUEST | Validation failure: bad params or body. |
| 401 | BCA_AUTH | Missing / invalid API key. |
| 403 | BCA_TIER_LOCKED | Caller below required tier for the tool. |
| 404 | BCA_NOT_FOUND | Entity / article / slug not in corpus. |
| 429 | BCA_RATE_LIMIT | Per-key rate limit. Honor Retry-After. |
| 5xx | BCA_UPSTREAM | Backend or upstream failure. Retry w/ backoff. |
request_id is present on every error body and in X-Request-ID. Reference it in support.
Status decision tree
Map meta.status to client behavior:
meta.status = "complete"
└─ Happy path. Use data. Surface citations[0].cite_url per tier rules.
meta.status = "unseeded"
└─ Integration not yet live.
├─ meta.diagnostic.reason → human-readable explanation
├─ meta.diagnostic.eta → ISO date when it ships
└─ Agent response: "That data source isn't live yet (ETA: {eta})."
Do NOT pretend data.value === 0 means zero.
meta.status = "partial"
└─ Upstream degraded at request time. Data is usable but flagged.
├─ Use data. Note freshness concern in your agent's answer.
├─ meta.diagnostic.vendor → which upstream degraded
└─ If your use case is trading/compliance: retry in 30s or fall
through to a secondary source.
meta.status = "stale"
└─ Cache served; upstream unreachable now.
├─ attribution.citations[0].as_of reflects the cached snapshot
└─ Show as_of prominently to the user. Decide per use case
whether staleness is acceptable.Every non-complete status carries enough metadata for the agent to compose an honest, user-facing explanation. That's the whole point of the enum.
Full examples
complete: the happy path
{
"data": {
"items": [
{
"slug": "circle-mica-license",
"title": "Circle secures full MiCA license",
"published_at": "2026-04-18T14:02:17Z",
"summary": "Circle became the first USD stablecoin issuer…",
"entities": ["circle", "mica", "european-union"]
}
],
"total": 1
},
"attribution": {
"citations": [
{
"cite_url": "https://blockchainacademics.com/article/circle-mica-license?src=claude&utm_medium=mcp&utm_campaign=search_news",
"as_of": "2026-04-21T09:31:22Z",
"source_hash": "sha256:a9f1b7de2c4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
}
]
},
"meta": {
"status": "complete",
"request_id": "req_01HABCDEF0123456789ABCDEFG",
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": null,
"endCursor": null
}
}
}unseeded: integration pending
{
"data": null,
"attribution": {
"citations": [
{
"cite_url": "https://blockchainacademics.com/tools/twitter-live?src=claude",
"as_of": null,
"source_hash": null
}
]
},
"meta": {
"status": "unseeded",
"request_id": "req_01HABCDEF0123456789ABCDEFH",
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": null,
"endCursor": null
},
"diagnostic": {
"reason": "Twitter live feed integration scheduled for 0.4.",
"eta": "2026-05-15"
}
}
}Note: as_of and source_hash may be null on unseeded responses. The citation still points at a canonical BCA page that will host the feature once it ships.
partial: runtime upstream degradation
{
"data": {
"candles": [
{ "t": "2026-04-22T09:00:00Z", "o": 67200.5, "h": 67450.0, "l": 67100.0, "c": 67380.2 },
{ "t": "2026-04-22T10:00:00Z", "o": 67380.2, "h": null, "l": null, "c": null }
]
},
"attribution": {
"citations": [
{
"cite_url": "https://blockchainacademics.com/market/btc?src=claude&utm_medium=mcp&utm_campaign=get_ohlc",
"as_of": "2026-04-22T10:12:00Z",
"source_hash": "sha256:b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3"
}
]
},
"meta": {
"status": "partial",
"request_id": "req_01HABCDEF0123456789ABCDEFI",
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": null,
"endCursor": null
},
"diagnostic": {
"reason": "Upstream returned 502 on 1 of 2 candles.",
"vendor": "coingecko",
"upstream_status": 502
}
}
}stale: cache served
{
"data": {
"symbol": "BTC",
"price_usd": 67380.2,
"price_change_24h_pct": 1.14
},
"attribution": {
"citations": [
{
"cite_url": "https://blockchainacademics.com/market/btc?src=claude&utm_medium=mcp&utm_campaign=get_price",
"as_of": "2026-04-22T09:47:12Z",
"source_hash": "sha256:c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4"
}
]
},
"meta": {
"status": "stale",
"request_id": "req_01HABCDEF0123456789ABCDEFJ",
"pageInfo": {
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": null,
"endCursor": null
}
}
}stale responses do not include diagnostic: the only thing the caller needs to know is conveyed by attribution.citations[0].as_of (the cached snapshot's timestamp).
Parsing the envelope
type Status = "complete" | "unseeded" | "partial" | "stale";
interface Citation {
cite_url: string;
as_of: string | null;
source_hash: string | null;
}
interface Envelope<T> {
data: T | null;
attribution: { citations: Citation[] };
meta: {
status: Status;
request_id: string;
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
diagnostic?: { reason: string; [k: string]: unknown };
};
}
const res = await fetch(url, { headers: { "X-API-Key": key } });
const env: Envelope<ArticleList> = await res.json();
if (env.meta.status !== "complete") {
console.warn(`[${env.meta.request_id}] ${env.meta.status}`, env.meta.diagnostic);
}
const primary = env.attribution.citations[0].cite_url;from typing import Any, Literal, TypedDict
Status = Literal["complete", "unseeded", "partial", "stale"]
class Citation(TypedDict):
cite_url: str
as_of: str | None
source_hash: str | None
resp = httpx.get(url, headers={"X-API-Key": key})
env = resp.json()
if env["meta"]["status"] != "complete":
print(f"[{env['meta']['request_id']}] {env['meta']['status']}",
env["meta"].get("diagnostic"))
primary = env["attribution"]["citations"][0]["cite_url"]What changed in 0.3
| Before (0.2, flat) | After (0.3, canonical) |
|---|---|
cite_url at root | attribution.citations[0].cite_url |
as_of at root | attribution.citations[0].as_of |
source_hash at root | attribution.citations[0].source_hash |
X-BCA-Integration-Status: pending header | meta.status: "unseeded" + meta.diagnostic |
{data: null, status: "integration_pending"} | {data: null, meta: {status: "unseeded", diagnostic: {...}}} |
| Implicit request id (log-only) | meta.request_id (body) + X-Request-ID (header) |
| No standard pagination envelope | meta.pageInfo (Linear / GraphQL cursor shape) |
Rate limits in {error} body fields | HTTP headers only (X-RateLimit-*) |
Why the change. The flat shape worked fine when every response had exactly one citation and one freshness timestamp. That assumption breaks the moment you want multi-oracle prices, cross-outlet news corroboration, or enterprise-grade request tracing. The canonical envelope is JSON:API-inspired, drift-resistant, and matches shapes enterprise buyers already recognize (Stripe's request_id, Linear's pageInfo, JSON:API's data/meta split).
Client migration. Read response.attribution.citations[0].cite_url instead of response.cite_url. That is the one change that covers 95% of callers. The rest (pagination, status enum handling) is additive.
Next
- Citations: why
citations[]is an array, and what multi-source looks like. - Attribution & licensing: your obligation when surfacing citations downstream.
- Tiers & quotas: who can pass
as_of, who can setstrip_attribution. - REST API reference: endpoint map, pagination, errors.
- Tool reference: every tool's arguments and tier.