SDK
@secondlayer/sdk is a typed TypeScript client for every Secondlayer surface: Streams, Index, Subgraphs, Subscriptions, plus webhook verification and checkpointed consumers for building your own index.
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 creates webhook subscriptions. To subscribe directly to raw chain events with no subgraph, pass a triggers array built with the trigger.* factories.
import { trigger } from "@secondlayer/sdk";
await sl.subscriptions.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" }),
],
});Builders cover every event type (contractCall, contractDeploy, printEvent, and the stx*/ft*/nft* transfer/mint/burn variants). See Subscriptions for the full trigger catalog and delivery envelope.
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.
Replay historical deliveries
sl.subscriptions.replay(id, { fromBlock, toBlock }) re-delivers a chain subscription's historical matches over a block range (capped at 100k blocks). It's idempotent (replaying the same range is a no-op) and doesn't move the live cursor, so live delivery keeps flowing.
const { replayId, enqueuedCount, scannedCount } = await sl.subscriptions.replay(
subscriptionId,
{ fromBlock: 8000000, toBlock: 8050000 },
);replay(id, { fromBlock, toBlock }): Promise<{ replayId, enqueuedCount, scannedCount }>.
Subscription deliveries are signed. Verify the signature before trusting a payload: rawBody comes first, then the headers.
import { verifyWebhookSignature } from "@secondlayer/sdk";
const valid = verifyWebhookSignature(rawBody, req.headers, secret);
if (!valid) return new Response("bad signature", { status: 401 });verifyWebhookSignature(rawBody, headers, secret, toleranceSeconds = 300): the optional toleranceSeconds bounds replay drift.
Universal webhook signature
Every webhook delivery also carries ed25519 platform headers: webhook-id, x-secondlayer-signature, and x-secondlayer-signature-keyid. The signed content is `${webhook-id}.${rawBody}`. Verify it with verifySecondlayerSignature, fetching the public key from GET /public/streams/signing-key (returns { algorithm, key_id, public_key_pem }).
import { verifySecondlayerSignature } from "@secondlayer/sdk";
const { public_key_pem } = await fetch(
"https://api.secondlayer.tools/public/streams/signing-key",
).then((r) => r.json());
const valid = verifySecondlayerSignature(rawBody, req.headers, public_key_pem);
if (!valid) return new Response("bad signature", { status: 401 });verifySecondlayerSignature(rawBody: string, headers, publicKeyPem: string): boolean.
Verify, without trusting Secondlayer, that a transaction is in a Stacks (Nakamoto) block and that ≥70% of the reward cycle's signer weight attested to it. verifyTransactionProof recomputes everything client-side; fetchRewardSet pulls the reward set from your own node for fully-trustless verification.
import { verifyTransactionProof, fetchRewardSet } from "@secondlayer/sdk";
const proof = await fetch(
`https://api.secondlayer.tools/v1/index/transactions/${txid}/proof`,
).then((r) => r.json());
// Anchored + consensus using the reward set embedded in the proof:
const result = verifyTransactionProof(proof);
// result.ok === true, result.level === "consensus", result.signerWeightBps ~ 7000+
// 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 });
// trustless.rewardSetSource === "provided"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, so it's Node / server-side only. See Verification for trust levels and the proof endpoint.
For indexers and ETL jobs, consume polls and commits a cursor so you never miss or double-process events: return the cursor you committed inside onBatch. It works on raw Streams events and decoded Index rows.
// Raw events from Streams
await sl.streams.events.consume({
types: ["ft_transfer"],
onBatch: async (events) => {
// write your rows here, then return the last committed cursor
return events.at(-1)?.cursor;
},
});On Index it works over decoded events and contract calls:
- Reorgs rewind the cursor to the fork point automatically:
onReorgfires so you roll back committed rows fromfork_point_heightup (inclusive of the fork block). 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.
// Decoded contract calls from Index
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
},
});sl.index.events.consume(...) is the same for decoded events (takes eventType). See Index, Build your index on it and the runnable sales-index example.
sl.streams.events.subscribe(...) opens the Streams SSE firehose and calls onEvent for each event as it lands. It's fetch-based (carries your Bearer key, works in 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) => {
// handle each event
},
onError: (err) => {
// optional — reconnection is automatic
},
});
// later
unsubscribe();subscribe({ fromCursor?, types?, notTypes?, contractId?, sender?, recipient?, assetIdentifier?, signal?, onEvent, onError? }): () => void.
Signature verification
A standalone streams client (createStreamsClient({ apiKey })) verifies both REST reads (X-Signature) and SSE frames by default. The default is lenient: signed responses are verified, unsigned ones (a self-hosted instance with no key) pass through, and an invalid signature always throws.
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 |
The key is fetched once from /public/streams/signing-key (a rotated X-Signature-KeyId triggers a single refresh).
verify lives on createStreamsClient
new SecondLayer() does not accept verify; use createStreamsClient when you need it.
sl.subgraphs.rows(name, table, opts) is the cursor-paginated open /v1 read, returning { rows, next_cursor, tip }. Anonymous works for public subgraphs; pass your key for private ones.
const { rows, next_cursor, tip } = await sl.subgraphs.rows(
"sbtc-flows",
"transfers",
{ order: "desc", limit: 25 },
);
// resume from where you left off
const next = await sl.subgraphs.rows("sbtc-flows", "transfers", {
cursor: next_cursor,
});sl.subgraphs.publish(name) flips a subgraph public, returning { name, visibility: "public", url }, or throws 409 PUBLIC_NAME_TAKEN if the global public name is already claimed. sl.subgraphs.unpublish(name) makes it private again.
Table clients come from sl.subgraphs.typed(def) (or getSubgraph(def, opts)) with a defineSubgraph() definition:
import sbtcFlows from "./subgraphs/sbtc-flows";
const subgraph = sl.subgraphs.typed(sbtcFlows); // or getSubgraph(sbtcFlows, { apiKey })subgraph.<table>.subscribe(onRow, { where?, since?, onError? }) streams new rows over SSE and returns an unsubscribe function. Pass since: <block_height> to replay from that height, then tail live.
const unsubscribe = subgraph.transfers.subscribe(
(row) => {
// handle each new row
},
{ since: 8054704 },
);Unlike the fetch-based Streams
subscribe, this 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.
const stats = await subgraph.transfers.aggregate({
count: true,
sum: ["amount"],
min: ["amount"],
countDistinct: ["sender"],
where: { status: "active" },
});
stats.count; // number
stats.sum.amount; // string (lossless)
stats.min.amount; // string | null
stats.countDistinct.sender; // number