Streams
Raw chain event firehose: cursor-paginated REST, idempotent, replayable. The lowest level of indexing on Secondlayer, for building an indexer from zero.
Index is built on this exact surface (our decoder is itself a Streams consumer). Page forward with a cursor, replay from any point, and with a committed checkpoint, within your tier's retention, never miss an event. Stream live over SSE in-process (below), or fan out to your backend with Subscriptions.
Auth & retention
Streams reads require an API key; send it as Authorization: Bearer. Free keys read the recent 24-hour window (free retention is 1 day); reaching older history is pay-as-you-go credits or a paid plan. A read below the free window returns 402 UPGRADE_REQUIRED.
curl -H "Authorization: Bearer $SL_API_KEY" \
"https://api.secondlayer.tools/v1/streams/events?limit=100"Each response returns a next_cursor. Pass it back as cursor to continue where you left off; deliveries are idempotent, so retries never double-count.
curl -H "Authorization: Bearer $SL_API_KEY" \
"https://api.secondlayer.tools/v1/streams/events?cursor=7970329:8"Narrow the firehose by event type, contract, or principal: types, contract_id, sender, recipient, asset_identifier, and from_height/to_height. Filters are exact-match; event types without a given field don't match.
curl -H "Authorization: Bearer $SL_API_KEY" \
"https://api.secondlayer.tools/v1/streams/events?types=ft_transfer&sender=SP...&limit=50"The SDK ships the loop: events.consume is a checkpointed consumer with automatic reorg rewind. Write rows inside onBatch, return the cursor you committed; crash anywhere and the next run resumes from your checkpoint. Reorgs rewind the cursor to the fork point and re-deliver (at-least-once, so writes should be idempotent).
await streams.events.consume({
types: ["ft_transfer"],
fromCursor: await db.loadCheckpoint(),
onBatch: async (events, envelope) => {
await db.insertEvents(events);
return envelope.next_cursor; // the checkpoint you committed
},
onReorg: async (reorg) => {
await db.rollbackFrom(reorg.fork_point_height); // inclusive of the fork block
},
});For cold history, events.replay backfills from the signed bulk dumps, then tails live from the manifest's latest_finalized_cursor, no gap or dupe at the seam:
await streams.events.replay({
from: "genesis",
async onDumpFile(file) {
const bytes = await streams.dumps.download(file); // sha256-verified
await ingestParquet(bytes);
},
async onBatch(events, envelope) {
await db.insertEvents(events);
return envelope.next_cursor;
},
});from: "genesis" replays every window the dumps cover; the manifest's coverage reports that range, which starts where the dump program began (not necessarily chain block 1). For a partial backfill, pass a cursor instead. Need decoded history all the way back? Use Index, which serves the full decoded chain from from_height=0.
Dumps + DuckDB
The dumps are plain parquet. Pull a verified range and query it locally, no indexer required:
sl streams pull --to ./dump # sha256-verified against the signed manifest
duckdb -c "SELECT event_type, count(*) AS events
FROM read_parquet('./dump/**/*.parquet')
GROUP BY 1 ORDER BY 2 DESC;"┌──────────────┬────────┐
│ event_type │ events │
├──────────────┼────────┤
│ print │ 610417 │
│ nft_mint │ 107479 │
│ stx_transfer │ 104690 │
│ ft_transfer │ 40294 │
│ … │ … │
└──────────────┴────────┘The complete runnable indexer (dumps backfill, live seam, checkpointed tail) is the indexer-from-zero example.
GET /v1/streams/events/stream streams events as Server-Sent Events over a single long-lived connection, the native push surface inside Streams. Authenticate with Authorization: Bearer and apply the same filters as /events: types, not_types, contract_id, sender, recipient, and asset_identifier.
curl -N -H "Authorization: Bearer $SL_API_KEY" \
"https://api.secondlayer.tools/v1/streams/events/stream?types=ft_transfer"Choose a start position with cursor (or from_cursor), or from_height. With no start position the connection live-tails from the reorg-clamped tip. A ping keepalive frame is sent every 20s to keep idle connections open.
Near-real-time, not instant
Delivery is near-real-time. The server polls for new events roughly every 1.5s and serves from a tip held a couple of blocks back for reorg safety, so an event lands seconds (occasionally tens of seconds) after it first appears on chain. SSE is the low-latency push surface versus polling /events yourself.
The SDK's client.events.subscribe(...) is the ergonomic way to consume this: it carries your Bearer key, auto-reconnects from the last delivered cursor, and verifies per-frame signatures. See the SDK docs.
Per-frame signatures
Each SSE frame body is { event, sig, key_id }, where sig is an ed25519 signature over JSON.stringify(event). The SDK verifies every frame by default against the key at /public/streams/signing-key before your handler runs.
The default is lenient: the hosted API signs every frame, while an unsigned frame from a self-hosted instance (no STREAMS_SIGNING_PRIVATE_KEY) is delivered as-is. An invalid signature always throws.
verify | Behavior |
|---|---|
| default (lenient) | Verify signed frames; pass unsigned ones through; throw on invalid |
true (strict) | Require a signature on every frame |
false | Skip verification |
This is the streaming counterpart to the signed X-Signature on REST reads: trust the data without trusting the server.
- Finality:
tip.finalized_heightplus afinalizedflag on every event, anchored to Bitcoin (burn-block) confirmations. Afinalizedevent won't reorg. - Caching: fully-finalized pages are served
Cache-Control: public, immutablewith anETag(and304onIf-None-Match); the moving tip stays short-lived. - Signed responses: every hosted read carries an ed25519
X-Signature, verified by default (lenient: hosted reads checked against/public/streams/signing-key; unsigned self-host responses pass through; invalid signatures throw). Passverify: truefor strict,verify: falseto skip, orverify: { publicKey }to pin a key. - Bulk + replay: download finalized history as signed bulk dumps (
sl streams pull, or the SDKdumpsnamespace) andevents.replay({ from: "genesis" })to backfill cold history then tail live with no gap at the seam.
Manifest signature is the root of trust
The bulk manifest is itself ed25519-signed and verified before any per-file sha256 is trusted; the file hashes chain beneath the manifest signature. The client option verifyDumpsManifest defaults to true (both dumps.list() and events.replay() enforce it); opt out with verifyDumpsManifest: false.
See the changelog for the full list.