SDK
@secondlayer/sdk — a typed TypeScript client for every Secondlayer surface: Streams, Index, Subgraphs, and Subscriptions, plus webhook verification and checkpointed consumers (raw or decoded) 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. Beyond binding to a subgraph table, you can subscribe directly to raw chain events with no subgraph at all — pass a triggers array built with the trigger.* factories (imported from the SDK root):
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 exist for 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 look similar but configure different things.
Replay historical deliveries
sl.subscriptions.replay(id, { fromBlock, toBlock }) re-delivers a chain subscription's historical matches over a block range. It is idempotent — replaying the same range is a no-op — and it does not move the live cursor, so live delivery keeps flowing uninterrupted. The range is capped at 100k blocks.
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 — in any format — 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 (which 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 included in a Stacks (Nakamoto) block, and that ≥70% of the reward cycle's signer weight attested to it. verifyTransactionProof recomputes everything client-side; fetchRewardSet resolves the reward set from your own stacks-node for fully-trustless consensus 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 (via @secondlayer/shared), so it is intended for Node / server-side use — the same as the Streams signature verification. See Verification for the 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's available on raw Streams events and on decoded Index rows, so you can build your app index at either level with the same contract.
// 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, consume 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 is fetch-based — so it carries your Bearer key (unlike a native EventSource) and works in the browser and Node 18+. It 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: the hosted API signs everything (so it's verified), while an unsigned response from a self-hosted instance with no signing key passes through — 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 — it returns { 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 — it returns { 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 for a subgraph table over SSE and returns an unsubscribe function. Pass since: <block_height> to replay rows from that height and 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 are not signed.
subgraph.<table>.aggregate(spec) runs scalar aggregates over an optional where filter and returns a result whose 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