<!-- source: https://secondlayer.tools/docs/sdk-reference/streams -->

# `sl.streams`

Every Stacks event in order, as a loop you checkpoint or a page you read. The same data the [Streams API](/docs/api/streams) serves over HTTP.

## Methods

### sl.streams.consume(params)

Read Streams page by page as an async iterator.

Yields one [`StreamsBatch`](/docs/sdk-reference/streams#t-StreamsBatch) per events page, so each yield is exactly
one API call. Empty pages are skipped; at the tip the iterator polls again
every `intervalMs` (default 2000) until `signal` aborts.

Reorgs appear on `batch.reorgs`, but the cursor is not rewound for you.
Use `events.consume` with `onReorg` when you need rollback.

A literal `types` array narrows every batch's event union to those types.
`chain: "bitcoin"` batches carry Runes rows only, on the bitcoin tip shape.

**Stacks**

```ts
sl.streams.consume<const T extends readonly StreamsEventType[]>(
  params: StreamsConsumeCommon
  & {
    chain?: "stacks";
    types?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print")[];
    notTypes?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print")[];
    contractId?: StreamsFilterValue;
    sender?: StreamsFilterValue;
    recipient?: StreamsFilterValue;
    assetIdentifier?: string;
    filters?: StreamsFilterMap;
  }
  & { types: T },
): AsyncIterableIterator<StreamsBatch<StreamsEventOfTypes<T>, StreamsTip>, any, any>
```

**Any chain**

```ts
sl.streams.consume(
  params?: StreamsConsumeBitcoinParams | StreamsConsumeStacksParams,
): AsyncIterableIterator<StreamsBatch<StreamsEvent, StreamsTip>, any, any>
```

**Bitcoin**

```ts
sl.streams.consume(
  params: StreamsConsumeBitcoinParams,
): AsyncIterableIterator<StreamsBatch<RuneStreamsEvent, StreamsBitcoinTip>, any, any>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | `string \| null` | no (Stacks · Bitcoin only) | Resume strictly after this cursor; omit to start from the oldest seekable page. |
| `batchSize` | `number` | no (Stacks · Bitcoin only) | Events per page (the `limit` query param). Default 100. |
| `intervalMs` | `number` | no (Stacks · Bitcoin only) | Poll interval while caught up at the tip, in ms. Default 2000. |
| `signal` | `AbortSignal` | no (Stacks · Bitcoin only) |  |
| `chain` | `"stacks"` | no (Stacks · Bitcoin only) |  |
| `types` | `T` | yes (Stacks · Bitcoin only) |  |
| `notTypes` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]` | no (Stacks · Bitcoin only) |  |
| `contractId` | `StreamsFilterValue` | no (Stacks only) |  |
| `sender` | `StreamsFilterValue` | no (Stacks only) |  |
| `recipient` | `StreamsFilterValue` | no (Stacks only) |  |
| `assetIdentifier` | `string` | no (Stacks only) |  |
| `filters` | `StreamsFilterMap` | no (Stacks only) |  |
| `params` | `StreamsConsumeBitcoinParams \| StreamsConsumeStacksParams` | no (Any chain only) |  |
| `rune` | `string` | no (Bitcoin only) | A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`). |
| `address` | `string` | no (Bitcoin only) | A mainnet address. |

### sl.streams.events.list(params)

Read one page of chain events and the next cursor.

Narrow with `types`, `notTypes`, `contractId`, `sender`, `recipient`,
`assetIdentifier`, `fromHeight`/`toHeight`, or labelled `filters`.
Filters match exactly, and an event type without a given field never
matches it. Pass `next_cursor` back as `cursor` to continue. The
envelope also carries the `tip` and any `reorgs`. To write the events
somewhere with a checkpoint, use `events.consume` instead.

A literal `types` array narrows every row to those event types.
`clock: "vm"` pages carry vm rows only (a parallel vocabulary,
`event_index` = `ordinal`), never `StreamsEvent`; it is Stacks only.
`chain: "bitcoin"` pages carry Runes rows only, on the bitcoin tip shape,
and filter by `rune` or `address`.

**Stacks**

```ts
sl.streams.events.list(
  params: StreamsEventsListParamsCommon
  & {
    chain?: "stacks";
    clock?: "classic" | "vm";
    types?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print" | "nested_contract_call" | "var_set" | "map_set" | "map_insert" | "map_delete")[];
    notTypes?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print" | "nested_contract_call" | "var_set" | "map_set" | "map_insert" | "map_delete")[];
    contractId?: StreamsFilterValue;
    sender?: StreamsFilterValue;
    recipient?: StreamsFilterValue;
    assetIdentifier?: string;
    filters?: StreamsFilterMap;
  }
  & { clock: "vm" },
): Promise<StreamsEventsEnvelope<VmStreamsEvent, StreamsTip>>
```

**Stacks**

```ts
sl.streams.events.list(
  params: {
    cursor?: string | null;
    fromHeight?: number;
    toHeight?: number;
    limit?: number;
    chain?: "stacks";
    contractId?: StreamsFilterValue;
    sender?: StreamsFilterValue;
    recipient?: StreamsFilterValue;
    assetIdentifier?: string;
    filters?: StreamsFilterMap;
  }
  & {
    types: T;
    notTypes?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print")[];
    clock?: "classic";
  },
): Promise<StreamsEventsEnvelope<StreamsEventOfTypes<T>, StreamsTip>>
```

**Stacks**

```ts
sl.streams.events.list(
  params?: {
    cursor?: string | null;
    fromHeight?: number;
    toHeight?: number;
    limit?: number;
    chain?: "stacks";
    contractId?: StreamsFilterValue;
    sender?: StreamsFilterValue;
    recipient?: StreamsFilterValue;
    assetIdentifier?: string;
    filters?: StreamsFilterMap;
  }
  & {
    types?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print")[];
    notTypes?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print")[];
    clock?: "classic";
  },
): Promise<StreamsEventsEnvelope<StreamsEvent, StreamsTip>>
```

**Any chain**

```ts
sl.streams.events.list(
  params: StreamsEventsListParams,
): Promise<StreamsEventsEnvelope<StreamsWireEvent, StreamsTip>>
```

**Bitcoin**

```ts
sl.streams.events.list(
  params: StreamsEventsBitcoinListParams,
): Promise<StreamsEventsEnvelope<RuneStreamsEvent, StreamsBitcoinTip>>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | `string \| null` | no (Stacks · Bitcoin only) |  |
| `fromHeight` | `number` | no (Stacks · Bitcoin only) |  |
| `toHeight` | `number` | no (Stacks · Bitcoin only) |  |
| `limit` | `number` | no (Stacks · Bitcoin only) |  |
| `chain` | `"stacks"` | no (Stacks · Bitcoin only) |  |
| `clock` | `"vm"` | yes (Stacks only) |  |
| `types` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print" \| "nested_contract_call" \| "var_set" \| "map_set" \| "map_insert" \| "map_delete")[]` | no (Stacks · Bitcoin only) |  |
| `notTypes` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print" \| "nested_contract_call" \| "var_set" \| "map_set" \| "map_insert" \| "map_delete")[]` | no (Stacks · Bitcoin only) |  |
| `contractId` | `StreamsFilterValue` | no (Stacks only) |  |
| `sender` | `StreamsFilterValue` | no (Stacks only) |  |
| `recipient` | `StreamsFilterValue` | no (Stacks only) |  |
| `assetIdentifier` | `string` | no (Stacks only) |  |
| `filters` | `StreamsFilterMap` | no (Stacks only) |  |
| `params` | `StreamsEventsListParams` | yes (Any chain only) |  |
| `rune` | `string` | no (Bitcoin only) | A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`). |
| `address` | `string` | no (Bitcoin only) | A mainnet address. |

### sl.streams.events.byTxId(txId)

Read every event a transaction emitted.

Returns the events and the `tip`, with no `next_cursor`: one transaction
is one response.

`chain: "bitcoin"` returns the Runes events for one Bitcoin transaction.

**Overload 1**

```ts
sl.streams.events.byTxId(
  txId: string,
): Promise<StreamsEventsListEnvelope<StreamsEvent, StreamsTip>>
```

**Bitcoin**

```ts
sl.streams.events.byTxId(
  txId: string,
  opts: { chain: "bitcoin" },
): Promise<StreamsEventsListEnvelope<RuneStreamsEvent, StreamsBitcoinTip>>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `txId` | `string` | yes |  |
| `opts.chain` | `"bitcoin"` | yes (Bitcoin only) |  |

### sl.streams.events.consume(params)

Run a checkpointed indexer that rewinds on reorgs.

Write your rows in `onBatch` and commit `ctx.cursor` in the same
transaction; resume with `fromCursor`. Reorgs at or below your checkpoint
call `onReorg`, then the loop re-reads from the fork point. Delivery is
at-least-once, so keep writes idempotent. With a `sink`, the checkpoint
and rollback are handled for you. `mode: "tail"` (default) keeps polling
at the tip; `mode: "bounded"` returns on the first empty page. It also
stops on `maxPages`, `maxEmptyPolls`, or `signal`. Pass `finalizedOnly`
to skip reorg handling entirely.

`chain: "bitcoin"` sweeps Runes events; see [`StreamsEventsConsumeBitcoinParams`](/docs/sdk-reference/streams#t-StreamsEventsConsumeBitcoinParams).

**Stacks**

```ts
sl.streams.events.consume(
  params: StreamsEventsConsumeParams<TTx, D, F> & { sink?: ConsumerSink<TTx> },
): Promise<StreamsEventsConsumeResult>
```

**Bitcoin**

```ts
sl.streams.events.consume(
  params: StreamsEventsConsumeBitcoinParams<TTx> & { sink?: ConsumerSink<TTx> },
): Promise<StreamsEventsConsumeResult>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `chain` | `"stacks"` | no | `chain: "stacks"` (default, omit `chain` entirely) — the bitcoin branch is [`StreamsEventsConsumeBitcoinParams`](/docs/sdk-reference/streams#t-StreamsEventsConsumeBitcoinParams), a separate overload with no `filters`/`on`/`decoded` (Stacks-only concepts). |
| `fromCursor` | `string \| null` | no |  |
| `decoded` | `D` | no (Stacks only) | Deliver events pre-decoded as the flat, `event_type`-discriminated rows Index serves (`IndexEvent`), so Streams consumption reads identically to Index consumption — no guard+decode pairs in your handler. |
| `sink` | `ConsumerSink<TTx>` | no |  |
| `onProgress` | `(ctx: ConsumerBatchContext<unknown, unknown>) => void` | no | Fires once per page, before `onBatch` and before any early return. |
| `mode` | `"tail" \| "bounded"` | no |  |
| `finalizedOnly` | `boolean` | no | Emit only finalized (immutable) events and never surface reorgs. The SDK checkpoints at the last finalized event and re-reads the unfinalized tail until it settles. Trades finality lag for zero reorg handling; `onReorg` is ignored. |
| `types` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]` | no |  |
| `notTypes` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]` | no |  |
| `contractId` | `StreamsFilterValue` | no (Stacks only) |  |
| `sender` | `StreamsFilterValue` | no (Stacks only) |  |
| `recipient` | `StreamsFilterValue` | no (Stacks only) |  |
| `assetIdentifier` | `string` | no (Stacks only) |  |
| `filters` | `F` | no (Stacks only) | Labelled filter groups. The groups OR together in ONE server-side scan, so two unrelated concerns share one page, one cursor, and one checkpoint instead of two consume loops. Pair with `on` to handle each label. |
| `on` | `{ [K in keyof F]: ( events: StreamsEventForFilter<F[K], D>[], ctx: StreamsBatchContext & WithSinkTx<TTx>, ) => void \| Promise<void>; }` | no (Stacks only) | One handler per label in `filters`. Every label must be handled, so adding a label is a compile error until its handler exists. A label's declared `types` narrows its handler's events, so `payload` is typed without an `event_type` guard. Labels are dispatched in declaration order: a label's own events stay in cursor order, but events of different labels do not interleave within a page — add `onBatch` when you need strict global order across labels. |
| `batchSize` | `number` | no |  |
| `onBatch` | `(events: D extends true ? IndexEvent[] : StreamsEvent[], envelope: StreamsEventsEnvelope<StreamsEvent, StreamsTip>, ctx: StreamsBatchContext & [TTx] extends [never] ? unknown : { tx: TTx }) => string \| Promise<void> \| Promise<string \| undefined \| null> \| undefined \| null \| void` | no | Apply a page of canonical events. Persist `ctx.cursor` in the same transaction as your writes. Returning a cursor overrides `ctx.cursor` as the resume point (advanced manual control); returning nothing uses it. Optional only when `on` handles the page instead; a consume call with neither throws. |
| `onReorg` | `(reorg: StreamsReorg, ctx: StreamsReorgContext) => Promise<void> \| void` | no | Roll your projection back to `reorg.fork_point_height`, persisting `ctx.cursor` in the same transaction. Called once per *new* reorg at or below the checkpoint (deduped in-memory, fork-ascending) before the SDK rewinds and re-reads the now-canonical events. A reorg whose fork sits above the checkpoint is not a rollback (nothing past it was written) and is skipped. Omit it to ignore reorgs (events stay canonical, but stale rows from an orphaned fork are left in place). With `onReorg` or a `sink` attached, every empty page also polls `reorgs.list` (one extra request per idle poll) so a fork that lands while the consumer idles at the tip is rolled back too. |
| `maxRollbackDepth` | `number` | no | Deepest rewind one reorg may make below the checkpoint before the loop refuses with `ValidationError`. Default 1000 blocks. The fork point is server supplied and drives a delete on every declared table, so raise this only for a source you trust. |
| `retryCount` | `number` | no | Page-fetch retries after the first failure (429/5xx/network only — 4xx and handler throws always propagate). Default 3; `0` disables. |
| `retryDelay` | `number` | no | Base retry delay in ms; the n-th retry waits `retryDelay * n`. A server `Retry-After` overrides it. Default 1000. |
| `onError` | `(err: unknown, ctx: { attempt: number; retriesLeft: number; delayMs: number }) => void` | no | Void observer, called before each retry sleep (metrics/logging). |
| `emptyBackoffMs` | `number` | no |  |
| `maxPages` | `number` | no |  |
| `maxEmptyPolls` | `number` | no |  |
| `signal` | `AbortSignal` | no |  |
| `wake` | `() => Promise<void>` | no | Cuts the empty-poll backoff short: on an empty page, the loop races `emptyBackoffMs` against this promise instead of sleeping the full amount. Pass a function that resolves once when new data MIGHT be available (e.g. a Postgres NOTIFY listener) — it is called fresh on every empty page, never awaited more than once concurrently per page. The backoff timer still runs underneath, so a wake source that never resolves (or a `wake` this call omits) degrades to the same polling behavior as before. |
| `rune` | `string` | no (Bitcoin only) | A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`). |
| `address` | `string` | no (Bitcoin only) | A mainnet address. |

```ts
import { Database } from "bun:sqlite";
import { SecondLayer } from "@secondlayer/sdk";

const db = new Database("transfers.sqlite");
db.run(`create table if not exists transfers (
  cursor text primary key,
  block_height integer not null,
  sender text not null,
  recipient text not null,
  amount text not null
)`);
db.run(
	"create table if not exists checkpoint (id integer primary key check (id = 1), cursor text)",
);

const sl = new SecondLayer({
	apiKey: process.env.SECONDLAYER_API_KEY,
});
const saved = db
	.query<{ cursor: string | null }, []>("select cursor from checkpoint")
	.get();

await sl.streams.events.consume({
	types: ["ft_transfer"],
	fromCursor: saved?.cursor ?? null,
	onBatch: (events, _envelope, ctx) => {
		db.transaction(() => {
			for (const event of events) {
				if (event.event_type !== "ft_transfer") continue;
				db.query("insert or replace into transfers values (?, ?, ?, ?, ?)").run(
					event.cursor,
					event.block_height,
					event.payload.sender,
					event.payload.recipient,
					event.payload.amount,
				);
			}
			db.query("insert or replace into checkpoint values (1, ?)").run(
				ctx.cursor,
			);
		})();
		return ctx.cursor;
	},
	onReorg: (reorg, ctx) => {
		db.transaction(() => {
			db.query("delete from transfers where block_height >= ?").run(
				reorg.fork_point_height,
			);
			db.query("insert or replace into checkpoint values (1, ?)").run(
				ctx.cursor,
			);
		})();
	},
});
```

### sl.streams.events.replay(params)

Backfill from bulk dumps, then continue live in one call.

Hands each finalized dump file to `onDumpFile` in block order, then tails
live from the manifest's `latest_finalized_cursor`, with no gap or
duplicate at the seam. A file that straddles `from` is delivered whole,
so skip the rows at or below it. Requires `dumpsBaseUrl`.

```ts
sl.streams.events.replay(
  params: StreamsEventsReplayParams,
): Promise<StreamsEventsConsumeResult>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | `string` | no | Start point: `"genesis"` (default) or a `<block>:<index>` cursor. |
| `onDumpFile` | `(file: StreamsDumpFile, ctx: { from: string \| null }) => Promise<void> \| void` | yes | Called once per finalized dump file, in block order, before live tailing. Process the parquet with your own tooling (e.g. DuckDB) — the SDK does not decode parquet. Use `client.dumps.download(file)` to fetch + verify bytes. Delivery is file-granular and at-least-once: a file whose range straddles `from` is handed over whole. `ctx.from` is that cursor (`null` from genesis); skip rows at or below it, or key rows by `cursor` so a re-run is an idempotent no-op. Files ending at or below `from` are not handed over at all. |
| `onBatch` | `(events: StreamsEvent[], envelope: StreamsEventsEnvelope<StreamsEvent, StreamsTip>) => string \| Promise<string \| undefined \| null> \| undefined \| null` | yes | Called per live page after the dump phase, like `consume`. |
| `onReorg` | `(reorg: StreamsReorg, ctx: StreamsReorgContext) => Promise<void> \| void` | no | Called when the live-tail phase (after the dump backfill) crosses a reorg, before the cursor rewinds and re-reads the now-canonical events — same contract as `consume`. The dump-backfill phase is finalized and never reorgs; omit this to leave stale rows from an orphaned fork in place. |
| `mode` | `"tail" \| "bounded"` | no |  |
| `batchSize` | `number` | no |  |
| `emptyBackoffMs` | `number` | no |  |
| `maxPages` | `number` | no |  |
| `maxEmptyPolls` | `number` | no |  |
| `signal` | `AbortSignal` | no |  |
| `types` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]` | no | Narrow the LIVE TAIL after the dump phase. The dump files themselves are always all-type (block-partitioned parquet) — filter those in your own tooling while processing each file. |
| `notTypes` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]` | no |  |
| `filters` | `StreamsFilterMap` | no | Labelled OR-groups for the live tail, like `consume`. |

```ts
import { SecondLayer } from "@secondlayer/sdk";

const sl = new SecondLayer({
	apiKey: process.env.SECONDLAYER_API_KEY,
	dumpsBaseUrl: process.env.STREAMS_DUMPS_BASE_URL,
});

const result = await sl.streams.events.replay({
	from: "genesis",
	mode: "bounded",
	onDumpFile: async (file, { from }) => {
		const bytes = await sl.streams.dumps.download(file);
		await Bun.write(`dumps/${file.from_block}-${file.to_block}.parquet`, bytes);
		console.log(`dump ${file.path}, resume after ${from ?? "genesis"}`);
	},
	onBatch: (events, envelope) => {
		console.log(`live page: ${events.length} events`);
		return envelope.next_cursor;
	},
});

console.log(`caught up at ${result.cursor} after ${result.pages} live pages`);
```

### sl.streams.events.stream(params)

Read events one at a time as an async iterator.

For live processors and watch-style apps that keep their own state. It
tails indefinitely and stops when `signal`, `maxPages`, or
`maxEmptyPolls` ends it. There is no checkpoint or reorg handling; use
`events.consume` for that.

A literal `types` array narrows every event to those types.
`chain: "bitcoin"` yields Runes events only.

**Stacks**

```ts
sl.streams.events.stream(
  params: StreamsEventsStreamCommon
  & {
    chain?: "stacks";
    types?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print")[];
    notTypes?: readonly ("stx_transfer" | "stx_mint" | "stx_burn" | "stx_lock" | "ft_transfer" | "ft_mint" | "ft_burn" | "nft_transfer" | "nft_mint" | "nft_burn" | "print")[];
    contractId?: StreamsFilterValue;
    sender?: StreamsFilterValue;
    recipient?: StreamsFilterValue;
    assetIdentifier?: string;
    filters?: StreamsFilterMap;
  }
  & { types: T },
): AsyncIterable<StreamsEventOfTypes<T>, any, any>
```

**Any chain**

```ts
sl.streams.events.stream(
  params?: StreamsEventsStreamBitcoinParams | StreamsEventsStreamStacksParams,
): AsyncIterable<StreamsEvent, any, any>
```

**Bitcoin**

```ts
sl.streams.events.stream(
  params: StreamsEventsStreamBitcoinParams,
): AsyncIterable<RuneStreamsEvent, any, any>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `fromCursor` | `string \| null` | no (Stacks · Bitcoin only) |  |
| `batchSize` | `number` | no (Stacks · Bitcoin only) |  |
| `emptyBackoffMs` | `number` | no (Stacks · Bitcoin only) |  |
| `maxPages` | `number` | no (Stacks · Bitcoin only) |  |
| `maxEmptyPolls` | `number` | no (Stacks · Bitcoin only) |  |
| `signal` | `AbortSignal` | no (Stacks · Bitcoin only) |  |
| `chain` | `"stacks"` | no (Stacks · Bitcoin only) |  |
| `types` | `T` | yes (Stacks · Bitcoin only) |  |
| `notTypes` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]` | no (Stacks · Bitcoin only) |  |
| `contractId` | `StreamsFilterValue` | no (Stacks only) |  |
| `sender` | `StreamsFilterValue` | no (Stacks only) |  |
| `recipient` | `StreamsFilterValue` | no (Stacks only) |  |
| `assetIdentifier` | `string` | no (Stacks only) |  |
| `filters` | `StreamsFilterMap` | no (Stacks only) |  |
| `params` | `StreamsEventsStreamBitcoinParams \| StreamsEventsStreamStacksParams` | no (Any chain only) |  |
| `rune` | `string` | no (Bitcoin only) | A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`). |
| `address` | `string` | no (Bitcoin only) | A mainnet address. |

### sl.streams.events.subscribe(params)

Get each new event pushed to `onEvent` as it lands.

Reconnects from the last handled cursor after a transport error, and
verifies each frame's ed25519 signature when the client has `verify` on.
Returns an unsubscribe function whose `done` promise settles when the
loop ends. Not reorg-aware: an event delivered and then orphaned is never
retracted. Durable writers use `events.consume`.

`chain: "bitcoin"` delivers Runes rows to `onEvent`.

**Stacks**

```ts
sl.streams.events.subscribe(
  params: StreamsEventsSubscribeStacksParams,
): StreamsSubscription
```

**Bitcoin**

```ts
sl.streams.events.subscribe(
  params: StreamsEventsSubscribeBitcoinParams,
): StreamsSubscription
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `fromCursor` | `string \| null` | no | Resume strictly after this cursor; omit to live-tail from the tip. |
| `signal` | `AbortSignal` | no |  |
| `onError` | `(err: unknown) => void` | no | Called on every failure. Transport errors (dropped socket, 5xx, 429, a stale connection, a throwing handler) reconnect from the last handled cursor with exponential backoff. Errors that a retry cannot fix (401, 4xx, a bad signature) end the subscription and reject its `done`. |
| `reconnectDelayMs` | `number` | no | First reconnect pause in ms (default 1000). Doubles per consecutive failure up to 30 s with jitter, resets once a frame arrives, and never undercuts a `Retry-After`. |
| `staleAfterMs` | `number` | no | Reconnect when no frame (pings included) arrives for this long (default 60000, three server heartbeats). Catches a half-open socket that never errors; set it above your instance's heartbeat interval. |
| `chain` | `"stacks"` | no |  |
| `types` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]` | no |  |
| `notTypes` | `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]` | no |  |
| `contractId` | `StreamsFilterValue` | no (Stacks only) |  |
| `sender` | `StreamsFilterValue` | no (Stacks only) |  |
| `recipient` | `StreamsFilterValue` | no (Stacks only) |  |
| `assetIdentifier` | `string` | no (Stacks only) |  |
| `filters` | `StreamsFilterMap` | no (Stacks only) | Labelled filter groups; see `StreamsEventsListParams.filters`. |
| `onEvent` | `(event: StreamsEvent) => Promise<void> \| void` | yes | Called for each pushed event, in order. The resume cursor advances only after this resolves, so a handler that throws sees the same event again on reconnect (at-least-once). Key durable writes by `cursor`. |
| `rune` | `string` | no (Bitcoin only) | A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`). |
| `address` | `string` | no (Bitcoin only) | A mainnet address. |

```ts
import { SecondLayer } from "@secondlayer/sdk";

const sl = new SecondLayer({
	apiKey: process.env.SECONDLAYER_API_KEY,
});

const subscription = sl.streams.events.subscribe({
	types: ["print"],
	contractId: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry",
	onEvent: (event) => {
		if (event.event_type !== "print") return;
		console.log(event.cursor, event.payload.topic);
	},
	onError: (err) => console.error("subscription error", err),
});

// Calling the subscription unsubscribes.
process.on("SIGTERM", () => subscription());

await subscription.done;
```

### sl.streams.blocks.events(heightOrHash)

Read every event in one block, by height or hash.

Returns the events and the `tip`, with no `next_cursor`.

`chain: "bitcoin"` returns the Runes events in one Bitcoin block.

**Overload 1**

```ts
sl.streams.blocks.events(
  heightOrHash: string | number,
): Promise<StreamsEventsListEnvelope<StreamsEvent, StreamsTip>>
```

**Bitcoin**

```ts
sl.streams.blocks.events(
  heightOrHash: string | number,
  opts: { chain: "bitcoin" },
): Promise<StreamsEventsListEnvelope<RuneStreamsEvent, StreamsBitcoinTip>>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `heightOrHash` | `string \| number` | yes |  |
| `opts.chain` | `"bitcoin"` | yes (Bitcoin only) |  |

### sl.streams.reorgs.list(params)

List chain reorgs since a cursor.

`since` is required. Each reorg gives its `fork_point_height` and the
orphaned range. Page with `next_since`.

`chain: "bitcoin"` lists Bitcoin reorgs, on their own cursor space.

**Overload 1**

```ts
sl.streams.reorgs.list(
  params: StreamsReorgsListParams,
): Promise<StreamsReorgsListEnvelope>
```

**Bitcoin**

```ts
sl.streams.reorgs.list(
  params: StreamsReorgsListParams & { chain: "bitcoin" },
): Promise<StreamsReorgsListEnvelope>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `since` | `string` | yes |  |
| `limit` | `number` | no |  |
| `chain` | `"bitcoin"` | yes (Bitcoin only) |  |

### sl.streams.dumps.list()

Fetch and parse the latest dumps manifest.

```ts
sl.streams.dumps.list(): Promise<StreamsDumpsManifest>
```

### sl.streams.dumps.fileUrl(file)

Absolute URL for a manifest file.

```ts
sl.streams.dumps.fileUrl(file: StreamsDumpFile): string
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `path` | `string` | yes |  |
| `from_block` | `number` | yes |  |
| `to_block` | `number` | yes |  |
| `min_cursor` | `string \| null` | yes |  |
| `max_cursor` | `string \| null` | yes |  |
| `row_count` | `number` | yes |  |
| `byte_size` | `number` | yes |  |
| `sha256` | `string` | yes |  |
| `schema_version` | `number` | yes |  |
| `created_at` | `string` | yes |  |

### sl.streams.dumps.download(file)

Download a parquet file and verify its sha256 against the manifest.

```ts
sl.streams.dumps.download(file: StreamsDumpFile): Promise<Uint8Array>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `path` | `string` | yes |  |
| `from_block` | `number` | yes |  |
| `to_block` | `number` | yes |  |
| `min_cursor` | `string \| null` | yes |  |
| `max_cursor` | `string \| null` | yes |  |
| `row_count` | `number` | yes |  |
| `byte_size` | `number` | yes |  |
| `sha256` | `string` | yes |  |
| `schema_version` | `number` | yes |  |
| `created_at` | `string` | yes |  |

### sl.streams.canonical(height)

Get the canonical block at a height.

Returns the block hash and, on Stacks, the burn block it anchors to.

`chain: "bitcoin"` returns the Bitcoin block, without burn-chain fields.

**Overload 1**

```ts
sl.streams.canonical(height: number): Promise<StreamsCanonicalBlock>
```

**Bitcoin**

```ts
sl.streams.canonical(
  height: number,
  opts: { chain: "bitcoin" },
): Promise<StreamsBitcoinCanonicalBlock>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `height` | `number` | yes |  |
| `opts.chain` | `"bitcoin"` | yes (Bitcoin only) |  |

### sl.streams.tip()

Get the current chain tip.

Returns the tip height and hash, `finalized_height`, and `lag_seconds`. On
Stacks it also reports the oldest height your tier can seek; older reads
come from the bulk dumps.

`chain: "bitcoin"` returns the Bitcoin tip, without burn-chain or retention
fields.

**Overload 1**

```ts
sl.streams.tip(): Promise<StreamsTip>
```

**Bitcoin**

```ts
sl.streams.tip(opts: { chain: "bitcoin" }): Promise<StreamsBitcoinTip>
```

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `chain` | `"bitcoin"` | yes (Bitcoin only) |  |

## Types

- `ConsumerBatchContext`: What the loop knows at the end of a page, handed to `onBatch`.
  - `cursor`: `string \| null`. Checkpoint to commit alongside your rows.
  - `height`: `number \| null`. Highest canonical block a row was DELIVERED from. `null` before the first row of a fresh consume. With a sparse filter this parks at the last matching event — it measures event recency, not progress.
  - `scannedHeight`: `number \| null`. Highest canonical block the sweep has VERIFIED through: an empty page is the server confirming nothing matches between the cursor and the tip, so a caught-up tail scans to the tip even when `height` is far below it. Rolls back with reorg rewinds.
  - `tipHeight`: `number`. Chain tip as of this page's read.
  - `blocksBehind`: `number \| null`. `tipHeight - scannedHeight`, floored at 0 — the consumer's actual backlog. `null` until a position is known. A caught-up consumer on a quiet contract reads ~0 here while `tipHeight - height` grows — that difference is event age, not lag.
  - `tip`: `TTip`. The full tip for this page — `StreamsTip` or `IndexTip` depending on the surface. Exposed here so a handler never needs the `envelope` parameter, which is the only way to reach it today and is also how the worst `finalizedOnly` mistake becomes reachable (`return envelope.next_cursor` jumps the cursor past events that were filtered out and never delivered).
  - `reorgs`: `readonly TReorg[]`. Reorgs reported alongside this page; empty when none. Rollback still belongs in `onReorg` — this is for observability inside a batch.
- `ConsumerSink`: A consumer sink: the destination adapter a consume loop writes through.
  - `_tx`?: `Tx`. Phantom marker carrying `Tx` in a directly-inferable position, so the consume loops' `TTx` type parameter resolves from the `sink` option (never set at runtime).
  - `capabilities`?: `{ finalizedOnly?: boolean }`. Static capabilities, read by the loop before the first fetch to fail fast on impossible pairings.
  - `loadCursor`: `() => Promise<string \| null>`. The committed checkpoint, or `null` on first run. Called once, before the first page. Implementations may create their checkpoint storage here and SHOULD validate their rollback preconditions (e.g. that every declared table carries the height column).
  - `commitBatch`: `(cursor: string, write: (tx: Tx) => Promise<void> \| void) => Promise<void>`. Apply one batch atomically: open a transaction, run `write(tx)` (the user's inserts), and commit the rows AND `cursor` together. A throw from `write` must abort the whole transaction — leaving neither rows nor cursor, so a crashed batch is simply re-read on restart.
  - `rollback`: `(forkPointHeight: number, rewindCursor: string \| null) => Promise<void>`. Roll the projection back to the fork: delete everything AT OR ABOVE `forkPointHeight` (inclusive `>=` — the new chain re-supplies the fork block) and commit `rewindCursor` in the SAME transaction. Deleting without the rewound cursor is the classic silent-gap bug: a crash between the two writes resumes above the fork and the deleted range is never re-read. On a multi-fork page this is called once PER fork, every call with the same `rewindCursor` (the lowest fork point) but its own `forkPointHeight` — scope the undo by `forkPointHeight` only (contract invariant #11), and expect re-application after a crash (#10). `rewindCursor` is `null` only for a fork at genesis: clear the checkpoint so the next run starts from the first event.
- `FtBurnPayload`
  - `asset_identifier`: `string`
  - `sender`: `string`
  - `amount`: `string`
- `FtMintPayload`
  - `asset_identifier`: `string`
  - `recipient`: `string`
  - `amount`: `string`
- `FtTransferPayload`
  - `asset_identifier`: `string`
  - `sender`: `string`
  - `recipient`: `string`
  - `amount`: `string`
- `MapDeletePayload`
  - `contract_identifier`: `string`
  - `map_name`: `string`
  - `raw_key`: `string`
- `MapWritePayload`
  - `contract_identifier`: `string`
  - `map_name`: `string`
  - `raw_key`: `string`
  - `raw_value`: `string`
- `NestedContractCallPayload`
  - `contract_identifier`: `string`
  - `sender`: `string \| null`. tx-sender (the signer). `null` when the VM had none.
  - `caller`: `string`. The contract that issued `contract-call?`.
  - `function_name`: `string`
  - `function_args`: `string[]`. Clarity hex per argument.
  - `raw_result`: `string`
- `NftBurnPayload`
  - `asset_identifier`: `string`
  - `sender`: `string`
  - `value`: `StreamsClarityValue`
  - `raw_value`?: `string`
- `NftMintPayload`
  - `asset_identifier`: `string`
  - `recipient`: `string`
  - `value`: `StreamsClarityValue`
  - `raw_value`?: `string`
- `NftTransferPayload`
  - `asset_identifier`: `string`
  - `sender`: `string`
  - `recipient`: `string`
  - `value`: `StreamsClarityValue`
  - `raw_value`?: `string`. Canonical serialized hex of `value`, when the stream carries it.
- `PrintPayload`
  - `contract_id`?: `string \| null`
  - `topic`?: `string`
  - `value`?: `unknown`
  - `raw_value`?: `string`
- `RuneEtchEntry`: The etch a `rune_etch` event created — everything a consumer needs to render a fresh rune without a second lookup, minus what the envelope already carries (`rune_id`).
  - `name`: `string`. No spacers, uppercase.
  - `spaced_name`: `string`. As etched, spacers included (e.g. `DOG•GO•TO•THE•MOON`).
  - `symbol`: `string \| null`
  - `divisibility`: `number`
  - `premine`: `string`. u128 decimal string. Never `Number()`.
  - `turbo`: `boolean`
  - `terms`: `{ amount: string \| null; cap: string \| null; height_start: string \| null; height_end: string \| null; offset_start: string \| null; offset_end: string \| null; } \| null`
- `RuneEventPayload`
  - `amount`: `string`. u128 decimal string. Never `Number()`.
  - `vout`?: `number`. The output this event landed on. Set on `rune_transfer` only — an etch or mint's allocation isn't tied to one output the way a transfer is (`packages/bitcoin/src/runes/updater.ts`).
  - `address`?: `string`. The output's mainnet address. Set on `rune_transfer` only, and only when the output has a standard scriptPubKey.
  - `entry`?: `RuneEtchEntry`. `rune_etch` only — the entry this event created.
- `RuneEventType`: `"rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn"`
- `RuneStreamsEvent`: A Bitcoin rune event as Streams serves it.
  - `cursor`: `string`
  - `chain`: `"bitcoin"`
  - `block_height`: `number`
  - `block_hash`: `string`
  - `tx_id`: `string`
  - `tx_index`: `number`
  - `event_index`: `number`
  - `event_type`: `RuneEventType`
  - `rune_id`: `string`
  - `payload`: `RuneEventPayload`
  - `ts`?: `string`. The event's block time, ISO 8601. Absent when the indexer has no header time for that block yet (rows written before block times were recorded, until backfilled).
  - `finalized`?: `boolean`. True when this event's block is past the finality boundary (immutable). Optional for back-compat with the same reasoning as `StreamsEventBase`.
- `StreamsBatch`: One page yielded by `consume`, with `cursor` to persist and resume.
  - `events`: `TEvent[]`. Canonical events of this page, in cursor order.
  - `cursor`: `string \| null`. Checkpoint after this page — pass back as `consume({ cursor })` to resume.
  - `tip`: `TTip`
  - `reorgs`: `StreamsReorg[]`. Chain reorgs reported alongside this page; empty when none.
- `StreamsBatchContext` (deprecated)
  - `cursor`: `string \| null`. Checkpoint to commit alongside your rows.
  - `height`: `number \| null`. Highest canonical block a row was DELIVERED from. `null` before the first row of a fresh consume. With a sparse filter this parks at the last matching event — it measures event recency, not progress.
  - `scannedHeight`: `number \| null`. Highest canonical block the sweep has VERIFIED through: an empty page is the server confirming nothing matches between the cursor and the tip, so a caught-up tail scans to the tip even when `height` is far below it. Rolls back with reorg rewinds.
  - `tipHeight`: `number`. Chain tip as of this page's read.
  - `blocksBehind`: `number \| null`. `tipHeight - scannedHeight`, floored at 0 — the consumer's actual backlog. `null` until a position is known. A caught-up consumer on a quiet contract reads ~0 here while `tipHeight - height` grows — that difference is event age, not lag.
  - `tip`: `unknown`. The full tip for this page — `StreamsTip` or `IndexTip` depending on the surface. Exposed here so a handler never needs the `envelope` parameter, which is the only way to reach it today and is also how the worst `finalizedOnly` mistake becomes reachable (`return envelope.next_cursor` jumps the cursor past events that were filtered out and never delivered).
  - `reorgs`: `readonly unknown[]`. Reorgs reported alongside this page; empty when none. Rollback still belongs in `onReorg` — this is for observability inside a batch.
- `StreamsBitcoinCanonicalBlock`: A Bitcoin canonical block, without burn-chain fields.
  - `block_height`: `number`
  - `block_hash`: `string`
  - `is_canonical`: `true`
- `StreamsBitcoinTip`: The Bitcoin tip, without burn-chain or retention fields.
  - `block_height`: `number`
  - `block_hash`: `string`
  - `finalized_height`: `number`
  - `lag_seconds`: `number`
- `StreamsCanonicalBlock`: A canonical Stacks block with its Bitcoin anchor.
  - `block_height`: `number`
  - `block_hash`: `string`
  - `burn_block_height`: `number`
  - `burn_block_hash`: `string \| null`
  - `is_canonical`: `true`
- `StreamsClarityValue`: A Clarity value as Streams serves it: the canonical hex string, a typed object carrying that hex (`{ hex }`), or a decoded Clarity-JSON object.
- `StreamsConsumeBitcoinParams`: Consume options for Runes batches, with their own cursor space.
  - `cursor`?: `string \| null`. Resume strictly after this cursor; omit to start from the oldest seekable page.
  - `batchSize`?: `number`. Events per page (the `limit` query param). Default 100.
  - `intervalMs`?: `number`. Poll interval while caught up at the tip, in ms. Default 2000.
  - `signal`?: `AbortSignal`
  - `chain`: `"bitcoin"`
  - `types`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `notTypes`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `rune`?: `string`. A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`).
  - `address`?: `string`. A mainnet address.
- `StreamsConsumeCommon`: Cursor, batch size, and polling options shared by every consume loop.
  - `cursor`?: `string \| null`. Resume strictly after this cursor; omit to start from the oldest seekable page.
  - `batchSize`?: `number`. Events per page (the `limit` query param). Default 100.
  - `intervalMs`?: `number`. Poll interval while caught up at the tip, in ms. Default 2000.
  - `signal`?: `AbortSignal`
- `StreamsConsumeStacksParams`: `chain: "stacks"` (default, omit `chain` entirely).
  - `cursor`?: `string \| null`. Resume strictly after this cursor; omit to start from the oldest seekable page.
  - `batchSize`?: `number`. Events per page (the `limit` query param). Default 100.
  - `intervalMs`?: `number`. Poll interval while caught up at the tip, in ms. Default 2000.
  - `signal`?: `AbortSignal`
  - `chain`?: `"stacks"`
  - `types`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `notTypes`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `contractId`?: `StreamsFilterValue`
  - `sender`?: `StreamsFilterValue`
  - `recipient`?: `StreamsFilterValue`
  - `assetIdentifier`?: `string`
  - `filters`?: `StreamsFilterMap`. Labelled OR-groups — same semantics as `events.consume`; each returned event echoes the labels it matched.
- `StreamsDumpFile`: One bulk parquet file in the dumps manifest.
  - `path`: `string`
  - `from_block`: `number`
  - `to_block`: `number`
  - `min_cursor`: `string \| null`
  - `max_cursor`: `string \| null`
  - `row_count`: `number`
  - `byte_size`: `number`
  - `sha256`: `string`
  - `schema_version`: `number`
  - `created_at`: `string`
- `StreamsDumpsManifest`: The index of finalized Parquet dump files for one dataset.
  - `dataset`: `string`
  - `network`: `string`
  - `version`: `string`
  - `schema_version`: `number`
  - `generated_at`: `string`
  - `producer_version`: `string`
  - `finality_lag_blocks`: `number`
  - `latest_finalized_cursor`: `string \| null`. Cursor at the end of the finalized bulk coverage — hand to live tailing.
  - `coverage`: `{ from_block: number; to_block: number }`
  - `files`: `StreamsDumpFile[]`
  - `signature`?: `string`. ed25519 signature over the manifest's canonical bytes. Absent on legacy unsigned manifests. Verified by `list()` when `verifyDumpsManifest` is on.
  - `key_id`?: `string`. Short id of the signing public key.
- `StreamsEvent`: A raw Streams event.
- `StreamsEventOf`
  - `cursor`: `string`. Globally unique, monotonic position of this event (`<block>:<index>`). Use it as the primary key of your projection rows — replaying a batch then upserts cleanly. Don't synthesize your own id from `tx_id`/`event_index`.
  - `block_height`: `number`
  - `block_hash`: `string`
  - `burn_block_height`: `number`
  - `tx_id`: `string`
  - `tx_index`: `number`
  - `event_index`: `number`
  - `contract_id`: `string \| null`
  - `ts`: `string`
  - `finalized`?: `boolean`. True when this event's block is past the finality boundary (immutable). Optional for back-compat; the API always sets it on Streams responses.
  - `matched`?: `string[]`. Labels whose filter group this event satisfied, present only when the request used a labelled `filters` map. The SDK dispatches on it for you (`on.<label>`), so handlers rarely read it directly.
  - `chain`?: `"stacks"`. Which chain this event came from. Every Stacks Streams response sets this to `"stacks"`; optional here for back-compat with fixtures/tests that pre-date the field — the same reasoning as `finalized?`. Cursors stay per-chain but chain-agnostic in shape, so this is the only signal a mixed-chain caller can discriminate on.
  - `event_type`: `T`
  - `payload`: `P`
- `StreamsEventOfTypes`: The event union narrowed to a `types` selection.
- `StreamsEventsBitcoinListParams`: List options for Runes events, with their own cursor space.
  - `cursor`?: `string \| null`
  - `fromHeight`?: `number`
  - `toHeight`?: `number`
  - `limit`?: `number`
  - `chain`: `"bitcoin"`
  - `types`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `notTypes`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `rune`?: `string`. A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`).
  - `address`?: `string`. A mainnet address.
- `StreamsEventsConsumeBitcoinParams`: Options for the checkpointed sweep over Runes events.
  - `chain`: `"bitcoin"`
  - `fromCursor`?: `string \| null`
  - `sink`?: `ConsumerSink<TTx>`
  - `onProgress`?: `(ctx: ConsumerBatchContext<unknown, unknown>) => void`
  - `mode`?: `"tail" \| "bounded"`
  - `finalizedOnly`?: `boolean`
  - `types`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `notTypes`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `rune`?: `string`. A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`).
  - `address`?: `string`. A mainnet address.
  - `batchSize`?: `number`
  - `onBatch`: `(events: RuneStreamsEvent[], envelope: StreamsEventsEnvelope<RuneStreamsEvent, StreamsBitcoinTip>, ctx: StreamsBatchContext & [TTx] extends [never] ? unknown : { tx: TTx }) => string \| Promise<void> \| Promise<string \| undefined \| null> \| undefined \| null \| void`
  - `onReorg`?: `(reorg: StreamsReorg, ctx: StreamsReorgContext) => Promise<void> \| void`
  - `maxRollbackDepth`?: `number`
  - `retryCount`?: `number`
  - `retryDelay`?: `number`
  - `onError`?: `(err: unknown, ctx: { attempt: number; retriesLeft: number; delayMs: number }) => void`
  - `emptyBackoffMs`?: `number`
  - `maxPages`?: `number`
  - `maxEmptyPolls`?: `number`
  - `signal`?: `AbortSignal`
  - `wake`?: `() => Promise<void>`
- `StreamsEventsConsumeParams`: Options for consuming Streams events: cursor, batching, filters, handler.
  - `chain`?: `"stacks"`. `chain: "stacks"` (default, omit `chain` entirely) — the bitcoin branch is [`StreamsEventsConsumeBitcoinParams`](/docs/sdk-reference/streams#t-StreamsEventsConsumeBitcoinParams), a separate overload with no `filters`/`on`/`decoded` (Stacks-only concepts).
  - `fromCursor`?: `string \| null`
  - `decoded`?: `D`. Deliver events pre-decoded as the flat, `event_type`-discriminated rows Index serves (`IndexEvent`), so Streams consumption reads identically to Index consumption — no guard+decode pairs in your handler.
  - `sink`?: `ConsumerSink<TTx>`
  - `onProgress`?: `(ctx: ConsumerBatchContext<unknown, unknown>) => void`. Fires once per page, before `onBatch` and before any early return.
  - `mode`?: `"tail" \| "bounded"`
  - `finalizedOnly`?: `boolean`. Emit only finalized (immutable) events and never surface reorgs. The SDK checkpoints at the last finalized event and re-reads the unfinalized tail until it settles. Trades finality lag for zero reorg handling; `onReorg` is ignored.
  - `types`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `notTypes`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `contractId`?: `StreamsFilterValue`
  - `sender`?: `StreamsFilterValue`
  - `recipient`?: `StreamsFilterValue`
  - `assetIdentifier`?: `string`
  - `filters`?: `F`. Labelled filter groups. The groups OR together in ONE server-side scan, so two unrelated concerns share one page, one cursor, and one checkpoint instead of two consume loops. Pair with `on` to handle each label.
  - `on`?: `{ [K in keyof F]: ( events: StreamsEventForFilter<F[K], D>[], ctx: StreamsBatchContext & WithSinkTx<TTx>, ) => void \| Promise<void>; }`. One handler per label in `filters`. Every label must be handled, so adding a label is a compile error until its handler exists. A label's declared `types` narrows its handler's events, so `payload` is typed without an `event_type` guard. Labels are dispatched in declaration order: a label's own events stay in cursor order, but events of different labels do not interleave within a page — add `onBatch` when you need strict global order across labels.
  - `batchSize`?: `number`
  - `onBatch`?: `(events: D extends true ? IndexEvent[] : StreamsEvent[], envelope: StreamsEventsEnvelope<StreamsEvent, StreamsTip>, ctx: StreamsBatchContext & [TTx] extends [never] ? unknown : { tx: TTx }) => string \| Promise<void> \| Promise<string \| undefined \| null> \| undefined \| null \| void`. Apply a page of canonical events. Persist `ctx.cursor` in the same transaction as your writes. Returning a cursor overrides `ctx.cursor` as the resume point (advanced manual control); returning nothing uses it. Optional only when `on` handles the page instead; a consume call with neither throws.
  - `onReorg`?: `(reorg: StreamsReorg, ctx: StreamsReorgContext) => Promise<void> \| void`. Roll your projection back to `reorg.fork_point_height`, persisting `ctx.cursor` in the same transaction. Called once per *new* reorg at or below the checkpoint (deduped in-memory, fork-ascending) before the SDK rewinds and re-reads the now-canonical events. A reorg whose fork sits above the checkpoint is not a rollback (nothing past it was written) and is skipped. Omit it to ignore reorgs (events stay canonical, but stale rows from an orphaned fork are left in place). With `onReorg` or a `sink` attached, every empty page also polls `reorgs.list` (one extra request per idle poll) so a fork that lands while the consumer idles at the tip is rolled back too.
  - `maxRollbackDepth`?: `number`. Deepest rewind one reorg may make below the checkpoint before the loop refuses with `ValidationError`. Default 1000 blocks. The fork point is server supplied and drives a delete on every declared table, so raise this only for a source you trust.
  - `retryCount`?: `number`. Page-fetch retries after the first failure (429/5xx/network only — 4xx and handler throws always propagate). Default 3; `0` disables.
  - `retryDelay`?: `number`. Base retry delay in ms; the n-th retry waits `retryDelay * n`. A server `Retry-After` overrides it. Default 1000.
  - `onError`?: `(err: unknown, ctx: { attempt: number; retriesLeft: number; delayMs: number }) => void`. Void observer, called before each retry sleep (metrics/logging).
  - `emptyBackoffMs`?: `number`
  - `maxPages`?: `number`
  - `maxEmptyPolls`?: `number`
  - `signal`?: `AbortSignal`
  - `wake`?: `() => Promise<void>`. Cuts the empty-poll backoff short: on an empty page, the loop races `emptyBackoffMs` against this promise instead of sleeping the full amount. Pass a function that resolves once when new data MIGHT be available (e.g. a Postgres NOTIFY listener) — it is called fresh on every empty page, never awaited more than once concurrently per page. The backoff timer still runs underneath, so a wake source that never resolves (or a `wake` this call omits) degrades to the same polling behavior as before.
- `StreamsEventsConsumeResult`: Where a consume loop stopped: cursor, pages read, empty polls.
  - `cursor`: `string \| null`
  - `pages`: `number`
  - `emptyPolls`: `number`
- `StreamsEventsEnvelope`: One page of events with the next cursor, tip, and reorgs.
  - `events`: `TEvent[]`
  - `next_cursor`: `string \| null`
  - `tip`: `TTip`
  - `reorgs`: `StreamsReorg[]`
- `StreamsEventsListEnvelope`: One page of events with the tip and reorgs, no cursor.
  - `events`: `TEvent[]`
  - `tip`: `TTip`
  - `reorgs`: `StreamsReorg[]`
- `StreamsEventsListParams`: Chain, cursor, height range, and filters for listing Streams events.
- `StreamsEventsListParamsCommon`: Cursor, height range, and limit shared by every list call.
  - `cursor`?: `string \| null`
  - `fromHeight`?: `number`
  - `toHeight`?: `number`
  - `limit`?: `number`
- `StreamsEventsReplayParams`: Options for replaying history from dump files, then tailing live.
  - `from`?: `string`. Start point: `"genesis"` (default) or a `<block>:<index>` cursor.
  - `onDumpFile`: `(file: StreamsDumpFile, ctx: { from: string \| null }) => Promise<void> \| void`. Called once per finalized dump file, in block order, before live tailing. Process the parquet with your own tooling (e.g. DuckDB) — the SDK does not decode parquet. Use `client.dumps.download(file)` to fetch + verify bytes. Delivery is file-granular and at-least-once: a file whose range straddles `from` is handed over whole. `ctx.from` is that cursor (`null` from genesis); skip rows at or below it, or key rows by `cursor` so a re-run is an idempotent no-op. Files ending at or below `from` are not handed over at all.
  - `onBatch`: `(events: StreamsEvent[], envelope: StreamsEventsEnvelope<StreamsEvent, StreamsTip>) => string \| Promise<string \| undefined \| null> \| undefined \| null`. Called per live page after the dump phase, like `consume`.
  - `onReorg`?: `(reorg: StreamsReorg, ctx: StreamsReorgContext) => Promise<void> \| void`. Called when the live-tail phase (after the dump backfill) crosses a reorg, before the cursor rewinds and re-reads the now-canonical events — same contract as `consume`. The dump-backfill phase is finalized and never reorgs; omit this to leave stale rows from an orphaned fork in place.
  - `mode`?: `"tail" \| "bounded"`
  - `batchSize`?: `number`
  - `emptyBackoffMs`?: `number`
  - `maxPages`?: `number`
  - `maxEmptyPolls`?: `number`
  - `signal`?: `AbortSignal`
  - `types`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`. Narrow the LIVE TAIL after the dump phase. The dump files themselves are always all-type (block-partitioned parquet) — filter those in your own tooling while processing each file.
  - `notTypes`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `filters`?: `StreamsFilterMap`. Labelled OR-groups for the live tail, like `consume`.
- `StreamsEventsStacksListParams`: `chain: "stacks"` (default, omit `chain` entirely) — unchanged since before Runes events were added.
  - `cursor`?: `string \| null`
  - `fromHeight`?: `number`
  - `toHeight`?: `number`
  - `limit`?: `number`
  - `chain`?: `"stacks"`
  - `clock`?: `"classic" \| "vm"`. `vm` reads ordinal. Omit for Streams 1.0.
  - `types`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print" \| "nested_contract_call" \| "var_set" \| "map_set" \| "map_insert" \| "map_delete")[]`
  - `notTypes`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print" \| "nested_contract_call" \| "var_set" \| "map_set" \| "map_insert" \| "map_delete")[]`. Event types to exclude (applied after `types`).
  - `contractId`?: `StreamsFilterValue`
  - `sender`?: `StreamsFilterValue`
  - `recipient`?: `StreamsFilterValue`
  - `assetIdentifier`?: `string`
  - `filters`?: `StreamsFilterMap`. Labelled filter groups. The groups OR together in one scan and each returned event carries the labels it matched (`event.matched`) — two unrelated concerns share one page, one cursor, one checkpoint.
- `StreamsEventsStreamBitcoinParams`: Stream options for Runes events, tailed like the Stacks default.
  - `fromCursor`?: `string \| null`
  - `batchSize`?: `number`
  - `emptyBackoffMs`?: `number`
  - `maxPages`?: `number`
  - `maxEmptyPolls`?: `number`
  - `signal`?: `AbortSignal`
  - `chain`: `"bitcoin"`
  - `types`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `notTypes`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `rune`?: `string`. A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`).
  - `address`?: `string`. A mainnet address.
- `StreamsEventsStreamCommon`: Cursor, batching, and stop options shared by every event stream.
  - `fromCursor`?: `string \| null`
  - `batchSize`?: `number`
  - `emptyBackoffMs`?: `number`
  - `maxPages`?: `number`
  - `maxEmptyPolls`?: `number`
  - `signal`?: `AbortSignal`
- `StreamsEventsStreamStacksParams`: Stream options for Stacks events, the default chain.
  - `fromCursor`?: `string \| null`
  - `batchSize`?: `number`
  - `emptyBackoffMs`?: `number`
  - `maxPages`?: `number`
  - `maxEmptyPolls`?: `number`
  - `signal`?: `AbortSignal`
  - `chain`?: `"stacks"`
  - `types`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `notTypes`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `contractId`?: `StreamsFilterValue`
  - `sender`?: `StreamsFilterValue`
  - `recipient`?: `StreamsFilterValue`
  - `assetIdentifier`?: `string`
  - `filters`?: `StreamsFilterMap`. Labelled filter groups; see `StreamsEventsListParams.filters`.
- `StreamsEventsSubscribeBitcoinParams`: Options for pushing Runes events live, on their own cursor space.
  - `fromCursor`?: `string \| null`. Resume strictly after this cursor; omit to live-tail from the tip.
  - `signal`?: `AbortSignal`
  - `onError`?: `(err: unknown) => void`. Called on every failure. Transport errors (dropped socket, 5xx, 429, a stale connection, a throwing handler) reconnect from the last handled cursor with exponential backoff. Errors that a retry cannot fix (401, 4xx, a bad signature) end the subscription and reject its `done`.
  - `reconnectDelayMs`?: `number`. First reconnect pause in ms (default 1000). Doubles per consecutive failure up to 30 s with jitter, resets once a frame arrives, and never undercuts a `Retry-After`.
  - `staleAfterMs`?: `number`. Reconnect when no frame (pings included) arrives for this long (default 60000, three server heartbeats). Catches a half-open socket that never errors; set it above your instance's heartbeat interval.
  - `chain`: `"bitcoin"`
  - `types`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `notTypes`?: `readonly ("rune_etch" \| "rune_mint" \| "rune_transfer" \| "rune_burn")[]`
  - `rune`?: `string`. A RuneRef: an id (`840000:3`) or a name (`DOG•GO•TO•THE•MOON`).
  - `address`?: `string`. A mainnet address.
  - `onEvent`: `(event: RuneStreamsEvent) => Promise<void> \| void`
- `StreamsEventsSubscribeStacksParams`: `chain: "stacks"` (default, omit `chain` entirely).
  - `fromCursor`?: `string \| null`. Resume strictly after this cursor; omit to live-tail from the tip.
  - `signal`?: `AbortSignal`
  - `onError`?: `(err: unknown) => void`. Called on every failure. Transport errors (dropped socket, 5xx, 429, a stale connection, a throwing handler) reconnect from the last handled cursor with exponential backoff. Errors that a retry cannot fix (401, 4xx, a bad signature) end the subscription and reject its `done`.
  - `reconnectDelayMs`?: `number`. First reconnect pause in ms (default 1000). Doubles per consecutive failure up to 30 s with jitter, resets once a frame arrives, and never undercuts a `Retry-After`.
  - `staleAfterMs`?: `number`. Reconnect when no frame (pings included) arrives for this long (default 60000, three server heartbeats). Catches a half-open socket that never errors; set it above your instance's heartbeat interval.
  - `chain`?: `"stacks"`
  - `types`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `notTypes`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `contractId`?: `StreamsFilterValue`
  - `sender`?: `StreamsFilterValue`
  - `recipient`?: `StreamsFilterValue`
  - `assetIdentifier`?: `string`
  - `filters`?: `StreamsFilterMap`. Labelled filter groups; see `StreamsEventsListParams.filters`.
  - `onEvent`: `(event: StreamsEvent) => Promise<void> \| void`. Called for each pushed event, in order. The resume cursor advances only after this resolves, so a handler that throws sees the same event again on reconnect (at-least-once). Key durable writes by `cursor`.
- `StreamsFilterMap`: A map of label → filter group.
- `StreamsFilterValue`: A single value or a list of values to match.
- `StreamsLabelledFilter`: One labelled filter group.
  - `types`?: `readonly ("stx_transfer" \| "stx_mint" \| "stx_burn" \| "stx_lock" \| "ft_transfer" \| "ft_mint" \| "ft_burn" \| "nft_transfer" \| "nft_mint" \| "nft_burn" \| "print")[]`
  - `contractId`?: `StreamsFilterValue`
  - `sender`?: `StreamsFilterValue`
  - `recipient`?: `StreamsFilterValue`
  - `assetIdentifier`?: `string`
- `StreamsReorg`
  - `detected_at`: `string`
  - `fork_point_height`: `number`
  - `orphaned_range`: `{ from: string; to: string }`
  - `new_canonical_tip`: `string`. First position of the new canonical chain at the fork, `fork:0` (INCLUSIVE). Not an exclusive resume token — resuming directly from it skips `fork:0`. The consumer rewinds to the foot of `fork_point_height` (`Cursor.atHeight`) to re-read the new run from `fork:0` inclusive.
- `StreamsReorgContext`: `cursor` is the rewind position the loop resumes from after `onReorg`: the foot of the fork point, or `null` for a fork at genesis.
  - `cursor`: `string \| null`
- `StreamsReorgsListEnvelope`: A list of reorgs and the cursor to resume from.
  - `reorgs`: `StreamsReorg[]`
  - `next_since`: `string \| null`
- `StreamsReorgsListParams`: A starting point and limit for listing reorgs.
  - `since`: `string`
  - `limit`?: `number`
- `StreamsSubscription`: Handle for a live subscription: call it to unsubscribe.
  - `done`: `Promise<void>`
- `StreamsTip`: The chain tip: latest block, Bitcoin anchor, and finality boundary.
  - `block_height`: `number`
  - `block_hash`: `string`
  - `burn_block_height`: `number`
  - `finalized_height`?: `number`. Highest Stacks block past the burn-confirmation finality boundary. Optional for back-compat; the API always sets it.
  - `lag_seconds`: `number`
  - `oldest_seekable_height`?: `number \| null`. Oldest height still seekable on the live API for the caller's tier (`tip - retention`). `null` = unlimited retention. Older reads must use the cold dumps lane. Optional for back-compat.
  - `oldest_cursor`?: `string \| null`. Oldest seekable cursor (`<oldest_seekable_height>:0`); `null` = unlimited.
- `StreamsWireEvent`: Any row `GET /v1/streams/events` can return.
- `StxBurnPayload`
  - `sender`: `string`
  - `amount`: `string`
- `StxLockPayload`
  - `locked_address`: `string`
  - `locked_amount`: `string`
  - `unlock_height`: `string`
- `StxMintPayload`
  - `recipient`: `string`
  - `amount`: `string`
- `StxTransferPayload`
  - `sender`: `string`
  - `recipient`: `string`
  - `amount`: `string`
  - `memo`?: `string`
- `VarSetPayload`
  - `contract_identifier`: `string`
  - `var_name`: `string`
  - `raw_value`: `string`
- `VmStreamsEvent`: A Streams `clock=vm` row.
- `VmStreamsEventOf`
  - `cursor`: `string`. Globally unique, monotonic position of this event (`<block>:<index>`). Use it as the primary key of your projection rows — replaying a batch then upserts cleanly. Don't synthesize your own id from `tx_id`/`event_index`.
  - `block_height`: `number`
  - `block_hash`: `string`
  - `burn_block_height`: `number`
  - `tx_id`: `string`
  - `tx_index`: `number`
  - `event_index`: `number`
  - `contract_id`: `string \| null`
  - `ts`: `string`
  - `finalized`?: `boolean`. True when this event's block is past the finality boundary (immutable). Optional for back-compat; the API always sets it on Streams responses.
  - `matched`?: `string[]`. Labels whose filter group this event satisfied, present only when the request used a labelled `filters` map. The SDK dispatches on it for you (`on.<label>`), so handlers rarely read it directly.
  - `chain`?: `"stacks"`. Which chain this event came from. Every Stacks Streams response sets this to `"stacks"`; optional here for back-compat with fixtures/tests that pre-date the field — the same reasoning as `finalized?`. Cursors stay per-chain but chain-agnostic in shape, so this is the only signal a mixed-chain caller can discriminate on.
  - `event_type`: `T`
  - `payload`: `P`

## Notes for agents

- Methods live on 'sl.streams' of 'const sl = new SecondLayer()'. Signatures and overloads are generated from the SDK source.
- Prefer a checkpointed consume loop over hand-rolled paging; save the cursor in the same transaction as your rows.
- A method's REST twin is linked on its entry; the Streams API page serves the same data over HTTP.

## Setup

Ensure setup once, skipping any step already done:
- Skill: `bunx skills add ryanwaits/secondlayer --skill secondlayer -y`
- CLI: `bun add -g @secondlayer/cli`
- Instance: `secondlayer init` writes `INSTANCE_TOKEN`. Loopback `/v1` reads need no key.
- Hosted/archive: export `SECONDLAYER_API_KEY` (`sk-sl_*`) or buy credits with `secondlayer credits buy` (`balance`, `refill`), then `secondlayer whoami`
