Skip to main content

Chain events reference

How GET /api/v1/chain-events, /chain-events/stats, and /blocks/{n}/chain-events serve the lakehouse deep-history all-events tier — params, response shapes, curl examples, how they differ from the curated event feeds, and what a cold tier returns.

Last updated

The deep-history all-events tier — every raw pallet.method event on the chain, read from the chain.chain_events lakehouse table over R2 SQL. This page covers the three /chain-events routes, how they differ from the other two "events" surfaces, and what you get back when the tier can't answer.

Three "events" surfaces — pick the right one

Metagraphed exposes three unrelated things named "events." They don't share a store, a shape, or a purpose.

RouteStoreReal-time?What it returns
GET /api/v1/eventsR2 artifact + KV pointerSSE, poll-on-reconnectNot chain data. A thin change feed over the registry's own publish snapshot (build pointer + changelog) — one snapshot SSE event per (re)connect, 5-minute suggested retry. Answers "did the site's content change," not "did the chain move."
GET /api/v1/subnets/{netuid}/eventsLakehouse — chain.account_eventsNear-real-time (cache: short)Curated, decoded events for one subnet: a fixed allowlist of "interesting" kinds (Transfer, NetworkAdded, StakeAdded/Removed, …), attributed to the account(s) involved. Originated on D1, spent a period on Postgres, and now reads the lakehouse.
GET /api/v1/chain-events (+ /stats, + /blocks/{n}/chain-events)Lakehouse — chain.chain_eventsNear-real-time (cache: short), degraded empty if it can't answerThe raw deep-history all-events tier documented on this page: every pallet.method event, no kind filtering, no account attribution. Never had a D1-era equivalent.

One store, two tables

The curated explorer views and the deep-history feed both read the lakehouse now. What separates them is not the store — it's the table:

  • The curated explorer surfaces (/blocks/{ref}/events, /accounts/{ss58}/events, /subnets/{netuid}/events) read chain.account_events — decoded and filtered down to a fixed allowlist of "interesting" kinds (Transfer, NetworkAdded, NeuronDeregistered, StakeAdded/Removed, and roughly thirty more), attributed to the account(s) involved.
  • The deep-history tier (/chain-events*) reads chain.chain_events — literally every pallet.method event the indexer decodes, no kind filtering, no account attribution.

How it got here

Worth knowing if you're reading older ADRs or the METAGRAPH_*_SOURCE flags and expecting them to still mean what they say.

ADR 0013 proposed D1 as a near-real-time explorer cache (blocks/extrinsics/account_events, pruned after a few days) with Postgres as the durable, unbounded sink feeding a genuinely new tier: chain_events, the raw all-events firehose. ADR 0014 (accepted 2026-07-10, supersedes 0013) recorded the cutover onto Postgres.

Both are history. The indexer box that ran Postgres was decommissioned, the postgres.js driver and the read dispatcher behind it were deleted, and HYPERDRIVE is unbound — so nothing can serve from Postgres regardless of configuration. The serving flags (METAGRAPH_BLOCKS_SOURCE, METAGRAPH_EXTRINSICS_SOURCE, METAGRAPH_ACCOUNT_EVENTS_SOURCE) read "retired" in production today, and each family is served by its own lakehouse reader.

Why the same block can show two different event counts

Not a bug: two different tables, two different filters, populated by the same indexer.

GET /api/v1/blocks/{n}/chain-events (raw, unfiltered chain_events) and GET /api/v1/blocks/{ref}/events (curated, filtered account_events) can legitimately report different counts for the identical block. The curated feed is always a subset of the raw one — routine system/consensus events (System.ExtrinsicSuccess, TransactionPayment.TransactionFeePaid, and similar per-extrinsic bookkeeping) show up in chain-events but were never in scope for the curated allowlist.

Don't treat a mismatch between the two as a sync bug or evidence of ingestion drift — cross-checking them for parity is comparing two different, intentionally-scoped views of the same block, not two copies of the same data.

What a cold tier returns

These routes are first-party handlers in the main API Worker. They used to be forwarded to a separate data Worker over a DATA_API service binding, but that upstream's store is gone, so the forward was a subrequest that could only fail — it was removed, and the lakehouse reader that was the fallback is now the primary path.

A tier that can't answer does not error. You get the same schema-stable empty every other tier in this API degrades to — 200 with zero rows — marked so you can tell it from a genuinely empty query, and barred from the edge cache so a cold answer is never served to anyone else.

The marker is a response header, x-metagraph-degraded: tier_unavailable, paired with meta.source: "data-worker-unavailable" in the body. Check one of those rather than inferring from count: 0 — an empty page is a legitimate answer for a narrow ?pallet/?block filter:

200 · degraded (schema-stable empty)
{
  "ok": true,
  "schema_version": 1,
  "data": { "count": 0, "next_before": null, "next_cursor": null, "events": [] },
  "meta": {
    "artifact_path": "/api/v1/chain-events",
    "cache": "short",
    "source": "data-worker-unavailable"
  }
}

A measured answer reports meta.source: "lakehouse-cold-tier" instead, and carries no degraded header.

All three routes only accept GET (and HEAD, normalized to a GET internally).

Every request to any of the three routes is capped by a shared rate limiter — 60 requests / 60s per client IP with no key, or 300 requests / 60s (5×) keyed by account when a valid Authorization: Bearer mg_... API key is presented instead of an IP. Exceeding it returns 429 data_rate_limited with retry-after, x-ratelimit-limit, x-ratelimit-remaining (always 0 on a 429), and x-ratelimit-reset (an upper-bound estimate, not an exact window boundary) headers.

GET /api/v1/chain-events

The recent all-events feed, newest first. Optionally scoped to one pallet/method, one block, or one extrinsic within a block; keyset-paginated for stable seeking at the head of the chain.

ParamWhereTypeDefaultNotes
limitqueryinteger501–200. Values outside range clamp rather than error.
palletquerystringOptional. Must match ^[A-Za-z][A-Za-z0-9_]{0,63}$ (1–64 ASCII letters, digits, or underscores, starting with a letter) or the request 400s.
methodquerystringOptional, same pattern as pallet. Requires pallet unless block is also set (avoids an unindexed global scan) — 400s otherwise.
blockqueryintegerOptional. Scopes the feed to one block number.
extrinsicqueryintegerOptional. Only honored when block is also set — otherwise it's silently ignored, not an error.
cursorquerystringOpaque observed_at.block_number.event_index keyset token from a prior response's next_cursor. Takes precedence over before when both are sent.
beforequeryintegerLegacy block_number-only cursor, kept for existing callers. Prefer cursor — it can skip same-block events at a page boundary.
formatquery"json" | "csv"jsoncsv (or an Accept: text/csv header) downloads the page's rows as text/csv — block_number, event_index, pallet, method, phase, extrinsic_index, observed_at. The nested args object has no flat CSV form and is omitted.

Response{ count, next_before, next_cursor, events: ChainEvent[] }. next_cursor is null once a page comes back shorter than limit (no more rows). Each ChainEvent is { block_number, event_index, pallet, method, args, phase, extrinsic_index, observed_at }; args is decoded server-side (account fields render as SS58, other 32/20-byte values as 0x-hex) rather than the raw SCALE dump; observed_at is epoch milliseconds.

curl -s 'https://api.metagraph.sh/api/v1/chain-events?pallet=SubtensorModule&method=NeuronRegistered&limit=5'

GET /api/v1/chain-events/stats

The pallet.method event-count distribution over a recent block window — an aggregate ("what's been happening lately"), not a row-level feed. This is what the MCP get_chain_activity tool mirrors.

ParamWhereTypeDefaultNotes
blocksqueryinteger10001–5000, the trailing-block window measured from the current chain tip.

Response{ window_blocks, groups, activity: [{ pallet, method, count }] }. activity is ordered by count descending (ties broken by pallet/method for a stable order under Hyperdrive's pooled connections), capped at the top 100 groups.

curl -s 'https://api.metagraph.sh/api/v1/chain-events/stats?blocks=500'

?format=csv has no effect here — this route has no top-level row array to export, so a CSV request falls through to the normal JSON envelope.

GET /api/v1/blocks/{block_number}/chain-events

Every raw event in exactly one block, in natural order. The block-level companion to /api/v1/chain-events?block=.

ParamWhereTypeDefaultNotes
block_numberpathintegerRequired, digits only (no 0x block-hash form here, unlike /blocks/{ref}). An unknown or not-yet-backfilled block still returns 200 with an empty events array — never a 404.

Response{ block_number, count, events: ChainEvent[] }, events ordered by event_index ascending.

curl -s https://api.metagraph.sh/api/v1/blocks/5000000/chain-events

Also reachable via MCP

Four MCP tools mirror these routes for AI agents: list_chain_events (GET /api/v1/chain-events), get_extrinsic_chain_events (the same route scoped by block + extrinsic), get_chain_activity (GET /api/v1/chain-events/stats), and get_block_chain_events (GET /api/v1/blocks/{n}/chain-events). The MCP tools call the DATA_API Worker directly and return its bare JSON body; the REST routes above wrap the identical data in the standard { ok, data, meta } envelope. Both hit the same 503 when the data tier isn't bound.