Index
Decoded rows you sweep into your own tables. You write the loop; the decoding, the backfill, and the reorg rewind are done.
History
Reads hit your own Postgres: no meter, no rate limit. from_height=0 reaches whatever history your instance holds; restoring more from the signed archive is metered (archive credits).
Every feed offers the same three verbs. They are not interchangeable.
| Verb | Gives you | Follows cursors | Checkpoints | Reorgs | Ends |
|---|---|---|---|---|---|
list() | One page plus the envelope | No; you pass cursor back | No | Reports reorgs[]; you act | Immediately |
walk() | An async iterable of rows | Yes, automatically | No | Ignored | On catching the tip |
consume() | A loop calling your onBatch | Yes, automatically | Yes; you commit the cursor | Rewinds and fires onReorg | Never; tails until aborted |
const { rows, next_cursor } = await sl.index.blocks.list({ limit: 10 }); // one page
for await (const t of sl.index.ftTransfers.walk({ contractId })) {} // every row, once
await sl.index.events.consume({ eventType: "ft_transfer", onBatch }); // forever, resumablewalk() into a database is a trap
It works, and it's right for a one-shot export or an analysis script. As a live index it fails three ways: no checkpoint, so a crash restarts from zero; reorgs are ignored, so a fork leaves orphaned rows behind forever; and it returns at the tip instead of following the chain. Writing rows you intend to keep is consume().
secondlayer codegen index emits the mirror schema so your tables can't drift:
secondlayer codegen index --target kysely # also: prisma, drizzle, json-schemaconsume() is a checkpointed consumer with automatic reorg rewind. Write rows in onBatch, return the cursor you committed; crash anywhere and the next run resumes from your checkpoint.
import { Index } from "@secondlayer/sdk";
const index = new Index();
await index.events.consume({
eventType: "ft_transfer",
fromCursor: await db.loadCheckpoint(), // null on first run
fromHeight: 0, // first run: backfill from genesis
txContext: true, // join the submitting tx into each event (tx_sender, tx_type, …)
signal: shutdown.signal, // abort checked between batches, never mid-commit
onBatch: async (events, envelope, ctx) => {
await db.transaction(async (tx) => {
for (const t of events) await tx.upsertTransfer(t);
await tx.saveCheckpoint(ctx.cursor); // commits with the rows
});
return ctx.cursor;
},
onReorg: async (reorg) => {
// the fork block and everything above it is no longer canonical (delete
// is inclusive of the fork height); the consumer then rewinds the cursor
// and re-reads the canonical run for you
await db.deleteFromHeight(reorg.fork_point_height);
},
});| Option | Effect |
|---|---|
eventType | A literal narrows the rows: "ft_transfer" hands onBatch typed amount/sender, "print" hands it payload.topic. No discriminant check to write |
contractId | One id, or an array to follow a whole protocol on one cursor: ["…sbtc-token", "…sbtc-registry"], up to 20. Mutually exclusive with trait |
txContext: true | Same on walk and list; every row carries its submitting tx, so no per-event /v1/index/transactions call |
finalizedOnly: true | Holds delivery to rows at or below tip.finalized_height; finalized data never reorgs, so onReorg is unnecessary |
signal | Checked at the top of the loop, so the in-flight batch always commits; see Deploy |
index.contractCalls.consume() | Same loop for decoded calls; already carries sender/function_name, so it needs no txContext |
Where to run it: Deploy. Running balances: invert them in a sink's onRollback (Sinks).
Progress on every batch
ctx carries where the sweep has reached, so liveness needs no bookkeeping of its own.
| Field | Is |
|---|---|
cursor | The checkpoint to commit alongside your rows |
height | Highest canonical block a row was DELIVERED from; event recency, parks on a quiet filter |
scannedHeight | Highest block VERIFIED; an empty page proves nothing matches up to the tip; rolled back on a reorg |
tipHeight | Chain tip as of this page's read |
blocksBehind | tipHeight - scannedHeight, floored at 0; real backlog, ~0 for a caught-up tail even when height is old |
Want the loop run for you instead: Subgraphs.
Read envelopes carry a reorgs array: the reorganizations overlapping the page's height range, empty when none.
{
"reorgs": [
{
"id": "...",
"detected_at": "2026-06-05T12:00:00Z",
"fork_point_height": 892100,
"old_index_block_hash": "0x…",
"new_index_block_hash": "0x…",
"orphaned_range": { "from": "892101:0", "to": "892140:7" },
"new_canonical_tip": "892140:3"
}
]
}Roll back rows whose cursor falls inside orphaned_range, then re-fetch from new_canonical_tip. consume() does both for you.
Both bounds are <block_height>:<event_index> cursors tracking decoded-event positions. On transaction-keyed feeds (/transactions, /contract-calls), compare by the block-height component.
Check your tables against the canonical chain any time:
const { canonical } = await sl.index.canonical({ fromHeight: myLowestHeight });
for (const block of canonical) {
if (myHashAt(block.block_height) !== block.block_hash) {
await db.deleteFromHeight(block.block_height); // diverged — re-sweep from here
break;
}
}Clarity ABIs don't describe print payloads, and the shape varies per topic within one contract.
curl "http://127.0.0.1:3800/v1/index/contracts/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry/print-schema"{
"contract_id": "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry",
"topics": [
{
"topic": "completed-deposit",
"count": 1342,
"fields": [
{
"name": "bitcoin-txid",
"camel_name": "bitcoinTxid",
"clarity_type": "(buff 32)",
"ts_type": "string",
"column_type": "text",
"always_present": true
}
]
}
],
"sampled": true,
"sample": { "size": 2000, "newest_height": 8054704, "oldest_height": 591820 }
}Sampled from indexed prints, raw Clarity hex deserialized for exact types, unified per topic. camel_name is what a handler sees on event.data.
- Optional fields report an
optional_some_rate. - Payloads with no
topicstring, or that aren't tuples, land under the"*"pseudo-topic withnon_tupleset. - Cached five minutes (
max-age=300+ weakETag).
Backs secondlayer subgraphs create <name> --from-contract <contract_id>; see Subgraphs.
Straight off your instance:
curl "http://127.0.0.1:3800/v1/index/events?event_type=stx_transfer&limit=20"| Endpoint | Returns | Filters beyond the common set |
|---|---|---|
/v1/index/events | Decoded events for a chosen event_type (+ ft-transfers / nft-transfers aliases) | event_type required; asset_identifier on nft_*; contract_id + decoded payload on print; no contract_id on stx_* |
/v1/index/contract-calls | Decoded contract-call transactions with args and result | function_name, sender, trait; cursors are a separate keyspace from events |
| /v1/index/transactions · /{tx_id} | Full transaction documents (fee, nonce, sponsored, post-conditions, decoded payload) | type, sender, contract_id; /{tx_id}/proof returns an inclusion proof (Verification) |
| /v1/index/blocks · /{height_or_hash} | Decoded blocks, list or single | — |
| /v1/index/canonical | Canonical block-hash map, one row per height, orphans excluded | from_height |
| /v1/index/stacking | Decoded PoX-4 stacking actions (stack-stx, delegate-stx, …) (historical: era ended at Epoch 4.0; live: /v1/index/pox5/events) | function_name, stacker, caller |
| /v1/index/pox5/events | Decoded PoX-5 print events, all 19 topics (PoX-5) | topic, staker, signer, signer_manager, bond_index, reward_cycle |
| /v1/index/mempool · /{tx_id} | Pending transactions: no block_height/result, plus received_at; 404s once mined, never cached | sender, type, contract_id, function_name |
On the events and contract-call feeds, contract_id also takes a comma-separated set (?contract_id=SP1.a,SP1.b, max 20): one cursor over a protocol's contracts instead of one consumer each. For "every contract of a standard" use trait, which picks up contracts deployed after you ship.
tx_context=true collapses an N+1 walk into one pass: every event carries tx_sender, tx_type, tx_status, tx_contract_id, tx_function_name. For print events, tx_sender is the only place the sender lives.
Every list response carries the collection, an opaque next_cursor, the chain tip, and reorgs. Full grammar and response bodies: API reference. GET /v1/index describes itself at runtime, so an agent can learn each event_type without docs.
Built on Streams: the decoder on your instance reads the same raw firehose you can.
A consume() loop pulls. To be pushed instead, into a queue or a serverless function, a subscription POSTs matching chain events to your URL as they land:
secondlayer subscriptions create amm-swaps \
--url https://my-app.com/webhook \
--trigger '{"type":"contract_call","contractId":"SP....amm","functionName":"swap-*"}'Deliveries are signed, retried, and land in a dead-letter queue you can inspect and requeue. Full trigger grammar and the delivery envelope: Subscriptions.
secondlayer index transactions get 0x<tx_id>
secondlayer index mempool --contract-id SP….tokenMCP exposes the same families as tools: index_events, index_transactions, index_stacking, index_mempool, index_blocks, and the rest.