ConceptsResponse envelope

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.

FieldTypeMeaning
cite_urlstringCanonical URL on blockchainacademics.com. Always HTTPS. UTM-tagged.
as_ofstringRFC3339 UTC timestamp — when the upstream snapshot was reconciled.
source_hashstringsha256:<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.

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 / 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 + 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 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: 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:

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

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