Response envelope
Every successful 2xx response from the Blockchain Academics platform — REST API, MCP tools (TypeScript and Python servers), and first-party SDKs — 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.
Migration from 0.2 → 0.3 (locked 2026-04-22). The envelope moved from a flat {data, cite_url, as_of, source_hash} shape to the canonical {data, attribution, meta} shape below. The flat shape is gone. See What changed in 0.3 at the bottom of this page.
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.
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 | Canonical URL on blockchainacademics.com. Always HTTPS. UTM-tagged. |
as_of | string | RFC3339 UTC timestamp — when the upstream snapshot was reconciled. |
source_hash | string | sha256:<64-hex> of the upstream snapshot. For caching + audit. |
citations[0] is always the primary source. Subsequent entries (when populated) are corroborating sources. Today most responses return a single citation; multi-source responses are being rolled out in 0.4 — see Citations for the rationale.
Why array-only, even with one element? Because the day multi-source citations ship (price from multiple oracles, news cross-referenced across outlets), existing clients that read citations[0].cite_url keep working unchanged. A cite_url shortcut at the root would have forced a breaking change. We paid the small cost up front.
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 / 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 + 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 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;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 — per-tool payload shapes.