Build / SDK

SDK

@secondlayer/sdk is a typed TypeScript client for every Secondlayer surface, plus webhook verification, proofs, and checkpointed consumers.

Every export, with signatures: SDK reference.

bun add @secondlayer/sdk
import { SecondLayer } from "@secondlayer/sdk";

const sl = new SecondLayer({
  apiKey: process.env.SL_API_KEY, // or a session token
  baseUrl: "https://api.secondlayer.tools", // default
});
  • sl.streams: raw, ordered chain events (cursor-paginated, replayable).
  • sl.index: decoded rows: FT/NFT transfers, all event types, contract calls, and printSchema(contractId) (empirical per-topic print payload schemas; resolves null when the contract has no print history).
  • sl.contracts: find deployed contracts by trait (SIP-009/010/013).
  • sl.subgraphs: your app-specific tables, plus open /v1 reads (rows) and visibility (publish/unpublish).
  • sl.subscriptions: create and manage webhook subscriptions (subgraph rows or raw chain events).
const tip = await sl.streams.tip();
const page = await sl.streams.events.list({
  types: ["ft_transfer"],
  contractId: "SP000000000000000000002Q6VF78.sbtc-token",
  limit: 10,
});

sl.subscriptions.create({ name, url, triggers }) opens a webhook on raw chain events, no subgraph. Build triggers with the trigger.* factories: contractCall, contractDeploy, printEvent, the stx*/ft*/nft* transfer/mint/burn variants, and the sBTC peg triggers. Full catalog and envelope: Subscriptions.

sl.subscriptions.replay(id, { fromBlock, toBlock }): Promise<{ replayId, enqueuedCount, scannedCount }> re-delivers historical matches — see Replay.

Don't confuse trigger.* (from @secondlayer/sdk, for chain subscriptions) with on.* (from @secondlayer/stacks, for subgraph sources in a handler config): they configure different things.

  • verifyWebhookSignature(rawBody, headers, secret, toleranceSeconds = 300) — the per-subscription HMAC; toleranceSeconds bounds replay drift.
  • verifySecondlayerSignature(rawBody, headers, publicKeyPem): boolean — the universal ed25519 signature. Fetch the key from GET /public/streams/signing-key, which returns { algorithm, key_id, public_key_pem }.

Both are canonical in Subscriptions.

Prove client-side that a transaction is in a Stacks (Nakamoto) block and that ≥70% of the reward cycle's signer weight attested to it.

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

const proof = await fetch(
  `https://api.secondlayer.tools/v1/index/transactions/${txid}/proof`,
).then((r) => r.json());

const result = verifyTransactionProof(proof); // reward set embedded in the proof

// 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 });
  • verifyTransactionProof(proof, opts?: { rewardSet?: RewardSet }) returns a TransactionProofVerifyResult: { level: "anchored" | "consensus", txidMatches, includedInHeader, headerSelfConsistent, signerWeightBps?, thresholdMet?, rewardSetSource?, ok, errors }.
  • fetchRewardSet({ nodeUrl, cycle, fetchImpl? }) resolves the reward set from /v3/stacker_set/{cycle} on a node you trust, returning RewardSet | null.
  • Exported types: TransactionProof, TransactionProofVerifyResult, RewardSet.

Verification uses Node's crypto — server-side only. See Verification for trust levels and the proof endpoint.

consume polls and commits a cursor so you never miss or double-process. Return the committed cursor from onBatch. Available on sl.streams.events, sl.index.events (takes eventType), and sl.index.contractCalls.

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 rollbackFromHeight(reorg.fork_point_height); // inclusive of the fork block
  },
});
  • Reorgs rewind the cursor to the fork point automatically; onReorg fires so you roll back committed rows from fork_point_height up.
  • fromHeight: 0 backfills from genesis (hosted: paid plan or pay-as-you-go credits; free/keyless reads cover the last 24h).
  • finalizedOnly holds delivery to rows at or below tip.finalized_height.

A sink removes the checkpoint, transaction, and rollback code above entirely — it owns all three, and your handler only inserts rows:

import { kyselySink } from "@secondlayer/sdk/sinks/kysely";

await sl.index.contractCalls.consume({
  contractId: "SP...marketplace-v4",
  sink: kyselySink(db, { id: "sales", tables: ["sales"], height: "height" }),
  onBatch: async (calls, _envelope, ctx) => {
    for (const call of calls) await ctx.tx.insertInto("sales").values(...).execute();
  },
});

See Sinks, Index, Build your index on it, and the runnable sales-index example.

subscribe({ fromCursor?, types?, notTypes?, contractId?, sender?, recipient?, assetIdentifier?, signal?, onEvent, onError? }): () => void opens the Streams SSE firehose. Fetch-based (browser and Node 18+), auto-reconnects from the last delivered cursor until you abort via signal, and returns an unsubscribe function.

const controller = new AbortController();

const unsubscribe = sl.streams.events.subscribe({
  types: ["ft_transfer"],
  contractId: "SP000000000000000000002Q6VF78.sbtc-token",
  signal: controller.signal,
  onEvent: (event) => {},
  onError: (err) => {}, // optional — reconnection is automatic
});

unsubscribe();

Signature verification

createStreamsClient({ apiKey }) verifies both REST reads (X-Signature) and SSE frames. The key is fetched once from /public/streams/signing-key; a rotated X-Signature-KeyId triggers a single refresh.

verifyBehavior
default (lenient)Verify signed responses; pass unsigned through; throw on invalid
true (strict)A missing signature throws too
{ publicKey }Pin a known PEM
falseDisable verification

verify lives on createStreamsClient

new SecondLayer() does not accept verify; use createStreamsClient when you need it.

const { rows, next_cursor, tip } = await sl.subgraphs.rows("sbtc-flows", "transfers", {
  order: "desc",
  limit: 25,
});
  • sl.subgraphs.rows(name, table, opts) returns { rows, next_cursor, tip } from the open /v1 read. Anonymous works for public subgraphs; pass your key for private ones.
  • sl.subgraphs.publish(name) returns { name, visibility: "public", url }, or throws 409 PUBLIC_NAME_TAKEN. sl.subgraphs.unpublish(name) makes it private again.
  • sl.subgraphs.typed(def) (or getSubgraph(def, opts)) turns a defineSubgraph() definition into typed table clients.
  • subgraph.<table>.subscribe(onRow, { where?, since?, onError? }) streams new rows over SSE and returns an unsubscribe function; since: <block_height> replays from that height, then tails live. See Subgraphs.

Unlike the fetch-based Streams subscribe, the row stream uses the global EventSource, so it requires the browser or Node ≥ 22 (it throws otherwise). Its frames are bare rows and aren't signed.

subgraph.<table>.aggregate(spec) runs scalar aggregates over an optional where filter. The result shape is inferred from the spec: only the keys you ask for appear, no as const needed.

  • sum/min/max accept numeric columns only (enforced at compile time) and return lossless strings.
  • count/countDistinct return numbers.

Full parameter set: REST API aggregates.