Docs navigation

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

json
{
  "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.

FieldTypeMeaning
cite_urlstringThe page the data came from. Always HTTPS. UTM-tagged on our own pages.
as_ofstringRFC3339 UTC timestamp: when the upstream snapshot was reconciled.
source_hashstringsha256:<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.

ValueMeaning
completeFully reconciled data from the canonical upstream. The default, expected 99%+ of the time on live tools.
unseededTool is scaffolded but upstream is not yet wired in. data will be null or a typed stub. diagnostic set.
partialRuntime degradation: upstream responded but with partial or stale data. data is usable but flagged.
staleCache 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.

FieldTypeMeaning
hasNextPagebooleantrue if another page of results exists forward.
hasPreviousPagebooleantrue if the cursor can walk backward.
startCursorstring | nullOpaque cursor for the first item on this page.
endCursorstring | nullOpaque 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:

json
"diagnostic": {
  "reason": "Twitter live feed integration scheduled for 0.4.",
  "eta": "2026-05-15"
}

When status: "partial": runtime upstream degradation:

json
"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: 1713787200

X-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:

json
{
  "error": {
    "code": "BCA_BAD_REQUEST",
    "message": "`limit` must be between 1 and 200.",
    "request_id": "req_01HABCDEF0123456789ABCDEFG"
  }
}
HTTPCanonical error.codeWhen
400BCA_BAD_REQUESTValidation failure: bad params or body.
401BCA_AUTHMissing / invalid API key.
403BCA_TIER_LOCKEDCaller below required tier for the tool.
404BCA_NOT_FOUNDEntity / article / slug not in corpus.
429BCA_RATE_LIMITPer-key rate limit. Honor Retry-After.
5xxBCA_UPSTREAMBackend 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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

ts
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 rootattribution.citations[0].cite_url
as_of at rootattribution.citations[0].as_of
source_hash at rootattribution.citations[0].source_hash
X-BCA-Integration-Status: pending headermeta.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 envelopemeta.pageInfo (Linear / GraphQL cursor shape)
Rate limits in {error} body fieldsHTTP 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