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/sdkimport { 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, andprintSchema(contractId)(empirical per-topic print payload schemas; resolvesnullwhen 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/v1reads (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) withon.*(from@secondlayer/stacks, for subgraph sources in a handler config): they configure different things.
verifyWebhookSignature(rawBody, headers, secret, toleranceSeconds = 300)— the per-subscription HMAC;toleranceSecondsbounds replay drift.verifySecondlayerSignature(rawBody, headers, publicKeyPem): boolean— the universal ed25519 signature. Fetch the key fromGET /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 aTransactionProofVerifyResult:{ 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, returningRewardSet | 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;
onReorgfires so you roll back committed rows fromfork_point_heightup. fromHeight: 0backfills from genesis (hosted: paid plan or pay-as-you-go credits; free/keyless reads cover the last 24h).finalizedOnlyholds delivery to rows at or belowtip.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.
verify | Behavior |
|---|---|
| default (lenient) | Verify signed responses; pass unsigned through; throw on invalid |
true (strict) | A missing signature throws too |
{ publicKey } | Pin a known PEM |
false | Disable 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/v1read. Anonymous works for public subgraphs; pass your key for private ones.sl.subgraphs.publish(name)returns{ name, visibility: "public", url }, or throws409 PUBLIC_NAME_TAKEN.sl.subgraphs.unpublish(name)makes it private again.sl.subgraphs.typed(def)(orgetSubgraph(def, opts)) turns adefineSubgraph()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 globalEventSource, 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/maxaccept numeric columns only (enforced at compile time) and return lossless strings.count/countDistinctreturn numbers.
Full parameter set: REST API aggregates.