Core surfaces / Streams

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.

verifyBehavior
default (lenient)Verify signed frames; pass unsigned ones through; throw on invalid
true (strict)Require a signature on every frame
falseSkip verification

This is the streaming counterpart to the signed X-Signature on REST reads: trust the data without trusting the server.

  • Finality: tip.finalized_height plus a finalized flag on every event, anchored to Bitcoin (burn-block) confirmations. A finalized event won't reorg.
  • Caching: fully-finalized pages are served Cache-Control: public, immutable with an ETag (and 304 on If-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). Pass verify: true for strict, verify: false to skip, or verify: { publicKey } to pin a key.
  • Bulk + replay: download finalized history as signed bulk dumps (sl streams pull, or the SDK dumps namespace) and events.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.