

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 [#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.

| Route                                                                 | Store                              | Real-time?                                                       | What it returns                                                                                                                                                                                                                                     |
| --------------------------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/events`                                                  | R2 artifact + KV pointer           | SSE, poll-on-reconnect                                           | Not 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}/events`                                 | Lakehouse — `chain.account_events` | Near-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_events`   | Near-real-time (cache: short), degraded empty if it can't answer | The 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 [#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 [#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](https://github.com/JSONbored/metagraphed/blob/main/docs/adr/0013-hybrid-deployment-topology.md) 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](https://github.com/JSONbored/metagraphed/blob/main/docs/adr/0014-chain-data-infrastructure-and-postgres-cutover.md) (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 [#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 [#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:

```json title="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](/settings) 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 [#get-apiv1chain-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.

| Param       | Where | Type              | Default | Notes                                                                                                                                                                                                                             |
| ----------- | ----- | ----------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `limit`     | query | integer           | `50`    | 1–200. Values outside range clamp rather than error.                                                                                                                                                                              |
| `pallet`    | query | string            | —       | Optional. 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.                                                                                     |
| `method`    | query | string            | —       | Optional, same pattern as `pallet`. Requires `pallet` unless `block` is also set (avoids an unindexed global scan) — 400s otherwise.                                                                                              |
| `block`     | query | integer           | —       | Optional. Scopes the feed to one block number.                                                                                                                                                                                    |
| `extrinsic` | query | integer           | —       | Optional. Only honored when `block` is also set — otherwise it's silently ignored, not an error.                                                                                                                                  |
| `cursor`    | query | string            | —       | Opaque `observed_at.block_number.event_index` keyset token from a prior response's `next_cursor`. Takes precedence over `before` when both are sent.                                                                              |
| `before`    | query | integer           | —       | Legacy `block_number`-only cursor, kept for existing callers. Prefer `cursor` — it can skip same-block events at a page boundary.                                                                                                 |
| `format`    | query | `"json" \| "csv"` | `json`  | `csv` (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.

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

## GET /api/v1/chain-events/stats [#get-apiv1chain-eventsstats]

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.

| Param    | Where | Type    | Default | Notes                                                                  |
| -------- | ----- | ------- | ------- | ---------------------------------------------------------------------- |
| `blocks` | query | integer | `1000`  | 1–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.

```bash
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 [#get-apiv1blocksblock_numberchain-events]

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

| Param          | Where | Type    | Default | Notes                                                                                                                                                                              |
| -------------- | ----- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `block_number` | path  | integer | —       | Required, 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.

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

## Also reachable via MCP [#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.

<ApiSources paths="[&#x22;/api/v1/chain-events&#x22;, &#x22;/api/v1/chain-events/stats&#x22;]" />
