.md

Index

Decoded rows you sweep into your own tables. You write the loop; the decoding, the backfill, and the reorg rewind are done.

1M rows free a month hosted, no meter on your own box.

You getDecoded rows from genesis, typed per event
You writeA consume() loop into your schema

, , and when a reorg lands. A crash resumes from your checkpoint.

index.tsts
1import { Index } from "@secondlayer/sdk";
2
3const index = new Index();
4await index.events.consume({
5 eventType: "ft_transfer",
6 fromCursor: await db.loadCheckpoint(), // null on first run
7 fromHeight: 0,
8 onBatch: async (events, envelope, ctx) => {
9 await db.transaction(async (tx) => {
10 for (const row of events) await tx.upsert(row);
11 await tx.saveCheckpoint(ctx.cursor);
12 });
13 return ctx.cursor;
14 },
15 onReorg: (reorg) => db.deleteFromHeight(reorg.fork_point_height),
16});

7First run backfills from genesis.

11Commits with the rows.

15Delete from the fork height, inclusive.

ft_transfer

Where to run it: Deploy.

Read envelopes carry a reorgs array: the reorganizations overlapping the page's height range, empty when none.

response.jsonjson
{
"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"
}
]
}

To recover from one by hand:

  • Roll back rows whose cursor falls inside orphaned_range.
  • Re-fetch from new_canonical_tip.
  • consume() does both for you.

In onReorg, the fork block and everything above it is no longer canonical, so delete rows at fork_point_height and above (inclusive); the consumer then rewinds the cursor and re-reads the canonical run 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.

Running balances: invert them in a sink's onRollback (Sinks).

Every row carries its block_height. Prove that block, its transactions and its Bitcoin anchor on your machine. This is the block of the sBTC transfer on Streams:

~/secondlayerexit 0
$secondlayer verify block 9210757
✓ block 9,210,757 c61897739dde0b73ac9c99d146c288ad0d5b728f07df7d728680bc57683c5604
✓ bitcoin headers 967,680..971,479, proof-of-work and retargets checked
✓ burn height 971,479 000000000000000000006445b9307441f663ae84a9fa66f5a50e4a8dc95b2ff5
✓ cycle 145, signer set proven forward from checkpoint cycle 144
✓ signatures 2,947 / 4,000 weight signed, threshold 2,800
✓ txs 4 transactions match the tx merkle root
✓ state root a1a4bfd3784a6650af0e2afa707d256c3272c9ddad66314a212e6951c783d538 matches the witness
✓ Block 9,210,757 is proven. Trusted: only the checkpoint (Stacks 9,055,251, Bitcoin 967,680).
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: trueJoins the submitting transaction into each event (tx_sender, tx_type, …). Same on walk and list; every row carries its submitting transaction, 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
signalAbort checked between batches, never mid-commit: 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

secondlayer codegen index emits the mirror schema so your tables can't drift:

$secondlayer codegen index --target kysely # also: prisma, drizzle, json-schema

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

Verify your mirror: 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;
}
}

To prove a block or transaction rather than compare hashes: Verify.

  • Self-hosted: reads hit your Postgres, with no meter and no rate limit. Auth is in Self-host.
  • Hosted api.secondlayer.tools: keyed, and bills rows.delivered. 1M free per account per month; rates and free block headers in Keys and billing.
  • History: from_height=0 reaches whatever your instance holds. Restoring more from the signed archive is metered (credits).

One page with list(), a one-off export with walk(). Neither checkpoints, so neither is a live index.

list, walk, consume

Every feed offers the same three verbs. They are not interchangeable.

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

walk() into a database is a trap. It's right for a one-shot export or an analysis script, and wrong as a live index:

  • No checkpoint, so a crash restarts from zero.
  • Reorgs are ignored, so a fork leaves orphaned rows behind forever.
  • It returns at the tip instead of following the chain.

Writing rows you intend to keep is consume().

Plain HTTP

Query rows over HTTP from any client, no SDK needed.

$curl "$SECONDLAYER_API_URL/v1/index/events?event_type=stx_transfer&limit=20" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY"

Every endpoint, filter and response shape is in the API reference. GET /v1/index describes itself at runtime, so an agent can learn each event_type without docs.

Two params on the events and contract-call feeds:

ParamDoes
contract_idTakes a comma-separated set (max 20): one cursor over a protocol's contracts
tx_context=truePuts tx_sender, tx_type, tx_status, tx_contract_id and tx_function_name on every event in one pass. For print events it is the only place the sender lives

Reads go oldest first. Add order=desc on events and ft/nft transfers to see what just happened: page one is the newest rows, and next_cursor pages older.

Every list response carries the collection, an opaque , the chain tip, and a reorgs array.

tip.block_height is the height committed for the event type you just read, not a global number. Each decoded type runs its own decoder, so two reads can report different tips at once.

  • /v1/index/blocks and /v1/index/transactions stay on the ingest tip (source_block_height).
  • Re-read the tip per response.

Built on Streams: the decoder on your instance reads the same raw firehose you can.

Long-poll instead of tight-loop polling. An empty page holds the connection open until new data commits or wait elapses; a page with rows answers at once.

GET /v1/index/events?event_type=ft_transfer&cursor=<last>&wait=25
  • /v1/index/events and /v1/index/blocks take wait in seconds, max 25.
  • An instance from before wait shipped answers 400; fall back to a plain poll loop.

From the CLI:

$secondlayer index transactions get 0x<tx_id>
$secondlayer index mempool --contract-id SP….token

Tool names for agents are on MCP and skills; see the CLI for the rest. Mempool, state writes, PoX-5, sBTC and Runes have no MCP tool; use REST or the SDK. To be pushed instead of pulling: Webhooks.

Clarity ABIs don't describe print payloads, and the shape varies per topic within one contract.

$curl "$SECONDLAYER_API_URL/v1/index/contracts/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry/print-schema" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY"

The response shape is in the API reference. 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.

Find contracts by standard

Find every contract that conforms to a token standard, without keeping a list of addresses. A contract counts as declared when its source has (impl-trait …), and inferred when its ABI matches the standard's shape.

$curl -G "$SECONDLAYER_API_URL/v1/contracts" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY" \
-d "trait=sip-010" -d "conformance=any"
200response.jsonjson
{
"contracts": [
{
"contract_id": "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.usda-token",
"deployer": "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9",
"block_height": 21000,
"declared_traits": ["sip-010"],
"inferred_standards": ["sip-010"],
"abi_status": "fetched",
"classified_at": "2026-10-15T17:15:45.764Z"
}
],
"next_cursor": "SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.usda-token"
}
ParamDescription
traitsip-009, sip-010, or sip-013. Required.
conformancedeclared, inferred, or any (default).
limit1 to 500, default 100.
cursorThe previous ; null on the last page.
includeabi adds each contract's ABI.
classified_afterISO timestamp. Only contracts classified at or after it, to sync a list without re-reading it.

GET /v1/contracts/{contract_id} returns one contract. trait also filters the event and contract-call feeds, and a subgraph source can target a trait and pick up contracts deployed later: Index by standard.

State writes

The exact contract-state writes each canonical block committed, in node order. Use it to see which key a transaction changed, which events alone don't show.

$curl "$SECONDLAYER_API_URL/v1/index/state-writes?block_height=8054704" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY"

Each row is one write to contract storage.

FieldMeans
cursor<block_height>:<ordinal>
block_height, ordinalThe block, and the write's position in node order
tx_indexnull for block-level writes
keyThe chain's storage key
value_hexThe new value

History starts at the block where your node first sent the state_writes observer key. There is no earlier history.

  • block_height reads one block; it excludes from_height and to_height.
  • On your own box this needs a node build that emits state writes. The stock node doesn't yet.
  • Rows bill like event rows.

Check whether an output is spent

Watch a Bitcoin output, such as a peg or bond UTXO, and learn when it moves and which transaction moved it.

$curl "$SECONDLAYER_API_URL/v1/bitcoin/outspend/$TXID/0" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY"
AnswerMeans
spent: falseStill unspent, including in the mempool
mempool: trueSpent by spending_txid, not yet in a block
confirmed: trueSpent in block_height
spending_txid: nullSpent more than 144 blocks below the tip; note says so
  • It reads your Bitcoin node live: Bitcoin Core 24 or later with txindex=1.
  • One answer is one row. Poll once per block, or more often to catch a mempool spend.
  • Fields and errors: Output spend.

Index on contracts everyone shares, with the same verbs and envelope as every other feed. Not products of their own: your contract stays in consume() or a subgraph.

PoX-5

Decoded prints from the pox-5 boot contract (SIP-045, live since Bitcoin block 960,230), plus per-cycle totals. The print log, not a position scoreboard: positions and payouts are a subgraph you deploy.

ReadRouteSDK
All 19 print topics/v1/index/pox5/eventspox5.events.list / walk / consume
Reward cycles of the current PoX/v1/index/pox/cycles · /{reward_cycle}pox.cycles.list / walk / get
PoX-4 stacking actions (historical)/v1/index/stackingstacking.list / walk
for await (const event of sl.index.pox5.events.walk({ topic: "stake", signer: "SP…" })) {
console.log(event.block_height, event.staker, event.amount_ustx);
}
const { events } = await sl.index.pox5.events.list({
topic: "register-for-bond",
fields: ["bond_index", "amount_sats"], // narrow the row to the columns you ask for
});

Event filters:

FilterNarrows to
topicOne print topic
stakerOne staker
signerOne signer; alone it returns a pool's full activity, claims and key grants included
signerManagerOne signer manager
bondIndexOne bond
rewardCycleOne reward cycle
fromHeight, toHeightA block-height range
confirmedtrue holds a read to rows past the reorg margin

register-for-bond rows add four fields:

  • bond_index
  • amount_sats
  • is_l1_lock
  • data.btc-lockup

pox5.events.consume() is the checkpointed sweep, with the same options as Build your index on it. Cycles are exact to the node and follow the chain again at the next PoX fork. PoX-4 cycle history is final and not served; rebuild it from /v1/index/stacking on a self-hosted instance.

sBTC

The peg lifecycle, decoded, plus Bitcoin confirmation of sweeps.

  • Withdrawals: a withdrawal-accept only means the signers committed to a sweep. Your instance checks sweep_txid against bitcoind (the bundled full-node profile or an external BITCOIN_RPC).
  • Deposits need no check: completed-deposit fires after the signers see the confirmations.
ReadRouteSDK
Raw peg log, six topics/v1/index/sbtc/eventssbtc.events.list / walk / consume
Completed deposits/v1/index/sbtc/deposits · /{bitcoin_txid}sbtc.deposits.list / walk / consume
Withdrawal lifecycles/v1/index/sbtc/withdrawals · /{request_id}sbtc.withdrawals.list / walk / get
Totals/v1/index/sbtc/summarynone

A withdrawal carries a settlement object:

{
"request_id": 42,
"status": "ACCEPTED",
"settlement": { "sweep_txid": "abcd…", "btc_confirmations": 7, "settlement_confirmed": true, "btc_block_height": 880321, "confirmed_at": "2026-06-01T00:00:00.000Z" }
}

settlement_confirmed has three values:

ValueMeans
truePast the confirmation threshold
falseShallow
nullNo sweep observed yet (not a denial)

The list takes it as a filter:

  • ?settlement_confirmed=true is confirmed peg-outs.
  • ?settlement_confirmed=false is the whole not-done bucket.

Withdrawals have no consume

A withdrawal row mutates as it moves REQUESTED, ACCEPTED, settled, and a checkpoint only moves forward. Mirror the append-only events and derive status from the withdrawal-* topics; read withdrawals.get(id) for the assembled lifecycle.

Settlement is verified against your instance's bitcoind, not a custody guarantee: a signer set still controls the pegged UTXO. A Bitcoin reorg that un-confirms a sweep self-corrects on read. To be pushed: Webhooks.

Runes

A catalog of etched runes, a per-rune activity log, and balances, on a Bitcoin tip instead of a Stacks one.

  • A rune's id is <block>:<transaction index> of its etch (840000:3).
  • Its name is a unique A-Z string. Spacers (•) are display only, so DOG•GO•TO•THE•MOON, dog.go.to.the.moon and doggotothemoon are one rune.
  • Every rune field takes a RuneRef: an id or a name in any of those forms.

Hosted serves it now. To index Runes on your own box, see Self-host.

ReadRouteSDK
Catalog/v1/index/runesrunes.list({ search, sort })
One rune/v1/index/runes/{rune}runes.get(ref), null on 404
Current per-outpoint balances/v1/index/runes/balancesrunes.balances({ address })
Etch, mint, transfer, burn log/v1/index/runes/activityrunes.activity.list / walk

From the terminal:

$secondlayer index runes [--search <prefix>] [--sort number|mints]
$secondlayer index runes get <rune>
$secondlayer index runes balances --address <address> # or --outpoint <txid:vout>
$secondlayer index runes activity [--rune] [--address] [--kind etch,mint,transfer,burn] [--txid]
await sl.index.runes.list({ search: "dog", sort: "mints" });
await sl.index.runes.balances({ address: "bc1phnp234rd5hel0h8ulqvnq3zrww2td0a8q48hjcg7mv67j97jnxfq9ayczm" });
for await (const activity of sl.index.runes.activity.walk({ rune: "840000:3" })) {
console.log(activity.kind, activity.rune.spaced_name, activity.amount);
}
ReadFilters
runes.listsearch (name prefix), sort (etch order, or mints for busiest)
runes.balancesaddress or outpoint (exactly one, enforced by the types), plus rune
runes.activityrune, address, kind, txid; pages by <block_height>:<event_index>
  • Amounts (premine, supply, burned, mints) are u128 decimal strings. Keep them as bigint, never Number().
  • supply is premine + mints × terms.amount.
  • Txids come back without 0x.

A Bitcoin block needs 6 confirmations to count as final: tip.finalized_height is tip.block_height - 6.

tip.lag_seconds is the age of the newest indexed block, so it climbs between Bitcoin blocks and 600 seconds or more is normal. To check freshness, compare tip.block_height with your Bitcoin node.

  • The catalog and balances read reorg-corrected snapshots, so their reorgs is always empty.
  • Only activity is an append-only log, so only its reorgs can name replaced heights, at block level.

The raw per-transaction events are on Streams with chain=bitcoin. To keep your own copy, see Index Runes into your own database.

Edit on GitHub