Build / Index

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.

VerbGives youFollows cursorsCheckpointsReorgsEnds
list()One page plus the envelopeNo; you pass cursor backNoReports reorgs[]; you actImmediately
walk()An async iterable of rowsYes, automaticallyNoIgnoredOn catching the tip
consume()A loop calling your onBatchYes, automaticallyYes; you commit the cursorRewinds and fires onReorgNever; 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, resumable

walk() 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-schema

consume() 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);
  },
});
OptionEffect
eventTypeA literal narrows the rows: "ft_transfer" hands onBatch typed amount/sender, "print" hands it payload.topic. No discriminant check to write
contractIdOne id, or an array to follow a whole protocol on one cursor: ["…sbtc-token", "…sbtc-registry"], up to 20. Mutually exclusive with trait
txContext: trueSame on walk and list; every row carries its submitting tx, so no per-event /v1/index/transactions call
finalizedOnly: trueHolds delivery to rows at or below tip.finalized_height; finalized data never reorgs, so onReorg is unnecessary
signalChecked 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.

FieldIs
cursorThe checkpoint to commit alongside your rows
heightHighest canonical block a row was DELIVERED from; event recency, parks on a quiet filter
scannedHeightHighest block VERIFIED; an empty page proves nothing matches up to the tip; rolled back on a reorg
tipHeightChain tip as of this page's read
blocksBehindtipHeight - 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 topic string, or that aren't tuples, land under the "*" pseudo-topic with non_tuple set.
  • Cached five minutes (max-age=300 + weak ETag).

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"
EndpointReturnsFilters beyond the common set
/v1/index/eventsDecoded 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-callsDecoded contract-call transactions with args and resultfunction_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….token

MCP exposes the same families as tools: index_events, index_transactions, index_stacking, index_mempool, index_blocks, and the rest.