# @secondlayer/sdk

TypeScript SDK for the Secondlayer API.

## Install

```bash
bun add @secondlayer/sdk
```

## Quick Start

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

const sl = new SecondLayer({
  apiKey: process.env.INSTANCE_TOKEN, // from secondlayer init; read from the env when omitted
  // default baseUrl: http://127.0.0.1:3800  (or SECONDLAYER_API_URL)
});
```

Auth, once: `sl.index`, `sl.streams`, `sl.subgraphs.rows`, and the typed
`subscribe` read `/v1`, which is open on loopback and needs `INSTANCE_TOKEN`
once the API is bound beyond it. Everything under `sl.subgraphs.*` and `sl.webhooks.*` calls
`/api`, which needs `INSTANCE_TOKEN` as soon as one is configured, loopback
included, and `secondlayer init` always configures one. Every client, including
`createStreamsClient`, reads `INSTANCE_TOKEN` from the env when `apiKey` is
omitted; pass `apiKey: ""` to force a keyless call. Public archive dumps
(`sl.streams.dumps`) need no instance key.

## Mental model

Everything is indexing — the question is how much of the indexer you run:

- `sl.index` — decoded rows we keep indexed: query FT/NFT transfers, all event
  types (`events`), and `contractCalls` — or build your own app index on them
  with the checkpointed `consume()` loop (automatic reorg rewind), `walk()`
  sweeps, and resumable cursors on every page.
- `sl.subgraphs` — deploy your own indexer (one `defineSubgraph()` file via the
  CLI), then read the tables on this instance.
- `sl.streams` — the raw ordered event firehose Index itself is built on, with
  a checkpointed `consume()` and dumps `replay()` for building from zero.
- `sl.contracts` — find deployed contracts by trait (SIP-009/010/013).

## Streams

Typed HTTP client for the raw event firehose. `/v1` reads need no key on loopback.

```typescript
const tip = await sl.streams.tip();
// tip.finalized_height — highest immutable (past Bitcoin-anchored finality) block
const page = await sl.streams.events.list({
  types: ["ft_transfer"],
  contractId: "SP...sbtc-token",
  sender: "SP...",       // exact payload sender (events that have one)
  recipient: "SP...",    // exact payload recipient
  assetIdentifier: "SP...token::asset", // exact FT/NFT asset id
  limit: 10,
});
// each event carries `finalized: boolean`
console.log({ tip, firstCursor: page.events[0]?.cursor });
```

`createStreamsClient` remains available for focused Streams-only consumers:

```typescript
import { createStreamsClient } from "@secondlayer/sdk";

const streams = createStreamsClient({
  // apiKey: process.env.INSTANCE_TOKEN, // read from the env when omitted
  // verify: true,                 // verify ed25519 X-Signature on every read
  //                               // (auto-fetches the public key; { publicKey } pins a PEM)
  // dumpsBaseUrl: process.env.SL_STREAMS_DUMPS_URL, // required to use client.dumps
});
```

Verified responses: every Streams read is signed (ed25519 `X-Signature` +
`X-Signature-KeyId`). Pass `verify: true` to check it on every read (or
`{ publicKey }` to pin a PEM); a missing/bad signature throws
`StreamsSignatureError`. The public key is at
`GET /public/streams/signing-key`. `verify` and `verifyDumpsManifest` are
accepted by `new SecondLayer({...})` too and reach `sl.streams`.

What `verify: true` proves depends on where the key came from. Over https, or
on loopback, the key is fetched once from the instance and cached; a 5xx on
that fetch surfaces as a retryable `StreamsServerError` and the next read
fetches again. Over plain http to any other host the key travels the same
unprotected path as the data, so the client refuses `verify: true` there and
asks for `verify: { publicKey }` instead. The tradeoff: you copy the PEM once,
and a server-side rotation then fails closed until you update it.

Convenience reads:

```typescript
await sl.streams.canonical(182431);
await sl.streams.events.byTxId("0x...");
await sl.streams.blocks.events(182431);
await sl.streams.blocks.events("0xindex-block-hash");
await sl.streams.reorgs.list({ since: "2026-05-03T00:00:00.000Z" });
```

Checkpointed consumer.

Use `client.events.consume` for indexers and ETL jobs. Write your database rows
inside `onBatch`, then return the cursor you committed. It exits when
`maxPages`, `maxEmptyPolls`, or `signal` stops it.

```typescript
await streams.events.consume({
  types: ["ft_transfer"],
  batchSize: 100,
  maxPages: 1,
  onBatch: async (events, envelope) => {
    for (const event of events) {
      console.log(event.cursor, event.tx_id);
    }
    return envelope.next_cursor;
  },
});
```

Live stream.

Use `client.events.stream` for live processors and watch-style apps. It follows
the tip indefinitely. Stop it with an `AbortSignal`.

```typescript
const abort = new AbortController();
process.once("SIGINT", () => abort.abort());

for await (const event of streams.events.stream({
  types: ["ft_transfer"],
  batchSize: 100,
  signal: abort.signal,
})) {
  console.log(event.cursor, event.tx_id);
}
```

Real-time push (`events.subscribe`).

Use `client.events.subscribe` for callback-style live delivery: it pushes each
event to `onEvent` as it lands. It's fetch-based (so it carries the Bearer key)
and works in browsers and Node 18+. It reconnects from the last handled cursor
after a dropped socket, a clean server close, or `staleAfterMs` (60 s) with no
frame, backing off from `reconnectDelayMs` (1 s) up to 30 s with jitter and
honoring `Retry-After`. Errors a retry cannot fix (401, other 4xx, a bad
signature) end the loop: `onError` sees them and the handle's `done` rejects.

```typescript
const subscription = streams.events.subscribe({
  types: ["ft_transfer"],          // notTypes / contractId / sender / recipient / assetIdentifier also filter
  // fromCursor: lastCursor,       // resume strictly after this cursor; omit to tail from the tip
  onEvent: async (event) => {
    console.log(event.cursor, event.tx_id);
  },
  onError: (err) => console.error("subscribe", err),
});

await subscription.done;          // rejects when the loop stopped on an unfixable error
// later
subscription();                   // unsubscribe, or pass `signal` and abort it
```

The cursor advances only after `onEvent` resolves, so a handler that throws
sees the same event again on reconnect (at-least-once). Key durable writes by
`cursor`. `subscribe` is not reorg-aware: an event delivered then orphaned is
never retracted, so anything writing durable state belongs in `consume()` with
`onReorg` or a sink.

Each pushed frame is `{ event, sig, key_id }`. When the client was created with
`verify` (or `{ publicKey }`), the per-frame ed25519 signature is checked before
`onEvent` runs; a bad/missing signature ends the subscription with
`StreamsSignatureError`.

Bulk parquet dumps.

Finalized history is published as public parquet files. Set `dumpsBaseUrl`
(or `SL_STREAMS_DUMPS_URL`) — no API key needed for dumps. The SDK does **not**
decode parquet; `download` hands you sha256-verified bytes to process with your
own tooling.

The bulk **manifest is ed25519-signed**, and the SDK verifies that signature
before it trusts any per-file sha256 listed in it. The `verifyDumpsManifest`
option **defaults to `true`** — `dumps.list()` and `events.replay()` enforce it,
so you don't trust the file hashes unless the manifest itself verifies. Opt out
with `verifyDumpsManifest: false`.

```typescript
const streams = createStreamsClient({
  dumpsBaseUrl: process.env.SL_STREAMS_DUMPS_URL!,
});

const manifest = await streams.dumps.list();       // parse the manifest
for (const file of manifest.files) {
  const bytes = await streams.dumps.download(file); // fetch + verify sha256
  await myParquetReader(bytes);
}
```

`download` hashes the body as it streams in and retries a dropped socket or
5xx with the same policy `consume()` uses for pages, so a multi-hundred-MB
file costs its own size in memory, not double, and one flaky fetch does not
end a replay. The bytes are still returned whole; write them out per file.

```typescript
for (const file of (await streams.dumps.list()).files) {
  await Bun.write(file.path.split("/").pop()!, await streams.dumps.download(file));
}
```

Backfill then tail (`events.replay`).

Backfills from bulk dumps, then tails live from the manifest's
`latest_finalized_cursor`, with no gap or dupe at the seam. `onDumpFile` hands you
each finalized file; `onBatch` receives live events after the seam.

Dump delivery is file-granular and at-least-once: the file straddling `from`
arrives whole, with `ctx.from` so you can skip rows at or below the checkpoint
(or key rows by `cursor` and let the upsert dedupe). Files ending at or below
`from` are not delivered.

```typescript
await streams.events.replay({
  from: lastCheckpoint,
  async onDumpFile(file, { from }) {
    const bytes = await streams.dumps.download(file);
    await ingestParquet(bytes, { skipAtOrBelow: from }); // your tooling
  },
  async onBatch(events, envelope) {
    for (const event of events) await handle(event);
    return envelope.next_cursor;
  },
});
```

Archive.

Signed canonical partitions (`blocks` / `transactions` / `events`), distinct
from Streams dumps. `latest` follows `latest.json` and verifies the ed25519
manifest (default on). `quote` and `fetch` need an account key (`sk-sl_*`)
against `api.secondlayer.tools`, not the instance `baseUrl`. `download`
returns sha256-verified bytes; it does not decode parquet. Verify, repair,
and bootstrap remain CLI.

```typescript
const sl = new SecondLayer({
  accountKey: process.env.SECONDLAYER_API_KEY,
});
// SECONDLAYER_API_KEY is the hosted account key (sk-sl_*). It is not the instance token.

const ref = await sl.archive.latest();
const partitions = sl.archive.partitions(ref, { dataset: "blocks" });
const quote = await sl.archive.quote({
  paths: partitions.map((p) => p.path),
  flow: "bootstrap",
});
const fetched = await sl.archive.fetch({
  paths: partitions.map((p) => p.path),
  flow: "bootstrap",
});
for (const item of fetched.urls) {
  const partition = partitions.find((p) => p.path === item.path)!;
  const bytes = await sl.archive.download(partition, { url: item.url });
}
```

Decoder helper.

```typescript
import { decodeFtTransfer, isFtTransfer } from "@secondlayer/sdk";

for await (const event of streams.events.stream({ types: ["ft_transfer"] })) {
  if (!isFtTransfer(event)) continue;
  const transfer = decodeFtTransfer(event);
  console.log(transfer.decoded_payload);
  break;
}
```

Helper convention: each event helper is a pure function with no shared state.
Use `is<EventName>(event)` as the type guard and `decode<EventName>(event)` as
the decoder. Decoders throw when the event type or payload is malformed. Add new
helpers beside `src/streams/ft-transfer.ts` and export them through
`src/streams/index.ts`.

## Index

Decoded transfer events.

```typescript
const ftPage = await sl.index.ftTransfers.list({
  contractId: "SP...sbtc-token",
  sender: "SP...",
  limit: 100,
});

const nftPage = await sl.index.nftTransfers.list({
  assetIdentifier: "SP...collection::token",
  recipient: "SP...",
});
```

Backfill with SDK walkers:

```typescript
for await (const transfer of sl.index.ftTransfers.walk({
  fromHeight: 0,
  batchSize: 500,
})) {
  console.log(transfer.cursor, transfer.amount);
}
```

Checkpointed consumer — build your app index.

`index.events.consume` / `index.contractCalls.consume` is the same contract as
the Streams consumer: write your rows inside `onBatch`, return the cursor you
committed, and reorgs rewind automatically to the fork point. Only forks at or
below your checkpoint roll back (a fork above it has nothing to undo), and a
rewind deeper than `maxRollbackDepth` (default 1000 blocks) is refused before
anything is deleted. `finalizedOnly` holds delivery to rows at or below
`tip.finalized_height` (Index rows carry no per-event flag). Walkthrough:
[docs/index](https://www.secondlayer.tools/docs/index#build-your-index-on-it).

```typescript
await sl.index.contractCalls.consume({
  contractId: "SP...marketplace-v4",
  functionName: "purchase-asset",
  fromCursor: await loadCheckpoint(), // null on first run
  fromHeight: 0,                      // first run: backfill from genesis
  onBatch: async (calls, envelope, ctx) => {
    await commitRowsAndCheckpoint(calls, ctx.cursor);
    return ctx.cursor;
  },
  onReorg: async (reorg) => {
    await rollbackAboveHeight(reorg.fork_point_height);
  },
});
```

## Transaction-inclusion proofs

Verify — **without trusting Secondlayer** — that a transaction is included in a
Stacks (Nakamoto) block, and that ≥70% of the reward cycle's signer weight
attested to that block. `verifyTransactionProof` recomputes everything
client-side and trusts nothing the API returned.

> Verification uses Node's crypto via `@secondlayer/shared` — Node/server-side use.

```typescript
import { verifyTransactionProof, fetchRewardSet } from "@secondlayer/sdk";

const proof = await fetch(
  `http://127.0.0.1:3800/v1/index/transactions/${txid}/proof`,
).then((r) => r.json());

const result = verifyTransactionProof(proof); // anchored + consensus (embedded set)
// result.ok, result.level === "consensus", result.signerWeightBps

// Fully trustless — resolve the reward set from your own node:
const rewardSet = await fetchRewardSet({
  nodeUrl: "https://your-stacks-node:20443",
  cycle: proof.consensus.reward_cycle,
});
const trustless = verifyTransactionProof(proof, { rewardSet }); // rewardSetSource: "provided"
```

Two trust levels:

- **Anchored** — recompute the txid from `raw_tx`, fold `tx_merkle_path` up to the
  header's `tx_merkle_root`, and recompute `block_hash` + `index_block_hash` from
  `raw_header`. The tx is in a header any node can corroborate.
- **Consensus** — additionally recover the header's signer signatures and confirm
  ≥70% of the reward cycle's signer weight signed the block. Fully trustless when
  you pass a `rewardSet` resolved yourself via `fetchRewardSet`
  (`rewardSetSource: "provided"`); otherwise it uses the proof's embedded set
  (`rewardSetSource: "embedded"`).

```typescript
verifyTransactionProof(
  proof: TransactionProof,
  opts?: { rewardSet?: RewardSet },
): TransactionProofVerifyResult;

fetchRewardSet(opts: {
  nodeUrl: string;            // your own stacks-node
  cycle: number;             // reward cycle — proof.consensus.reward_cycle
  fetchImpl?: typeof fetch;
}): Promise<RewardSet | null>; // reads /v3/stacker_set/{cycle}
```

`verifyTransactionProof` returns a `TransactionProofVerifyResult`:

```typescript
{
  level: "anchored" | "consensus";
  txidMatches: boolean;
  includedInHeader: boolean;
  headerSelfConsistent: boolean;
  signerWeightBps?: number;   // consensus only
  thresholdMet?: boolean;     // consensus only — ≥70% (7000 bps)
  rewardSetSource?: "provided" | "embedded";
  ok: boolean;
  errors: string[];
}
```

Exported types: `TransactionProof`, `TransactionProofVerifyResult`, `RewardSet`.

## Subgraphs

Deploy and query app-specific tables.

Subgraphs and webhooks live on the instance API alongside Streams and Index. Everything here except `rows` and the typed `subscribe` calls `/api`, which needs `INSTANCE_TOKEN` once one is configured (init always configures one), loopback included.

```typescript
// List
const { data } = await sl.subgraphs.list();

// Get
const subgraph = await sl.subgraphs.status("my-subgraph");

// Open read (/v1): keyless on loopback; needs the token once the API is bound beyond it
const { rows, next_cursor, tip } = await sl.subgraphs.rows("my-subgraph", "transfers", {
  order: "desc",
  limit: 50,
  // cursor: next_cursor — pass back to resume
});

// Authed control-plane query (/api)
const page = await sl.subgraphs.queryTable("my-subgraph", "transfers", {
  sort: "block_height",
  order: "desc",
  limit: 50,
});

const { count } = await sl.subgraphs.queryTableCount(
  "my-subgraph",
  "transfers",
);

const spec = await sl.subgraphs.openapi("my-subgraph");
const source = await sl.subgraphs.getSource("my-subgraph");
const gaps = await sl.subgraphs.gaps("my-subgraph");

// Deploy
const result = await sl.subgraphs.deploy({ name, sources, schema, handlerCode });
```

Filters and `orderBy` on the typed client name the system columns
`_id`, `_blockHeight`, `_txId`, `_createdAt` (the canonical row shape). The
unprefixed `id` / `blockHeight` / `txId` / `createdAt` shorthands mean the
system column only when your table declares no column of that name; a declared
`id` column is always your `id`.

Stream rows live with the typed client — each table exposes `subscribe`
alongside `findMany`/`count`:

```typescript
const subgraph = sl.subgraphs.typed(myDefinition); // { transfers, ... }

const unsubscribe = subgraph.transfers.subscribe(
  (row) => console.log(row),
  {
    where: { amount: { gte: "1000000" } }, // optional row filter
    since: 180000,                          // optional: replay from this block_height, then tail
    onError: (err) => console.error(err),
  },
);

// later
unsubscribe();
```

`subscribe` reads `/v1` like `rows`, so it is keyless on loopback; it is a
fetch-based SSE stream, so it carries the client's bearer token once the API is
bound past loopback, in browsers and Node 18+. Frames are unsigned rows in the
wire shape (`_block_height`, bigint columns as strings). `since: <block_height>`
replays matching rows from that height, then tails the live edge; omit it to
tail only. A dropped connection reconnects from the last delivered row's
`_block_height`, so rows at that height can arrive twice: key durable writes
by `_id`.

## Webhooks

Deprecated aliases (`sl.subscriptions`, `Subscriptions`) keep working for one release cycle.

Signed HTTP POSTs. Webhooks are polymorphic — pick one kind:

- **subgraph** — fires on rows written to a deployed subgraph table.
- **chain** — fires on raw chain events with no subgraph. Forward-looking: it
  starts at the chain tip and never backfills. The turnkey "webhook on a
  contract / event / function / trait".

```typescript
// List / get
const { data } = await sl.webhooks.list();
const hook = await sl.webhooks.get(id);

// Create a SUBGRAPH webhook — sink a subgraph table to a signed endpoint.
// `signingSecret` is returned ONCE; store it in the receiver's env.
const { webhook, signingSecret } = await sl.webhooks.create({
  name: "whale-alerts",
  subgraphName: "transfers",
  tableName: "events",
  url: "https://example.com/hooks/transfers",
  format: "standard-webhooks", // or inngest | trigger | cloudflare | cloudevents | raw
});
```

### Chain webhooks

Pass `triggers` instead of `subgraphName`/`tableName`. The `trigger.*` builders
are optional sugar — you can also pass raw objects (e.g.
`{ type: "contract_call", contractId: "SP....amm", functionName: "swap-*" }`).
All string fields accept `*` wildcards; `trait` scopes to contracts conforming
to a SIP/trait (e.g. `"sip-010"`); amounts are non-negative integer strings
(uint128-safe) or numbers.

```typescript
import { SecondLayer, trigger } from "@secondlayer/sdk";

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

const { webhook, signingSecret } = await sl.webhooks.create({
  name: "amm-swaps",
  url: "https://my-app.com/webhook",
  triggers: [
    trigger.contractCall({ contractId: "SP....amm", functionName: "swap-*" }),
    trigger.ftTransfer({ trait: "sip-010", minAmount: "1000000" }),
  ],
});
```

One builder per event type:
`trigger.stxTransfer` / `stxMint` / `stxBurn` / `stxLock`,
`trigger.ftTransfer` / `ftMint` / `ftBurn`,
`trigger.nftTransfer` / `nftMint` / `nftBurn`,
`trigger.contractCall`, `trigger.contractDeploy`, `trigger.printEvent`.

Delivery envelope (chain subs only): each apply is `chain.{type}.apply` with
body `{ action: "apply", block_hash, block_height, tx_id, canonical, trigger,
event }`. On reorg you get `chain.reorg.rollback` with `{ action: "rollback",
fork_point_height, orphaned: [{ tx_id, event }] }`. Delivery is at-least-once: a
tx surviving a reorg re-delivers an apply under its new `block_hash`, so key
consumer state on `(tx_id, block_hash)`. Per-webhook HMAC signing (Standard
Webhooks) is unchanged for both kinds.

```typescript
// Lifecycle (both kinds)
await sl.webhooks.update(id, { filter: { amount: { gte: "1000000" } } });
await sl.webhooks.pause(id);
await sl.webhooks.resume(id);
await sl.webhooks.rotateSecret(id); // returns new signing secret once
const { data: deliveries } = await sl.webhooks.deliveries(id);

// Replay historical block range
await sl.webhooks.replay(id, { fromBlock: 180000, toBlock: 181000 });

// Dead-letter inspection + requeue
const { data: dead } = await sl.webhooks.dead(id);
await sl.webhooks.requeue(id, outboxId);
```

### Verifying deliveries

Every delivery — any kind, any `format` — also carries a universal authenticity
signature you can verify with one published key, no per-webhook secret. The
headers are `webhook-id`, `x-secondlayer-signature`, and
`x-secondlayer-signature-keyid`; the signed content is `` `${webhook-id}.${rawBody}` ``
(ed25519). Fetch the public key from `GET /public/streams/signing-key`.

```typescript
import { verifySecondlayerSignature } from "@secondlayer/sdk";

app.post("/webhook", async (c) => {
  const raw = await c.req.text(); // raw body — never re-stringify the parsed JSON
  if (!verifySecondlayerSignature(raw, c.req.raw.headers, SECONDLAYER_PUBLIC_KEY)) {
    return c.text("Invalid signature", 401);
  }
  // ... trusted ...
  return c.body(null, 204);
});
```

```typescript
verifySecondlayerSignature(
  rawBody: string,
  headers: WebhookHeaderInput,   // plain object, Fetch `Headers`, or a lookup fn
  publicKeyPem: string,
): boolean;
```

Prefer the per-webhook HMAC (Standard Webhooks) secret instead? Use
`verifyWebhookSignature(rawBody, headers, secret)` — raw body first.

## Error Handling

```typescript
import { ApiError } from "@secondlayer/sdk";

try {
  await sl.subgraphs.status("nonexistent");
} catch (err) {
  if (err instanceof ApiError) {
    console.log(err.status);  // 404
    console.log(err.code);    // "NOT_FOUND" (from API's {error, code} envelope, if present)
    console.log(err.message); // "Subgraph not found"
    console.log(err.body);    // full parsed envelope
  }
}
```

Every failure status keeps the server's envelope: a 401 with
`code: "TOKEN_REVOKED"` reads differently from a missing token, a 503 carries
its reason and `retryAfterSeconds`, and Streams 4xx errors carry `code` the
same way Index errors do (`err.code === "CURSOR_INVALID"`).

Codes you will branch on:

- `code: "OPERATION_IN_PROGRESS"` (409) from `subgraphs.deploy`, `reindex`, or
  `backfill`: a reindex or backfill is already running for that subgraph. Poll
  `subgraphs.operations(name)` and retry when it finishes. There is no
  dedicated error class for this; it is an `ApiError` with `status === 409`.
- `code: "REQUEST_TIMEOUT"` (status 0): the request outlived `requestTimeoutMs`.
  Retryable.

`sl.context()` never throws. Each field is `{ value, error? }`, so a `null`
says why: `error.status === 0` means the API was unreachable,
`error.code === "UNAUTHORIZED"` means the token was rejected, and no `error`
at all means the read succeeded and found nothing.
