.md

Changelog

Notable changes to Secondlayer and its SDKs. Entries dated before August 2026 describe the hosted service as it worked then (per-row read metering, API keys, 402 past the free window); it was withdrawn in August 2026 and has since returned, with hosted Index, Streams, subgraphs and webhooks at api.secondlayer.tools on an account key and prepaid credits, while on a self-hosted instance reads stay yours and unmetered and archive bootstrap, backfill and reindex draw credits.

Read the newest events first

Add order=desc to /v1/index/events, /v1/index/ft-transfers or /v1/index/nft-transfers and your first page is what just happened, not rows from a day ago. next_cursor then pages older, and a cursor resumes only under the order that issued it. The SDK takes order: "desc" on those lists and walk(), the CLI takes --order desc, and the MCP event tools take order. Reads still default to oldest first, and consume() always tails forward (plain HTTP).

The archive is ready for Clarity state writes

Bootstrap, verify and repair now handle a fourth archive dataset, state_writes: every Clarity state change each block made, named per key and checkable against the block's state root. The archive starts publishing it once the hosted stack holds it from genesis; until then nothing changes for you, and archives without it bootstrap and verify as before (archive datasets).

secondlayer verify names the exact blocks that differ

When a range of your local data differs from the signed archive, secondlayer verify now lists each diverging block and what differs in it (the block, its transactions or its events), instead of only the 50,000-block range. It reads the archive's per-block digests for free and checks them against the signed manifest. --json carries every height under divergent_heights, and you get a secondlayer repair command for every diverging range, not just the first (verify against the archive).

Check whether a Bitcoin output is spent

GET /v1/bitcoin/outspend/{txid}/{vout} says whether an output is spent, by which transaction and input, and whether that spend is in a block or still in the mempool. It reads the Bitcoin node live, so a mempool spend shows on the next read. A confirmed spend more than 144 blocks below the tip answers spent: true without naming the spender (how to use it).

sBTC peg address follows the v2 signer format

From Bitcoin height 972,295 the sBTC signers lock the peg to a multi_a script built from the registry signer set and threshold, not the aggregate key. getSignersAddress in @secondlayer/stacks/sbtc now returns the format active at the node's burn tip, or at the height or version you pass. deriveSbtcV2PegOutput gives the v2 script, scriptPubKey and address for any signer set. Once DKG is disabled the registry's aggregate key becomes a 0xFF marker, and getSignersPublicKey throws SbtcV2MarkerError rather than returning it. Until the first v2 sweep confirms, the funds are still at the v1 address.

Connect an agent with a URL

The MCP server now runs at https://api.secondlayer.tools/mcp, so an agent needs no install and no key in its config. Run claude mcp add --transport http secondlayer https://api.secondlayer.tools/mcp, and the first call opens a browser page where you sign in and allow the app. It carries the read tools (Index, Streams, Contracts, subgraph scaffolds), and its reads use your credits like a key's. Apps you allowed are listed under Apps on your Keys tab, where you can revoke one. Clients without OAuth can send an account key as the bearer. See Connect.

Your dashboard on your phone

On a phone, your account opens as an app with five tabs: Pulse, Insights, Webhooks, Subgraphs and Account. Pulse shows what needs you, the last 24 hours of deliveries and today's spend. You can pause, resume, test and replay a webhook, and revoke a key by holding its button. Add it to your Home Screen to install it. On a computer nothing changes.

Push when ingestion breaks

The installed app can notify you when a webhook can't reach your server, a hosted subgraph stops ingesting or stops serving, or a replay you started finishes. Turn it on and pick what you get under Account, then Push. Spend and key alerts stay off unless you turn them on. Self-hosted instances send push once VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and VAPID_SUBJECT are set; without them push stays off.

Snooze an insight until tomorrow

POST /api/insights/:id/snooze with { "until": "<time>" } hides an insight for up to 7 days, and DELETE on the same path shows it again. DELETE /api/insights/:id/dismiss undoes a dismiss.

Keys get their own tab

Keys is a tab in your dashboard now. Each key shows the rows it read over 30 days, and a key you made that hasn't read anything in 30 days gets a note so you can revoke it. A new key is shown once, with how to use it from the CLI, your environment, the SDK or an agent. An account holds up to 50 active keys, and you can make 20 an hour. Your first key is one you name; sign-in no longer makes one called "Default".

Insights tell you what's wrong, worst first

We now check your webhooks, subgraphs and reads every few minutes and record what needs attention: a receiver that's down, a certificate about to expire, a handler that keeps throwing, a polling loop, spend on pace to hit your cap. GET /api/insights returns them ranked, worst first, each with the evidence and a plain-text prompt you can hand to your own agent. POST /api/insights/:id/dismiss hides one, and POST /api/insights/:id/expected stops it coming back for that resource. sl webhooks doctor uses the same thresholds.

Webhook-only stacks cost $5 a month

A hosted stack with no subgraphs no longer runs the process that executes subgraph handlers, so it uses less memory and bills less. Webhooks only: $5/mo minimum while your stack runs. With subgraphs: $10/mo minimum. Your first subgraph deploy moves the stack to the $10 minimum, and deleting your last one moves it back. See What it costs.

Your hosted stack stops when it has nothing to run

Pause or delete every webhook and subgraph and your hosted stack stops within minutes, and its memory and storage charges stop with it. It never stops in its first 15 minutes, and your next request starts it again with your tables intact. Webhook-only accounts run the same stack. See Keys and billing.

The spend cap covers every charge

Your monthly spend cap now stops your hosted stack too, not only reads past the free 1M rows. At the cap, webhooks stop delivering, subgraphs stop indexing and archive fetches answer 402 spend_cap_reached until the next month or until you raise the cap. See Spend cap.

Watch a replay finish

A replay now has a record you can follow. GET /api/webhooks/:id/replays/:replayId returns how many events it queued, how many are sent and how many are still pending; when it finishes it adds how many were delivered and how many gave up. GET /api/webhooks/replays lists recent replays across your webhooks. Only one replay scans per webhook at a time, even across restarts.

Requeue what gave up in one call

POST /api/webhooks/:id/dead/requeue sends every delivery that gave up back to the queue, optionally only those from one replay (replayId) or since a time (since). After an outage, use this instead of replaying the range: deliveries that were waiting go out on their own once you resume, and a replay would send them twice.

One email when a webhook is down

If a hosted webhook fails 5 deliveries in a row and nothing gets through for 15 minutes, we email the account owner once. Several webhooks down at the same time arrive in one email. Deliveries keep retrying while it's down, and you get no further email until it recovers and goes down again.

See the deliveries from one hour

GET /api/webhooks/:id/deliveries?from=&to= and sl.webhooks.deliveries(id, { from, to }) return the attempts in a time window of up to 31 days. Activity now counts failed attempts per hour, includes a usual range for each hour of the day, and takes ?days=30 for daily totals.

Know when your receiver's certificate expires

We check your https receiver's TLS certificate every few hours and return its expiry as receiverCertExpiresAt on the webhook.

Sentinel webhooks keep their names

Sentinel finds its webhooks by name, so a webhook whose name starts with sentinel: can't be renamed. The API answers 409 WEBHOOK_NAME_MANAGED.

Match print fields with where

A print_event trigger or subgraph source takes where, so you get only the prints that carry your values: your signer on PoX-5, your pool on a DEX. See Print fields.

Before, topic was ignored on webhooks and you received every print from the contract. It now delivers only prints whose tuple topic matches.

Prove a Bitcoin transaction offline

verifyBitcoinTx proves a Bitcoin transaction is in a block of the checkpoint's Bitcoin chain, and that one of its outputs pays the address and amount you expect. It needs no contract call and no Stacks node. Pair it with a state proof of an sBTC withdrawal sweep to follow the withdrawal to its Bitcoin output. See Bitcoin transactions.

Bitcoin transaction proofs route

GET /v1/proofs/bitcoin-tx/{txid} returns a transaction, its merkle branch and its block header, read from the instance's bitcoind. It is free like every proofs route. On your own box it reads the node at BITCOIN_RPC_URL, which needs txindex=1.

Health history and grouped handler errors

Your subgraph records a snapshot every minute: blocks behind the chain tip, rows written and storage, kept for 7 days. GET /api/subgraphs/:name/health returns it in hourly buckets. Handler errors are grouped by cause, so a bug that throws on every event shows up as one entry with a count. GET /api/subgraphs/:name/errors lists the groups, most frequent first.

Contract reads now work in hosted subgraphs

readContractAt and ctx.client work in hosted handlers with no setup, pinned to the block being processed like on your own box.

  • About 3,000 reads a minute per account and 5 s per read. Over the limit, reads wait and retry.
  • Self-hosted processors re-read each cached value once after upgrading: the read cache key changed.
  • See Reading the chain from a handler.

Chain reads halt the block instead of dropping rows

Breaking for self-host. Before, a ctx.client read that couldn't complete (a missing node URL, a node timeout or outage) failed only that event, and the block committed without its rows. Now the block retries, then the subgraph halts there with status error and resumes from that block once the node answers.

readContractAt deploys

A subgraph that imports readContractAt now deploys. It failed to bundle before with No matching export.

Typed map and variable writes

Pass an abi on a map or variable write source and name the map or variable, and your handler gets event.key and event.value typed and decoded from the contract's types. Without one they stay unknown.

Deploy checks map and variable names

secondlayer subgraphs deploy now fails when a write source names a map or variable its contract does not define, so a typo stops the deploy instead of matching nothing.

One login for your account

sl login and sl logout always sign you in and out of your Secondlayer account, and --credits is gone. One account and one credit balance cover rows, your hosted stack and archive fetches, so there's nothing separate to sign in to. A browser login opens a session, like sl login --email, rather than saving an API key, so the Keys tab only lists keys you made.

sl login opens your browser

sl login shows a short code and opens secondlayer.tools. Approve it there while you're signed in and the CLI is signed in on that machine until sl logout. Signing in never makes an API key, so the Keys tab only holds the keys you made for reading data. sl login --email keeps the emailed-code sign-in for a machine with no browser, and --with-token still reads a key from stdin.

See your rows by day

sl credits usage shows the rows you were delivered each day for the last 30 days, split into free and paid, and where they came from. Pass --days for a shorter window or --json for scripts. The Rows tab in your account shows the same numbers.

Self-host with docker compose

secondlayer setup and secondlayer uninstall are gone, along with the MCP setup tool. You run your own instance from docker/oss: secondlayer init writes the keys, docker compose pull and up -d start it, and bootstrap and verify fill and check it. Stop it with docker compose down. The compose file now pulls the published images, pinned with SECONDLAYER_VERSION. See Self-host.

Runes from the CLI

secondlayer index runes lists etched runes, and its get, balances and activity subcommands read one rune, a holder's balances and the etch, mint, transfer and burn log. secondlayer streams events --chain bitcoin (also by-tx, consume and reorgs) reads the raw Runes events. Self-hosted instances with the bitcoin profile have data now; hosted answers a "not configured" note until Runes data arrives with the hosted rollout. See Runes.

Talk to us from the site

Questions about hosting, volume or a deployment now have a home: /contact takes your message, keeps it, and a person replies to the address you give. Pricing's "Talk to us" links there.

Subgraph webhooks removed

Webhooks fire on chain events only. Upgrading deletes any webhook on a subgraph table, and the API answers 400 SUBGRAPH_WEBHOOKS_REMOVED for one. Your rows are yours: react to them from your Postgres, or poll the table hosted. See React to your rows. Run secondlayer webhooks list --json before you upgrade to keep a record.

Deliveries carry the block's time

Every Stacks chain delivery's data now has block_time, when the block was produced (ISO 8601), or null when it wasn't recorded. You no longer need a second read to timestamp an event. See Delivery envelope.

Recompute a subgraph and check its rows: secondlayer verify subgraph --replay

A state subgraph can now be checked end to end on your machine. secondlayer verify subgraph <name> --replay proves every block in the range from the checkpoint, checks each block's transactions against its header, names every write the block made, re-runs the deployed handler bundle in the deterministic realm, and compares the result with the rows the API serves. Rows compare on declared columns and _block_height; _tx_id is not compared, since which transaction made a write stays unproven. Reads are free. An instance without state_writes exits 2. See Check rows.

verify block checks transactions and names writes by default

secondlayer verify block adds a txs link: the block's transactions must hash to its header's tx merkle root. The state diff's writes are now named by default from /v1/proofs/writes/{height}, which is free and rate limited like the other proofs; where the instance has no writes for the block yet, the diff is still proven and listed as unnamed with a note. --rows now only adds the vm_events check, which bills as Index rows.

Check a block yourself with secondlayer verify block (CLI 19.2)

secondlayer verify block <height> proves a mainnet Stacks block on your machine instead of asking you to trust us or any node. It checks Bitcoin headers and their proof of work, ties the block to the Bitcoin block that elected it, proves that cycle's signer set and that 70% of signer weight signed, then rebuilds the block's state root from a witness and lists every state change the block made. The only thing it trusts is a checkpoint built into the CLI. A busy block takes a few seconds. It covers state: contract maps and variables, balances and nonces. Print events aren't committed in Stacks block headers, so they aren't covered yet. See the CLI reference.

Check a block in your browser

The Verify page and the verification docs now run the check live in your browser: type a height and watch each link turn green, computed on your machine against the free proofs API. verifyBlock takes onStep to report each link as it passes, and describeLinks turns a result into the lines and verdict, so you can build your own.

Check a block without an account

@secondlayer/verify and /v1/proofs no longer need a key. Check a block from a script, a wallet or a web page with new SecondlayerProofSource(); add a key for higher rate limits.

Check blocks older than the checkpoint (@secondlayer/verify 0.2)

verifyBlock now proves any height, not just blocks after the checkpoint. A block below it is tied to the checkpoint by a hash chain: parent links for nearby blocks, one proof from the chain's own height index for distant ones, epoch 2.x blocks included. secondlayer verify block <height> shows that link. Nothing new to configure.

Run the same block check in your own code: @secondlayer/verify (0.1)

The verifier behind secondlayer verify block is now its own package. verifyBlock(height, { source }) returns ok, the proven state diff, and the first broken link when one fails. Read proofs from /v1/proofs with SecondlayerProofSource and any account key, or take blocks and MARF proofs from your own node with NodeRpcProofSource. Proofs are free; rows: true also checks the block's indexed rows, which bill like any Index read. It trusts only the checkpoint it ships with, which you can replace. It's 0.x: expect the lower-level exports (MARF, witness and signer helpers) to change before 1.0. See the package README.

Every error carries a request id you can report (SDK 14.4)

Every JSON error from api.secondlayer.tools now includes a request_id (also sent as the X-Request-Id header), a code, and feedback.url. Send your own X-Request-Id and it comes back unchanged. When a call fails and the API looks wrong or incomplete, POST /v1/feedback with that request_id and one line on what you were trying to do. We attach our own record of the failed call, kept 24 hours, and request_matched in the reply tells you whether it was found. Hosted subgraph and webhook errors work the same way. In the SDK, ApiError.requestId holds the id, and MCP tool errors keep code, request_id and feedback. A self-hosted instance records nothing and points feedback.url at a GitHub issue. See the REST API page.

Proofs for any block at /v1/proofs

/v1/proofs serves what verify block checks: state witnesses, the bytes that tie a block to its Bitcoin block, Bitcoin headers, signed blocks and MARF proofs. Any account key works and proofs are never billed. Witness requests are limited to 2 per second per account.

Every storage write a block committed: /v1/index/state-writes

GET /v1/index/state-writes?block_height=H returns the exact writes a block made to contract state, keyed the way the chain stores them, and the SDK has index.stateWrites. Rows bill like event rows. Hosted rows arrive once our nodes switch to the instance that records them; a self-hosted node records them with the state_writes observer key.

Block headers are free on /v1/index/blocks

Hosted reads of /v1/index/blocks are never billed and never return a 402, even past your free 1M rows or with no balance. Event and transaction rows still bill. See Keys and billing.

Older releases

Subgraph reads return next_cursor on every page with rows

GET /v1/subgraphs/<name>/<table> used to set next_cursor only on a full page. It now comes back on every page that has rows and is null only on an empty page, so a poller tails with cursor = next_cursor ?? cursor. A loop that pages through a whole table ends on an empty or short page, one request later than before when the last page was full. secondlayer verify subgraph --replay already stops on a short page. See React to your rows.

Subgraphs whose rows anyone can recompute (CLI 19.3)

Every deploy now reports a verification level and a pin. A subgraph whose sources are all state writes (var_set, map_*) is level state: its rows can be rebuilt from block headers, so a handler that reads Date, Math.random, fetch, timers or locale formatting is refused with NONDETERMINISTIC_HANDLER, naming each use by file, line and column. It then runs in a locked-down realm that rejects the same calls and any non-integer number in a row. Event sources are level events until event proofs ship; they deploy exactly as before and get the same findings as advice. pin hashes the schema, bundled handler, startBlock, network and runtime, so a handler-only redeploy changes it. Both show in secondlayer subgraphs status and the subgraph detail. Subgraphs deployed earlier show none until redeployed. See Handlers.

map_insert sources are level events; deploys store their pin preimage

Storage holds an insert and a set alike, so a map_insert source now derives level events until event proofs ship; redeploy to update a stored level. A state subgraph reads its writes from state_writes once the instance holds them from its startBlock on, and keeps vm_events until then. Each deploy now stores the canonical JSON its pin hashes, served with the handler bundle so anyone can recompute the pin.

If you self-host a subgraph on var_set or map_* sources whose handler uses one of those calls, it keeps running, but its next redeploy is refused. Take the value from event, ctx.block.timestamp or ctx.tx instead.

Handler redeploys apply without a restart

A redeploy that changes only the handler (handler_updated) now takes effect on the next block. Before, the processor kept running the previous handler until it restarted. Separately, ctx.findOne and ctx.findMany now return committed rows in insertion order. A findOne whose filter matches several rows used to get whichever row Postgres found first, so a handler that depended on that could index differently after a reindex; it now always gets the oldest.

_tx_id always names the transaction that created the row

When one block created a key and a later transaction in the same block upserted it again, the stored row could carry the later transaction's _tx_id. It now keeps the creating write's _tx_id and _block_height, as it already did across blocks, and replay computes the same. Your own columns still take the last write. This applies to new writes; existing rows keep their _tx_id unless you reindex. See System columns.

Hosted subgraphs, private to your account (CLI 19.1)

Deploy a subgraph to api.secondlayer.tools with an sk-sl_* key and it runs in your account's own stack, with no box to run. secondlayer subgraphs takes SECONDLAYER_API_KEY the way webhooks does. Your handlers run in a gVisor sandbox with 512 MB of memory and no network beyond Secondlayer's API and the public internet. Sources must be event filters or contract_call / contract_deploy; anything else is refused at deploy with SOURCE_NOT_HOSTABLE, naming the source. Indexing reads bill rows.delivered against your allowance, plus the stack's memory and storage. A subgraph whose handler keeps stalling or running the processor out of memory is restarted, then marked error after three restarts at the same block. Subgraphs are private to your account: there is no public catalog. Subgraph webhooks stay self-host only. A subgraph with no startBlock reindexes from genesis, which now works hosted and fetches only your subgraph's matching contract events; it still bills every matching event and transaction row, so set a startBlock. See Deploy a hosted subgraph.

secondlayer console is gone (CLI 19.0.0)

The instance web console and the secondlayer console command are removed. Operate an instance with the CLI, REST API, or MCP. Account settings live at /account on the web app.

repair fixes transactions and events, not just blocks

secondlayer repair used to compare block digests only, so it reported "nothing to repair" whenever a transaction or event went missing underneath a block that still matched the archive. It now also compares your transactions/events row counts against the manifest and repairs those ranges on --apply, even when every block is already correct. The stale-height hint no longer suggests bootstrap, which refuses a database that already holds a completed import.

Credits: runway, projected spend, balance alerts and a monthly spend cap

Your credits page now shows what you spend per day, when your balance runs out, and what the month will cost at your current rate, plus your delivery service's memory against its 0.5 GB minimum. We email you at 7 days and 2 days of credit left, and if your delivery service stops. You can also set a monthly spend cap: once this month's spend reaches it, hosted Index and Streams reads past your free 1M rows pause with a 402 spend_cap_reached until the 1st or until you raise it. Webhooks keep running.

1M rows a month free, then prepaid; a memory floor for hosted stacks

Hosted Index and Streams give you 1M free rows a month, down from 10M. Past that: $5 per 1M rows, $2 per 1M past $50 in a month. Your hosted stack (it runs your webhooks) now bills at least 0.5 GB of memory while it runs, about $10/mo, instead of raw sampled RAM. A stopped stack still bills nothing.

pox/cycles now reports PoX-5, exact to the node (SDK major)

/v1/index/pox/cycles follows the chain: it now reports reward cycles of the current PoX (PoX-5) instead of the retired PoX-4 rollup, which stopped at the epoch 4.0 fork. Each cycle carries total stacked and reward-eligible STX, bond sats, sBTC custodied, rewards allocated and claimed, cumulative rewards-per-token, and is_current/is_frozen; the single-cycle route adds a per-signer breakdown. A new pox_version field marks the era, and this endpoint will follow the same move at the next PoX fork. PoX-4 cycle history is final and not served here: a cycle below 141 returns 404 with a note to rebuild it from /v1/index/stacking on a self-hosted instance. sl.index.pox.cycles picks up the new shape: an SDK major, since the old fields (unique_stackers, unique_delegators, action_count, function_breakdown) are gone.

Runes: sl.index.runes, and chain=bitcoin on Streams

Runes is data on the existing Index plane, not a new product. sl.index.runes.list() is the etch catalog, .get(ref) a single rune by id or name, .balances() current per-outpoint balances, and .activity.list() / .activity.walk() the etch/mint/transfer/burn event log, with the same envelope shape, cursors, and consume conventions as every other Index feed, on a Bitcoin tip instead of a Stacks one. sl.streams.events.list({ chain: "bitcoin" }) (and stream/subscribe/consume) reads the same events raw, on their own cursor space, and each event carries ts, its block's time. Four webhook triggers (runeEtch, runeMint, runeTransfer, runeBurn) fire off the same feed. Self-host with the bitcoin compose profile has data today; hosted follows. See Runes.

signer now returns a pool's full pox-5 activity

claim-rewards, claim-staker-rewards-for-signer, grant-signer-key, and revoke-signer-grant only ever printed signer-manager, so a pool had to query signer= and signerManager= and merge the results. The contract's own assertions make the two the same principal, so the decoder now fills signer on those topics too: signer= alone returns everything. signerManager stays for compatibility. pox5.events.consume() also ships: the same checkpointed paging, cursor saving, reorg rollback, and retries as events, contractCalls, and sbtc.events.

Tip is per event type, not one number for everyone

Each decoded event type runs its own decoder, and they don't all sit at the same height. tip.block_height on /v1/index/events (and ft-transfers/nft-transfers) now reports the committed height for the type you just read, instead of always reporting the ft_transfer decoder's checkpoint. A slower decoder no longer silently holds back reads of a type it isn't even feeding, and a type that's actually behind ft_transfer no longer serves rows past what it has decoded. Re-read tip per response if you cache it across event types. /v1/index/blocks and /v1/index/transactions are unchanged, on the ingest tip.

Hosted chain webhooks: one less poll on the way to your endpoint

The chain-webhook evaluator no longer makes a separate GET /public/status request every tick to check decoder progress; it trusts the tip the Index API already returned. A tick that catches up a backlog of blocks re-runs immediately instead of waiting out the poll interval first. Part of an ongoing push to cut hosted webhook latency; more of that work is still in flight.

wait: long-poll instead of a tight poll loop

/v1/index/events and /v1/index/blocks accept wait (seconds, max 25). With it, an empty page holds the connection open and returns the moment new data commits, instead of answering empty right away — same cursor logic, fewer wasted round trips. Under the hood, block ingest and every decoder checkpoint now NOTIFY Postgres on commit, which the L2 decoders and the hosted chain-webhook evaluator use to wake up early instead of sitting out their full poll interval. A self-hosted instance from before this shipped rejects wait with a 400; the SDK and evaluator detect that and fall back to plain polling automatically.

10M rows a month free, then prepaid

Hosted Index and Streams bill rows delivered, live or history, at one price. Every account gets 10M rows free each month, then $5 per 1M ($2 per 1M past $50 in a month). Past the allowance with no balance, reads answer 402 insufficient_credits with a top-up link. Hosted reads need an sk-sl_ key; a self-hosted instance stays keyless on loopback and unmetered.

Full history on every account

The one-day free window and the Streams retention limit are gone. Any key reads from genesis; rows past the allowance draw credits.

Receiver credentials are write-only

A webhook read (get, create, update, pause, resume, rotate-secret) no longer echoes back your bearer token, basic-auth string or header values. You get auth: { type, headerNames, hasSecret } instead: enough to see what's set, never the credential itself. Set it the same way as before, with authConfig on create or update. secondlayer webhooks get prints an Auth line ("bearer token (set, hidden), headers: x-team") in place of the old raw config dump.

A config card that explains what you'd otherwise have to guess

The webhook detail page has a new Configuration card: a plain-English read of what each trigger matches, its own 7-day volume, and which fields aren't filtered. An unfiltered trigger gets a warning right on the page, not just inside the card, with the events-a-day estimate it's costing you. The deliveries table adds an Event column (a short transaction id plus its position in the block) so events from the same block stop looking like duplicates. Two bugs are fixed along the way: reopening a delivery no longer swaps to a different one when a poll reshuffles the table underneath it, and a tall response body no longer squashes the tab row to nothing.

See what each webhook is doing at a glance

A webhook's page now shows its events per hour for the last 7 days, split into delivered, waiting and gave up, plus your last 100 attempts as one strip, so a failure burst or a growing backlog shows up before you read a single row. When a check fires, its insight carries a graph as evidence: events piling up while your receiver is down, the share of 429s, response times against your timeout, or block-to-delivery lag. Click any delivery to see the payload we sent and what your receiver answered, highlighted and copyable. The status pill reads Failing when your receiver is. The same data is in the SDK: webhooks.activity(id) and webhooks.delivery(id, deliveryId).

Doctor explains what's wrong, not just that something is

secondlayer webhooks doctor and the dashboard's detail page now run five deterministic checks against your last 100 delivery attempts: rate-limited, down, rejecting, slow, or running behind on delivery. Each fires on a fixed threshold and reports the evidence (attempt counts, response times, a status breakdown), never a guessed cause. One insight leads; the rest collapse underneath it. The webhooks list shows a one-line note per row from data it already has (a paused webhook, a tripped circuit breaker), and /account/credits now estimates days of credit left at this month's spend. Troubleshooting has one section per rule.

Hosted chain webhooks

Chain webhooks run hosted now, at api.secondlayer.tools with an sk-sl_ key and prepaid credits. Each account gets its own isolated webhook stack, not a shared queue. secondlayer webhooks create works against hosted the moment SECONDLAYER_API_KEY is set.

Delivered events run $10 per 1M; retries are free, never metered. Hosted caps at 7 retries and a 30s timeout per delivery.

Watch and fix hosted webhooks from the dashboard

/account/webhooks lists your hosted webhooks and, per webhook, the last 100 delivery attempts, a diagnosis, and failed events waiting on you. Send a test event, pause or resume, rotate the signing secret, resend one failed event or all of them, and delete — from the browser. Create and edit still only happen on the CLI/SDK. Opening the page no longer starts (or bills for) a delivery service on an account that's never used webhooks; the credits page link on a 402 also goes somewhere real now.

Rollbacks cover everything that may have landed

A reorg now rolls back every delivery your endpoint may have received, including one still in flight or retried, instead of dropping it. Delivery stays at-least-once and unordered.

Limits you can set

WEBHOOK_MAX_RETRIES_CEILING, WEBHOOK_TIMEOUT_MS_CEILING and WEBHOOK_REPLAY_MAX_BLOCKS cap retries, timeouts and replay size. One replay runs per webhook at a time; a second answers 409. secondlayer webhooks doctor warns when chain webhooks can't fire on your instance.

One way to write each row

Handlers write with insert, update, upsert, delete and increment, and read with findOne and findMany. Every source has a handler of the same name. ctx.patch, ctx.patchOrInsert, the handler aggregates, ctx.formatUnits, the "*" catch-all, materialize, table relations and the definition version field are gone. --from-contract now scaffolds a one-line ctx.insert handler per topic.

Contract calls find their own ABI

A contract_call source with functionName no longer needs a pasted abi. secondlayer subgraphs deploy fetches the deployed contract's ABI and writes it into the definition. Pass abi yourself for wildcard, multi-contract or trait sources, and for a local deploy.

Tip-first refuses handlers that read rows

Tip-first deploys and backfills already refused update and increment. They now also refuse findOne and findMany, because a read-then-write depends on the order blocks arrive in. Deploy those handlers in the default mode.

Subgraph reads live on /v1

Rows, streams and aggregates are served from /v1/subgraphs/* only. The duplicate /api read routes and the subgraph visibility setting are removed.

findMany returns a cursor page, queryTable* is gone

queryTable, queryTableCount and queryTableAggregate (the /api offset-read client) are removed. Typed findMany and untyped rows/count/aggregate read /v1 instead: findMany returns { rows, nextCursor, tip }, not a bare array — pass nextCursor back as cursor to resume. orderBy sorts one column; a second key throws.

Unchanged pages revalidate for free

The SDK caches the last 64 pages it read and sends If-None-Match on the next request. A 304 on a finalized page costs nothing on the hosted API. Pass etagCache to keep the cache across runs.

One name per setting

SL_API_URL, SL_API_KEY and SL_ARCHIVE_API_KEY are no longer read anywhere. The CLI, SDK and MCP read only the canonical names, with no fallback and no warning.

OldCanonical
SL_API_URLSECONDLAYER_API_URL
SL_API_KEY / SL_ARCHIVE_API_KEYSECONDLAYER_API_KEY

secondlayer init now honors SECONDLAYER_API_URL (it used to read only the legacy name), and secondlayer backup now backs up SECONDLAYER_API_KEY. Re-run secondlayer init if your .env still has the old names. If your .env has the instance token only as SL_API_KEY=<hex> (no INSTANCE_TOKEN= line), the next secondlayer init / setup mints you a new one; copy that value into INSTANCE_TOKEN= first if you want to keep it.

Setup asks plain questions instead of a terminal UI

secondlayer setup no longer installs a native terminal UI. Run it interactively and it asks for whatever --network, --node-mode, or --against you didn't already pass, with plain prompts that work under node, not just Bun. --against now defaults to the official archive, so you only pass it to point at a mirror or skip it with --skip-bootstrap.

Observer stanzas work on a stock node

secondlayer observer prints events_keys = ["*"] in both modes, the only set a stock stacks-core starts with. --recovery is gone. A block your node fails to deliver stays a gap until secondlayer repair fills it from the archive.

Instance web console is gone

The compose --profile console container and secondlayer console are removed. Operate with CLI, REST, and MCP. Old /docs/console redirects to Self-host.

Live Streams reads stay on REST

MCP keeps streams_tip and streams_dumps. List and follow tools (streams_events and siblings) are gone; call GET /v1/streams/events.

Renamed from Subscriptions

The product name is Webhooks. Canonical surfaces are secondlayer webhooks, sl.webhooks, webhooks_* MCP tools, and /api/webhooks. The subscriptions aliases are gone. Tables were renamed in place on upgrade. Test-ping bodies send webhook_id.

Identity maps have no handler

You declare materialize on a pinned print source: snake columns { from: "tokenX" }, { fromTx: "sender" }. No ctx.insert. --from-contract and MCP scaffold emit that for named topics. A source is materialize or a handler, not both.

Pinned prints are required, and empty mappings refuse deploy

A print_event with a contract id needs a prints map so event.data is typed. contract_call with functionName needs abi. Every other source needs a handler or "*". Deploy and dryRun return 422 EMPTY_MAPPING when dummy observed fields write 0 rows. Status health.emptyMapping is true only after processed events and empty tables.

Preview IN/OUT, and skipped prints are queryable

secondlayer subgraphs preview FILE --from H prints per-event IN keys and OUT columns, and exits 1 on empty OUT. Print-validate skips persist to authed GET /api/subgraphs/:name/violations (not /v1). MCP secondlayer://samples/{contractId} is five camelCased prints plus the field list.

Create from a contract

subgraphs create requires --from-contract, --trait, or --blank (native STX, not every FT). MCP subgraphs_scaffold prefers observed print-schema. subgraphs add --from-contract merges a second contract into the same file. subgraphs_test fail-closes the same way as the CLI; dryRun is DDL only.

/extended is gone

The optional Hiro-JSON listener on :3999 is removed. /v1 is the contract. Old /docs/extended redirects to the REST guide.

Hosted Index is keyed

Hosted /v1 Index at api.secondlayer.tools takes an account key (SECONDLAYER_API_KEY, sk-sl_*). Same prepaid credits as archive bootstrap. Your instance on loopback stays keyless.

POST /v1/archive/verify

Your instance compares local identity digests to a signed manifest URL. Nothing is uploaded but the URL. Signature failure is 200 unanchored, never clean. Without from_block/to_block the route caps at 40 ranges.

Live list reads stay on REST

MCP no longer wraps streams_events and siblings. GET /v1/streams/events is the live page. streams_tip and dumps stay on MCP.

Hosted Streams is keyed

Hosted /v1 Streams uses the same account key and the same prepaid balance. Scoped streams/index keys are revoked; mint account.

Two credentials on the client

apiKey / INSTANCE_TOKEN is your instance. accountKey / SECONDLAYER_API_KEY is the hosted account key. Archive quote and fetch send the account key, never the instance token. SL_API_KEY is a one-release alias of the hosted key. MCP archive tools read the same env.

Signed archive from the SDK

sl.archive loads the signed canonical tree, quotes and fetches gated partitions against api.secondlayer.tools with an account key, and downloads sha256-checked bytes. Quote and fetch never talk to your instance baseUrl. Verify, repair, and bootstrap stay CLI; download does not decode parquet.

Verify this instance, and name an empty index

sl.archive.verify POSTs to your instance. A 200 with unanchored is data, not an exception. sl.instance.status and diagnose read /public/status, so a box with no blocks names bootstrap instead of a bare DEGRADED. MCP can run the same loop: instance_status, archive_bootstrap, archive_verify, then codegen. Hosted credits need SECONDLAYER_API_KEY, not the instance token.

Running balances survive a reorg

You keep event rows append-only and fold running balances in onRollback on the sink. Same transaction as the rewind: doomed rows are still there, a throw aborts the delete. Remaining facts are height < fork.

/tools AI SDK entry is gone

@secondlayer/stacks/tools and /tools/btc no longer ship. Agent reads go through @secondlayer/mcp. ai and zod are not peers of the package.

Same getContract client on Clarinet simnet

Point createPublicClient at a Clarinet session with simnet() from @secondlayer/stacks/simnet and keep using getContract the way you do on mainnet. The root SDK still does not load clarinet-sdk; that entry is the only one that talks to the VM.

Codegen reads Clarinet from config, not a plugin API

secondlayer.config.ts takes clarinet: true (or { path, include, exclude }). @secondlayer/cli/plugins and plugins: [clarinet()] are gone. codegen contracts still loads simnet ABIs. The react() hook generator is gone too.

secondlayer local is gone

The frozen local stack group is removed. Use secondlayer setup for self-host and secondlayer devnet for Clarinet.

secondlayer archive

Price a fetch and read the official tip without running bootstrap. archive latest and archive quote sit with the same verify / repair / bootstrap verbs. Top-level bootstrap, verify, and repair stay.

Instance commands stay on your box

setup, init, subgraphs, and the other instance verbs refuse api.secondlayer.tools. index and streams send SECONDLAYER_API_KEY when pointed at us. whoami prints instance and merchant. init writes INSTANCE_TOKEN only.

testing() is gone; simnet is a client

testing() no longer generates Clarinet helpers. Import @secondlayer/stacks/simnet, hand it your session, and call getContract. clarinet() now depends on @stacks/clarinet-sdk.

Setup that lands in the right database, and a bootstrap that finishes

secondlayer setup writes DATABASE_URL into .env and hands it, with the archive key and instance token, to the bootstrap and verify it runs, so a fresh box restores into the Postgres it just started instead of whatever your shell pointed at. bootstrap resumes a torn import per dataset (blocks, transactions, events each keep their own mark), --from-block restores verify only what they loaded, and repair --apply rewrites a fixed block together with its transactions and events. secondlayer start is gone; setup brings the stack up.

Verify offline with a key that ships in the release

verify, repair, and bootstrap resolve the archive signing key the same way: --public-key, then ARCHIVE_SIGNING_PUBLIC_KEY, then the key compiled into the CLI. A self-hosted instance never leaves the machine for a key, http:// key endpoints are never consulted, and latest.json pointers must carry a digest and a valid signature. The tradeoff: rotating the built-in key takes a CLI release until the key ceremony lands.

One confirmation gate, and credits that log in as you

Every destructive or metered prompt defaults to no, -y skips it, and without a TTY the command exits 1 naming -y instead of letting a pipe answer. --json never stands in for -y: bootstrap and repair print the quote as CONFIRMATION_REQUIRED and exit 2 so a script can read the price first. Archive credits send your secondlayer login --credits session, never the instance token, so a logged-in operator can quote and fetch. Dumps stay under --to and land via .part, the Postgres password rides in PGPASSWORD, scaffolded .env files are owner-only, and archive fetches retry transient failures with a re-run hint instead of a refusal.

Consumers never skip rows on a reorg

consume() no longer rewinds forward: a fork above your checkpoint is noted, not rolled back, so the rows between the checkpoint and the fork are delivered instead of silently dropped. Fork points are validated before your sink runs, rewinds deeper than maxRollbackDepth (1000 blocks) throw, and the Streams loop polls reorgs.list on empty pages so a fork that lands while you idle at the tip is rolled back too. Cursor.atHeight(0) is now null, and onReorg can hand you a null cursor at genesis.

Subgraph subscribe() works on a token-gated instance

Typed subscribe() is a fetch-based SSE reader that carries your bearer token, reads /v1 keyless on loopback, and reconnects from the last delivered block height. Every path segment you supply is percent-encoded. typed() knows your declared columns, so a table with its own id column filters on your id, not the system row id. new SecondLayer({ verify, verifyDumpsManifest, origin }) reaches sl.streams.

walk() finishes, Streams errors keep their envelope, VersionConflictError is gone

walk() ends only when the server stops advancing next_cursor, retries page fetches like consume(), and rejects batchSize above 1000 up front. Index and REST requests run under requestTimeoutMs (30 s) and accept signal. Every failure status keeps the server's { error, code }, Streams and Index alike; the signing key is cached only after a successful fetch, and a key_id rotation mid-stream refreshes it once. streams.events.subscribe redelivers an event whose handler threw, backs off with jitter, reopens a stale socket, and stops (through onError and a new done promise) on a 401 or bad signature. Breaking: VersionConflictError is removed (nothing threw it; the real 409 is ApiError with code: "OPERATION_IN_PROGRESS"), and context() fields are { value, error? } so a null says why.

/tools is deprecated; use @secondlayer/mcp

@secondlayer/stacks/tools and /tools/btc still import until the next major but get no new tools. ai and zod are optional peer dependencies now, so a project that only reads contracts installs neither. While they last, readContract and getTransaction return Clarity values through cvToJSON instead of crashing on a bigint, and every model-supplied principal, txid, and hash is validated and percent-encoded before a request is built.

Transport timeouts cover the body, nonces come back on a failed send

One timeout covers headers and body, a stalled response rejects with TimeoutError, RequestOptions.signal cancels from your side, and broadcasts go out once. NonceStore.release hands a nonce back when a send fails before the node accepts it, so FeeTooLow no longer strands every later transaction. multicall caps reads in flight. Breaking: indexSource/indexTxSource go through the client transport, default baseUrl to the transport URL (no hosted default), and send Authorization: Bearer.

Tuples hash like the chain, principals and units fail loudly

Tuple fields serialize in the node's byte order so SIP-018 hashes match to-consensus-buff?. The deserializer caps nesting and checks declared counts against the bytes left. Cl.principal and parseContractId reject undecodable addresses, parseUnits refuses exponent-form numbers and silent rounding, and a legacy Bitcoin address with a bad base58check checksum is refused before it becomes a PoX reward address. apiKey no longer appears on transport.config.

Optional /extended JSON on :3999

You can keep /extended clients pointed at your instance. Same Postgres, off by default (EXTENDED_VIEW=1), second bind on :3999. /v1 is unchanged. status, block, tx, per-tx events, address txs, nft transfers, and decoded STX/FT/NFT holdings are live. BNS is empty until BNS_DECODER_ENABLED=true. No nonce; that stays on the node.

PoX-5 lock, reclaim, and eligibility

You can fund an L1 lockup, prove it, spend it, and check a stake before you pay the fee. buildRegisterMetadata produces the P2WSH address and lock script. buildPox5LockProof maps a SIP-044 proof onto registerForBond. reclaim() spends the same script (locktime or early-exit). eligibleStake / eligibleRegisterForBond / eligibleClaimRewards return { ok: true } or a set of Pox5ErrorCode reasons.

Signer calldata is a SIP-005 pox-addr tuple (buildSignerCalldata). pauseRewards is one-way. Import reclaim from @secondlayer/stacks/pox5; it is not on the Stacks wallet client.

Staking post-conditions, SIP-018, and Clarity 4 deploys

You can write the Epoch 3.4 / 4.0 post-conditions the chain actually checks: Pc.ustxToLock(), willPerformPox(), NFT maybe-sent, and postConditionMode: "originator". Hex PCs round-trip with Pc.fromHex. Contract deploys default to versioned Clarity 4, not an unversioned Clarity 1 payload.

SIP-018 structured-data hashing matches the spec (hashStructuredData / signStructuredData). signMessage({ domain }) previously omitted the inner hashes, so those signatures would not verify on-chain. The pox-5 grant helper was already correct.

pox-5 adds Pox5ErrorCode (parse (err u7) to a name) and getPoxInfo(), which reads sbtcContract from /v2/pox so you do not hardcode the token principal.

getDataVar({ contract, variableName }) reads a private data-var off the node (GET /v2/data_var) without a full callReadOnly.

One command replaces the five-command setup

secondlayer setup replaces the old five-command, one-manual-copy-paste onboarding path. Secrets get generated, docker-compose.yml and .env get written straight into your target directory, the stack comes up, and verified history gets restored and checked, all from one command.

Interactive by default: a real terminal UI, built on @opentui/react. Without a TTY, or with --yes, it skips the prompts and runs the same steps from flags instead, --network, --node-mode, and --against (unless --skip-bootstrap) are required explicitly in that mode, so an agent drives it exactly as well as a human at a terminal.

scripts/oss-bootstrap.ts is gone. It minted an older account and API-key credential that no longer matches how self-hosted instances authenticate.

Experimental observer-http block source

A subgraph processor can page raw /new_block bodies from the indexer's internal observer journal instead of Postgres or Streams+Index. Set SUBGRAPH_SOURCE=observer-http and OBSERVER_HTTP_URL at the processor. Default is still Postgres. The route is opt-in on the indexer (OBSERVER_HTTP_EXPORT=1) and is not a public /v1 API.

Publish, unpublish, and the visibility flag are gone

Publishing claimed a name in a hosted global namespace. A self-hosted instance has no such namespace, so the verb had nothing left to mean. There is no shim, the routes 404 and the calls are gone.

  • CLI: subgraphs publish, subgraphs unpublish, and subgraphs deploy --visibility are unregistered. Deploy's success footer always prints the /api/subgraphs read path.
  • SDK: subgraphs.publish() / subgraphs.unpublish() and the SubgraphPublishResult / SubgraphUnpublishResult types are removed. deploy() no longer accepts or returns visibility.
  • MCP: subgraphs_publish and subgraphs_unpublish are no longer registered, and subgraphs_list no longer reports visibility or a publicUrl.
  • HTTP: POST /api/subgraphs/:name/publish and .../unpublish are deleted and 404 in every mode. The PUBLIC_NAME_TAKEN error code retires with them.

Reads are unchanged: rows still come from /api/subgraphs/<name>/<table> on your instance, and the open /v1/subgraphs directory still serves what it served. Generated subgraph specs now default their server URL to http://127.0.0.1:3800 instead of the hosted API, so a spec produced on a self-hosted instance describes that instance.

Deprecated CLI aliases are gone

No shim, old spellings error instead of quietly working. One door per capability now:

−$secondlayer subgraphs cancel
+$secondlayer subgraphs stop
−$secondlayer streams pull
+$secondlayer streams dumps
−$secondlayer subgraphs codegen
+$secondlayer codegen subgraph
−$secondlayer subgraphs client
+$secondlayer codegen client
−$secondlayer index codegen
+$secondlayer codegen index
−$secondlayer contracts generate
+$secondlayer codegen contracts

subgraphs codegen --payloads is now codegen prints. contracts gen was the same alias, and the contracts group held nothing else, so it's gone too.

codegen subgraph now defaults --target to kysely, matching every other codegen surface. The retired alias defaulted to prisma, pass --target prisma if you relied on that.

Deprecated SDK method names are gone

Each call has one name now: subscriptions.recentDeliveries() is subscriptions.deliveries(), subscriptions.requeueDead() is subscriptions.requeue(), subgraphs.get() is subgraphs.status(). subscriptions.get(), contracts.get(), and the standalone getSubgraph() export are untouched.

INSTANCE_TOKEN is read as your primary credential

Precedence is now an explicit apiKey option, then INSTANCE_TOKEN, then SL_API_KEY, matching what the docs have always said. Before this, exporting INSTANCE_TOKEN authenticated as nobody with no error, the quietest way a documented happy path can fail.

SL_API_KEY still works, nothing breaks, but following the docs now works too. Setting both to different values warns once on stderr and INSTANCE_TOKEN wins; secondlayer init writes identical values, so it never warns.

September 2026 inverted that alias: SL_API_KEY is a one-release hosted alias of SECONDLAYER_API_KEY, not of INSTANCE_TOKEN. init no longer writes the hex token there.

MCP

Deprecated tool aliases are gone and subgraphs_get is renamed, every tool name is now <product>_<verb> with one spelling: scaffold_from_contract → subgraphs_scaffold, get_contract_abi → contracts_get_abi (last cycle's alias is retired for real now), subgraphs_get → subgraphs_status (matching sl.subgraphs.status() and secondlayer subgraphs status). Update any pinned tool name, the server no longer registers the old one.

INSTANCE_TOKEN is also read as your primary credential here, see the SDK entry above, the precedence change applies wherever the MCP server resolves a token.

Account tools (account_whoami, account_create_key) register only when the server is pointed at https://api.secondlayer.tools. A self-hosted instance does not show them. Docs and client examples lead with INSTANCE_TOKEN. September 2026: SL_API_KEY is a hosted alias of SECONDLAYER_API_KEY, not of the instance token.

POST /api/keys from an API key always mints an account key. Requesting streams or index on that path is a 400. Dashboard sessions can still mint scoped keys. Existing scoped rows still authenticate.

Hosted subgraphs and webhooks are retired

/api/subgraphs and /api/webhooks now 404 wherever the archive used to run them. Deploying handler code and delivering webhooks was never something a shared box should have been doing on your behalf. Self-host and nothing changes: it's still your subgraph, your subscription, your infrastructure end to end.

Since reversed: hosted subgraphs returned in October 2026, private to your account. See Deploy a hosted subgraph.

Pay-per-call is gone

The x402 rail is removed. Reads on your instance were never metered by us, and the rail shipped a hardcoded price list you couldn't override, the opposite of "you are the merchant." Removed from the SDK: withX402, createX402Client, payAndRetry, buildSignedX402Payment, readX402Challenge, readX402Receipt, selectOffer, resolveAccountNonce, X402SpendGuardError, and the @secondlayer/sdk/x402 subpath. Also gone: @secondlayer/stacks/x402, @secondlayer/shared/x402, the /v1/x402/* routes, and wallet-owned deploys with their 7-day expiry. Archive credits are unchanged. Hosted Index/Streams reads at api.secondlayer.tools later draw the same prepaid balance.

Archive fetches now quote before they charge

Point secondlayer bootstrap or secondlayer repair at the official archive and you see the price before anything is charged: partitions, dollars, and your balance, in the same confirm prompt you already use. Fetches pull signed partitions over short-lived presigned URLs and every partition is digest-checked before a row is written. Your first six repair bundles each month are free. Your own mirrors and local archives never touch the gate, and secondlayer verify stays free with no account, always. The tradeoff is plain: restoring from our archive costs credits; everything you run yourself costs nothing.

Uninstall without losing your index

secondlayer uninstall stops the stack and leaves your data where it is. Containers, networks, and the handler cache come down; the index, chainstate, secrets, and backups stay. It prints the plan and changes nothing until you pass --apply.

$secondlayer uninstall # dry run: what would go, what would stay
$secondlayer uninstall --apply

Wiping the volumes is a separate, guarded decision: --purge refuses to run unless --backup <dir> points at a bundle proving your keys exist somewhere else.

Backup and restore exit when they finish

Both commands close the database pool, so they return to your shell instead of hanging after the work is done. Scripts and CI jobs that waited on them no longer need a timeout.

Back up and restore your instance

secondlayer backup writes an encrypted bundle; secondlayer restore reads it back. Both carry a secrets-key canary, so restoring with the wrong key fails immediately instead of leaving you with subscription secrets nobody can decrypt.

Forward-only installs stop looking broken

An instance now records where its history starts. If you never bootstrapped genesis, the missing prefix reads as deliberate rather than as a gap in every check. Set SECONDLAYER_SYNC_START_HEIGHT to declare it.

One starter, generated from your contract

secondlayer subgraphs create no longer ships five opinionated templates behind --template. It emits a single starter, and the path worth using is --from-contract <id>, which infers sources, schema, and handlers from the contract's observed print events.

Default client hits your local API

new SecondLayer() and createStreamsClient() now talk to http://127.0.0.1:3800. Override with baseUrl or SL_API_URL. Archive dumps stay on the public signed bucket.

One account model: yours

Plans, tiers, projects, and API-key tiers are gone from every surface. Deploys are open on any instance (no trial, no quota, no visibility flag), and archive bootstrap/backfill draws prepaid archive credits. Hosted Index/Streams reads at api.secondlayer.tools later use that same balance. Removed with them: client.apiKeys, client.projects, index.usage(), subgraphs.publish(), and BYO databaseUrl deploys (typed ORM codegen for drizzle/prisma/kysely stays). Your schema, your Postgres, your instance: the SDK now assumes nothing else.

Buy archive credits from the CLI

secondlayer credits buy --email you@example.com --pack 25 opens a one-time card checkout. secondlayer credits balance shows the prepaid amount. secondlayer credits refill --below 5 --pack 25 turns on auto-refill; default is off.

One container next to Postgres

Self-host is postgres + secondlayer. secondlayer start --print writes the compose line. NETWORK, DATABASE_URL, NODE_MODE, DATA_DIR, API_PORT, and INDEXER_PORT are the required non-secrets. Optional --profile stacks-node or full-node bundles chain daemons.

$secondlayer init --network mainnet
$docker compose -f docker/oss/docker-compose.yml up -d
$secondlayer verify all --against <manifest>

Point verify at raw, a decoder, or a subgraph

secondlayer verify takes all, raw, decode:<name>, or subgraph:<name>. Default is raw. --quick hashes identity columns. --deep also recomputes semantic digests. --anchor requires a verified archive signature.

$secondlayer verify decode:ft_transfer --against ./snapshot.json --deep

A target with no matching archive dataset exits 2: not being able to check is not a pass.

Run the runtime without an account

secondlayer init writes the keys for a machine you operate. secondlayer bootstrap restores verified history. secondlayer observer prints the stanza your Stacks node posts to.

$secondlayer init --network mainnet
$secondlayer bootstrap --against <manifest>
$secondlayer observer

No login. No project. secondlayer instance is gone; those three verbs are top-level.

Mint mock sBTC on your devnet

secondlayer devnet faucet gives you sBTC to test against, locally:

$secondlayer devnet faucet --to ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM

No Bitcoin, no regtest, no waiting on someone else's faucet. The sBTC contracts you deployed make you the signer set on your own chain, so minting is just a contract call. Refuses any network but devnet, since elsewhere the signer set isn't yours.

Check your chain data against a signed archive

secondlayer verify tells you whether your history is intact. It compares your database against a signed canonical archive and names the exact height ranges that diverge: missing blocks, broken parent links, duplicate heights, forks left on the losing branch.

$secondlayer verify --against https://archive.secondlayer.tools/.../latest.json

It is read-only and nothing leaves your machine: only digests are compared. The full mainnet chain, 8.7M blocks, checks in well under a minute.

When something is wrong, secondlayer repair fixes it. It shows you a plan first, names every height it would change, and refuses any archive object that does not match its signed digest.

$secondlayer repair --against <archive> # plan
$secondlayer repair --against <archive> --apply # fix

And secondlayer bootstrap stands up a fresh instance from the archive instead of replaying the chain from genesis, and it shows you both timings before it starts, then restores and verifies itself.

All three exit with stable codes for CI: 0 clean, 1 divergence found, 2 unable to verify. That last one never reports as success: not being able to check is different from being fine.

secondlayer verify --semantic recomputes each partition's sha256:semantic-v1 digest locally and compares it to the manifest. The default pass hashes identity columns; --semantic also catches divergence in raw_tx, event data, and function_args.

A published attestations/<digest>/node.json is an independent stacks-node audit of that snapshot. coverage is the attested window; stats is exact; mismatches[] is a capped sample. Transactions and events stay db-reconstructive: the node does not expose them.

Mirror the PoX-5 feed in your own database

secondlayer index codegen refused pox5_events with Unknown Index table: the table was missing from the Index read contract, so the one stacking feed that is still growing after the epoch 4.0 activation was the one feed a BYO mirror couldn't hold. (The closed pox-4 era was covered the whole time.)

All three targets now emit it:

$secondlayer index codegen --target kysely --tables pox5_events

You get Pox5Events for Kysely, pox5Events for Drizzle, and a model Pox5Events keyed on cursor for Prisma, 21 columns including the decoded data payload.

Filter FT transfers by asset from the MCP tool

index_ft_transfers takes assetIdentifier, so an agent can ask for one token's transfers directly:

index_ft_transfers({ assetIdentifier: "SP…sbtc-token::sbtc-token" })

The filter was already on the API, the SDK, and the index_nft_transfers twin; only the FT tool's schema was missing it, which left agents scanning the full feed and paying per row to find one asset.

sbtc.deposits.consume stops pretending to accept fields

index.sbtc.deposits.consume({ fields: [...] }) type-checked and then ignored you: the consume loop built its page request field by field and never forwarded the projection, so you got full rows back either way. The option is gone, so passing it is a compile error instead of a silent no-op. For a projected read, use sbtc.deposits.list or .walk, which have always applied it.

The same gap was fixed for index.events.consume earlier; every Index consume now forwards its filters from one place, so a filter can't be dropped between what you asked for and what the loop requests.

Contract names with underscores work everywhere

my_contract is a legal Clarity contract name, but the principal validation rejected it, so passing SP….my_contract as a principal argument threw, both in @secondlayer/stacks and in the contract interfaces the CLI generates. Those contracts are now usable.

The same change tightens validation in the other direction. A principal carrying more than one dot segment (SP….token.extra) used to slip through: generated code truncated it to SP….token and called a different contract than you asked for, and the ABI guards passed it along. It throws now. Filter builders also check the contract name against the Clarity grammar, so a typo'd id fails where you write it instead of quietly matching zero rows.

parsePrincipal and CONTRACT_NAME_REGEX are exported from @secondlayer/stacks/utils if you want the same check.

Generated files keep the old behavior until you re-run secondlayer generate.

subgraphs.reindex() takes just a name

reindex(name) no longer accepts fromBlock or toBlock. The API stopped supporting a ranged reindex: it answers 400 REINDEX_RANGE_NOT_SUPPORTED, so the old signature type-checked and then failed at runtime.

Reindex is always the whole subgraph: dropped and rebuilt from your startBlock to chain tip. For a specific range, use subgraphs.backfill(), which never drops anything.

The subgraphs_reindex MCP tool drops the same two parameters.

Reindex no longer takes a block range

secondlayer subgraphs reindex drops --from-block and --to-block, and the API rejects either with 400 REINDEX_RANGE_NOT_SUPPORTED.

A reindex drops your subgraph's tables and rebuilds them. The range only ever controlled the rebuild, never the drop, so a narrow range didn't reindex those blocks, it kept them and threw away the rest. The subgraph then looked fine: status back to active, cursor at chain tip, no gaps recorded, nothing logged. Only counting rows against another source showed the history was gone.

Reindex is now always the whole subgraph: every row dropped, rebuilt from your startBlock to chain tip. The confirmation prompt says so plainly. For a specific block range, use secondlayer subgraphs backfill --from-block <n> --to-block <n>. It never drops anything, and it already refuses handlers that would double-count on a replay.

Watch the mempool for one function, not a whole contract

/v1/index/mempool takes function_name. Watching a privileged function on a busy contract used to mean pulling every pending call to it and filtering client-side; now you ask for the one you care about:

$curl http://127.0.0.1:3800/v1/index/mempool \
-G -d "contract_id=SP….my-dao" -d "function_name=execute-proposal"

It composes with sender, type, and the cursor like every other filter. secondlayer index mempool --function-name <name> and functionName on the SDK's mempool.list/walk pass it through. A (contract_id, function_name) index backs it, which also puts an index behind plain contract_id filtering for the first time.

Sorted reads stay fast on deep pages

Indexed columns now get a (column, _id) index alongside the single-column one, matching how /v1 orders sorted reads. On a low-cardinality column, a deep page went from ~17.5 ms and 8,557 buffer reads to ~0.09 ms and 14: Postgres walks the index in final order instead of re-sorting each tie group.

New tables get it on deploy. Existing tables pick it up on their next reindex or schema change, not on a plain redeploy.

Deploy refuses a subgraph source that isn't committed

secondlayer subgraphs deploy now checks whether your definition file is committed to git before it deploys. If it's untracked or has uncommitted changes, you get a prompt in a terminal and a hard failure in scripts and CI, because a deployed definition whose source isn't in version control exists only as a database row, with nothing to review, diff, or restore from.

Pass --allow-uncommitted to deploy anyway; it prints a line saying the check was skipped, so the choice is visible in CI logs. Deploys from outside a git repo, and --dry-run, are unaffected.

Block processing is now replay-proof across restarts

Your subgraph's accumulator tables are guarded against a class of drift that processor restarts could cause: if two catch-up walkers ever overlap on the same block (a restart mid-walk, a leadership handoff), only one commit stands. The block's writes and its checkpoint advance travel in one transaction with a conditional cursor, so a duplicate application aborts cleanly and a stale walker can no longer rewind progress and trigger re-walks. Reorg rewinds still work exactly as before; they take a dedicated path that serializes across processes.

None of this changes your handler code. ctx.increment and friends behave identically; the guarantee is in the runtime underneath them.

Staking post-conditions come through on PoX-5 transactions

/v1/index/transactions and /v1/index/mempool now return the SIP-045 staking and pox post-conditions that arrived with epoch 4.0. Before this, a stake call's post-conditions came back as an empty array, the same answer you'd get for a transaction that genuinely carried none.

Both types sit on post_conditions alongside stx/ft/nft: staking carries a principal, an amount (a string, like every other amount), and the fungible condition code; pox carries a principal and one of will_not_perform, may_perform, or will_perform. IndexPostCondition in the SDK gains both members. Post-conditions are decoded from raw_tx at read time, so every staking transaction already indexed reads correctly now, with no reindex and no migration.

The writing section gets a front page and a proper post frame

/writing now leads with the newest post and its signature figure rendered live as the feature art (the figures are the thumbnails); older posts drop into a two-column ledger beneath. Posts gain a right-hand meta rail on wide screens: number, date, reading time, and a figure index that jumps straight to each figure, and every post now ends with a previous/next pair and the feed, so the writing hands you the next piece instead of just stopping. Margin sidenotes moved to the left margin to make room.

Writings: long-form posts with an interactive figure library

The site grows a /writing section for long-form, mechanism-first guides that don't fit the docs register. The first post, Why your indexer resumes from the same block, walks how consume loops, sinks, and checkpoints work: what the three heights on the health endpoint mean, what a poll costs, and why the cursor parks on a quiet feed.

Posts are built from a 34-component figure library: annotated payloads, position tracks, sequence diagrams, hand-built SVG charts with collision-managed labels, and one interactive explorer per post, all on the site's tokens in both themes, with reduced-motion and keyboard support throughout. There's an RSS feed too.

blocks_behind now means backlog, not event age

Your consumer on a quiet contract used to report tens of thousands of blocks "behind" while fully caught up: the number measured how old the last matching event was, and it only grew. Now ctx.blocksBehind (and consumerHealth's blocks_behind) is tip - scannedHeight: the highest block the sweep has verified, where an empty page counts as the server confirming nothing matches up to the tip. A caught-up tail reads ~0. The new ctx.scannedHeight carries the verified position; ctx.height still marks the last delivered row. Capped server scans only claim the cursor they returned, finalizedOnly stops at the finalized boundary, and reorgs roll the verified position back below the fork.

Bring any database: sinks are now an open surface

Three sinks ship: kyselySink, drizzleSink (Postgres and SQLite, with your schema typing ctx.tx), and bunSqliteSink (one file, zero dependencies, no docker), and the machinery behind them is yours to build on. createSink(driver) from @secondlayer/sdk/sinks/core owns the transaction sequences that make a sink correct; your driver is seven methods of dialect. The contract's 13 invariants are documented on the interface and mechanically probed by a conformance kit at @secondlayer/sdk/sinks/testing, covering torn batches, cursor replay, inclusive >= rollback, and lock contention. It is the same kit every shipped sink passes in CI. Write your own sink.

Append-only stores (ClickHouse, parquet) declare capabilities: { finalizedOnly: true }: the consume loop refuses to follow the unfinalized tip at startup, loudly, instead of corrupting at the first fork. The Postgres advisory lock also moves to an int8 hash, so two unrelated sink ids can no longer collide into a false "another consumer" 409.

The type spelling you meant now works on both feeds

/v1/streams/events?event_type=print and /v1/index/events?types=print both work now: each surface accepts the other's spelling for a single type. Ask the Index for a type set and it refuses with the reason: Index pagination is keyed per event type, by design (a design we wrote down this week). The CLI takes both flags on both commands.

Streams consumers also close two gaps: the top-level consume() iterator takes the labelled filters map, replay() can narrow its live tail, and a literal types: ["ft_transfer"] now narrows row types at compile time the way labels always did. And subgraph factory sources (a router plus pools created after you deploy) can now be authored through the on.* filter vocabulary; every non-subgraph projection refuses the field loudly.

Filter renames stop pretending to be capability gaps

on.stxLock({ lockedAddress }).toIndexParams() works now: the Index stores the locked address as the row's sender, so the field is a rename, not something the surface can't do. Same for caller on contract-call reads: the endpoint filters by tx sender, which is the caller. And trait with contractId fails at projection time with the fix in the message, instead of as a server 400 after the request already left.

Two silent-wrong-results bugs are also gone: streams.events.stream() now forwards the labelled filters map (it used to silently deliver the full firehose, billed per row), and a wildcard inside a contractId array is refused at projection like scalar wildcards always were, instead of reaching the wire as a literal match that returns zero rows.

One day of dogfooding, four fixes

We ran the whole surface from a fresh repo with one API key, the way you would. What broke got fixed the same day:

  • on.ftTransfer({ assetIdentifier }) projected a filter the Index refused: the server accepted asset_identifier for nft event reads but not ft. It now works on all six token event types, and ftTransfers.list takes assetIdentifier directly.
  • pox5/events and sbtc/deposits 500'd, and transactions returned a corrupt cursor, when fields omitted the index column, because pagination machinery was reading projected rows. Cursors and reorg spans now always come from the raw rows.
  • /v1/subgraphs/<name>/<table>?_fields=… without _id returned a full page with next_cursor: null, silently dropping the rest of your rows. _id now drives the cursor server-side whether or not you asked for it.

Field projection reaches the transfer feeds

const { events } = await sl.index.ftTransfers.list({
contractId,
fields: ["amount", "sender", "recipient"],
});
events[0].tx_id; // ✗ not requested, not fetched, not in the type

ft-transfers and nft-transfers, almost certainly the highest-traffic Index reads, now take fields, with the same contract as everywhere else: requested columns come back, a misspelled one is refused, and the row type narrows to match. cursor/block_height always survive, plus event_type because it is the row's discriminant. Omitting block_time also lets the server skip the blocks join entirely.

We previously wrote here that these rows were "5-8 columns" and not worth projecting. That was a measurement error: we counted only the type-specific columns and missed the 12-column base they extend. A transfer row is 17 columns wide, wider than the withdrawals we did project. Corrected.

blocks alone stays unprojected, now for a checked reason: it is 8 columns read off a single table: block_time is a column of the row itself, so unlike the event feeds there is no join for a projection to skip.

Field projection across the Index surface

const { withdrawals } = await sl.sbtc.withdrawals.list({
fields: ["amount", "status"],
});
withdrawals[0].sender; // ✗ not requested, not fetched, not in the type

Deposits, withdrawals, transactions, and pox-5 events all take fields now, each narrowing its row type to what you asked for. Which columns always survive differs per resource, on purpose: a withdrawal is keyed by request_id and has no block_height, a transaction keeps tx_id, and a pox-5 event keeps topic because it is the row's discriminant. Dropping those would leave a row you cannot paginate or identify.

blocks, ft_transfers, and nft_transfers did not take fields in this release. The "5-8 columns" rationale originally published here undercounted the transfer rows; see the correction above. Both transfer feeds have since gained fields.

Field projection reaches sBTC deposits

const { deposits } = await sl.sbtc.deposits.list({
confirmed: true,
fields: ["bitcoin_txid", "output_index", "amount", "tx_id", "block_height"],
});
deposits[0].sender; // ✗ not requested, not fetched, not in the type

Projection shipped on /v1/index/events and stayed there, so every sibling resource returned full rows whether or not you wanted them. The parse-and-strip logic is now shared, which keeps the two rules that matter identical wherever it is applied: a misspelled field is refused rather than quietly ignored, and cursor/block_height always survive so pagination and reorg reporting cannot be projected away.

A reorg is confirmed before it overwrites anything

A block arriving for a height we already hold no longer replaces it on sight. The node's event observer emits competing blocks at the same height routinely, so a hash mismatch says "someone built a different block here", not "the chain moved", and only the second justifies orphaning indexed data. Guessing picked the losing fork twice in one week.

The contender is now staged and the next block decides: a block names exactly one parent, so whichever side it names is the one the chain kept. Cost is one block of latency on a genuine reorg; the alternative was sitting on the wrong chain until someone noticed.

Integrity asks whether the chain joins up

findBrokenLinks reports canonical heights whose block does not descend from the one below it, surfaced as a chain_unlinked status. The previous check only asked whether every height was present, which a chain on a losing fork answers yes to.

Orphaned blocks the canonical chain still links to are now reclaimed automatically, using local data only: if the canonical block at h + 1 names an orphaned block at h as its parent, that block is on-chain by definition.

Status reports progress, not just liveness

/public/status reported subgraph_processor: ok through a seventeen-hour stall: the loop was ticking and heartbeating while making no progress. It now also checks how far the furthest-behind subgraph trails the tip.

Decoded rows and their checkpoint commit together

Every decoder wrote its rows and then its checkpoint as two separate statements; a crash in between re-delivered the batch on resume, survivable only because the writes happened to be idempotent upserts. They now share one transaction. For BNS this also pulls its bns_names projections into the same commit, which previously could lag the events they derive from.

on is the filter union (breaking)

import { on } from "@secondlayer/stacks";
on.ftTransfer({ assetIdentifier: `${SBTC}::sbtc-token` }).toIndexParams({ limit: 50 });

The name used to hold a second, older set of factories keyed on snake_case database column names and typed Record<string, …>, so a typo compiled and silently matched nothing. They had no consumers, and they are gone, along with the Filter / FilterClause / FilterOperator / FilterPrimitive / SubscriptionFilterSpec / BnsAction / FactoryTarget / PoxFunction types.

on at the package root is now the same typed union as @secondlayer/stacks/filters: per-event-type fields, and explicit projections onto every surface. Using on.transferTo(...), on.bnsName(...), or on.poxStack(...)? Build the filter with on.ftTransfer({ … }) and project it onto the surface you're calling.

Field projection narrows everywhere it applies

events.walk({ fields }) now narrows its row type the way events.list already did; it was forwarding fields to the wire, so it yielded stripped rows while the type promised every column. The callable index.events({ fields }) shorthand narrows too. events.consume no longer accepts fields at all: the consume loop never forwarded it, so it type-checked and was then dropped.

The docs, as text an agent can read

  • /llms-full.txt: every docs page in one markdown file, sidebar-ordered, each passage tagged with the page it came from.
  • Any page as markdown: append .md to a docs URL (/docs/streams.md). Generated from the MDX itself, so it can't drift.
  • ?mode=agent now works. It was advertised in llms.txt but only ever set from localStorage, so the URL did nothing. Agent mode also stopped replacing the page, so you get the prompt deck and the reference content.
  • AGENTS.md ships inside @secondlayer/sdk with the five facts you can't infer from the types: reads need no key, cursors are opaque, reorg rollback is >= not >, rows and cursor commit in one transaction, and walk() is not reorg-safe.

MCP

codegen_index_schema emits an ORM schema for the Index tables in-conversation, from the same generator as secondlayer codegen index. get_contract_abi becomes contracts_get_abi (the unprefixed name filed itself under a phantom "get" product in the capability listing); the old name keeps working until the next major.

Read contract state from a handler

import { readContractAt } from "@secondlayer/subgraphs";
const token = readContractAt(ctx, contractId, SIP010, {
cache: "contract-constant",
});
const decimals = await token.read.getDecimals({}); // bigint, typed from the ABI

Some facts aren't in any event (a token's decimals, a pool's reserves), so amount stayed a bare integer nobody could render. Now a handler can ask the contract, with the ABI's camelCased read methods and no call (a handler indexes the chain, it never writes to it).

  • Pinned to the block being processed. The call carries that block's index_block_hash, so a handler is a pure function of its block and a reindex produces byte-identical rows. A block whose id was never persisted throws rather than silently reading at the node's tip.
  • Cached in Postgres, keyed on the block id, so a reorg-replaced block can't inherit the orphaned fork's answer. Pass cache: "contract-constant" for values that genuinely can't change and it is fetched once, ever; that is what makes a full backfill affordable.
  • Unit tests stay offline: createTestContext(schema, { reads }) stubs reads by "<contract>.<function-name>".
  • Self-hosting needs STACKS_NODE_RPC_URL on the subgraph processor: the node you already run, not a new service.

The sip-010-balances starter now labels tokens with their real symbol and decimals.

Track two things in one loop

await sl.streams.events.consume({
filters: {
peg: { types: ["ft_transfer"], assetIdentifier: `${SBTC}::sbtc-token` },
treasury: { types: ["stx_transfer"], sender: TREASURY },
},
on: {
peg: (events) => credit(events), // narrowed to ft_transfer
treasury: (events) => debit(events), // narrowed to stx_transfer
},
});

A flat filter is one AND-ed set, so two unrelated concerns meant two consume loops, two cursors, and two checkpoints, or fetching the union and discarding half of it. Name each concern and the server ORs them into a single scan.

  • Each label's declared types narrows its handler, so event.payload is typed without an event_type guard, and adding a label to filters is a compile error until on handles it.
  • One cursor covers every label. The position is computed over the block's full event set, so you can add or drop labels between restarts and keep the checkpoint.
  • Over REST it is one JSON query param, and each event carries the labels it matched. SSE takes the same param, so subscribe narrows the same way.

Ask for the columns you want

const { events } = await sl.index.events.list({
eventType: "ft_transfer",
fields: ["recipient", "amount"],
});
events[0].asset_identifier; // ✗ not requested, not fetched, not in the type

The server projects the SELECT, so an unrequested column is physically absent, and now the row type says so, making a read of one a compile error instead of undefined at runtime.

  • Omitting block_time lets the server skip the blocks join entirely. It is not a stored column but a to_timestamp() off a join taken on every read, so this is a planner-level saving on wide sweeps.
  • cursor, block_height, and event_type always come back, because pagination and the union discriminant don't depend on what you asked for. Reorg reporting is unaffected too.
  • It does not change your bill: Index meters per row, not per field.

Run a handler before you deploy

$secondlayer subgraphs test subgraphs/bns-names.ts --from 167484 --to 167600
$secondlayer subgraphs test subgraphs/bns-names.ts --offline

Real chain events, your local handler code, no deploy. The first run records a cassette so later runs are offline and free; changing a source filter discards it rather than passing against data your subgraph would no longer request. If events arrive and your handlers write nothing, it fails, and that is the shape of the field-mapping bug that ships a 0-row subgraph.

For unit tests, @secondlayer/subgraphs/testing gives you the same context the runtime uses, backed by memory, so read-your-writes, increment deltas, and upsert merging behave exactly as they do in production.

Index a contract set, including one that grows

contractId takes an array, so a router plus its pools is one source and one handler. For pools created after you deploy, a factory discovers addresses from another source's events:

swaps: {
type: "print_event",
topic: "swap",
factory: { from: "registry", field: "data.pool" },
}

A pool discovered in block N receives its own block-N events, and the discovered set rolls back on a reorg like any other chain-derived state.

Hoistable schemas, and balances that don't lose updates

defineSchema() lets you pull the schema out of the call and keep helpers fully typed. The sip-010-balances starter no longer reaches for ctx: any; it moves balances with ctx.increment, which applies a delta atomically instead of a read-modify-write that silently loses same-block updates.

Source filters are now validated as a real discriminated union, so a field that a source type doesn't support fails at deploy instead of matching nothing forever.

prints could only describe flat fields, so a nested payload had to be declared "jsonb". That is how a handler reading flat data.name type-checked, deployed, and decoded to null on every event while BNS-V2 emitted name as a nested tuple: zero rows chain-wide, while the subgraph tailed happily at the tip.

prints: {
"name-register": {
name: { tuple: { name: "text", namespace: "text" } },
owner: "principal",
memo: { type: "text", optional: true },
},
}
// event.data.name.namespace → string
// event.data.memo → string | undefined
  • Declaring prints also turns on runtime validation: an event that does not match is skipped and logged instead of written as nulls. It never throws, since a block that can't advance would wedge your checkpoint.
  • Deploy now refuses (rather than warns) when a handler on a prints-declaring source reads a field no observed event carries.
  • The abi on a source is validated at deploy against the real Clarity ABI shape. A raw Hiro or Clarinet ABI used to pass and then mis-decode event.input forever; the error now names the fix.

One verb for generating code

secondlayer codegen contracts | subgraph | index | client | prints replaces six entry points under three verbs. --target means the same thing with the same default everywhere; previously secondlayer subgraphs codegen -o db.ts wrote Prisma while the identical-looking secondlayer index codegen -o db.ts wrote Kysely. Old paths still work, print a deprecation notice, and keep their original defaults so your output doesn't change.

  • Scaffolds now emit the contract's as const ABI and reference it, so handlers read event.input.tokenId instead of event.args[0] as bigint.
  • Generated row types are derived from the schema instead of a second hand-written map that answered uint with number, which didn't compile against the client it shipped beside, and truncated uint128.

Sinks: your indexer is one file now

kyselySink owns the three hard parts of a durable indexer (checkpoint persistence, rows+cursor atomicity, and reorg rollback), so your handler is just the writes:

const sink = kyselySink(db, { id: "sales", tables: ["sales"], height: "block_height" });
await new Index().contractCalls.consume({
contractId: MARKETPLACE, functionName: "purchase-asset", fromHeight: 0,
sink,
signal: shutdownSignal(),
onBatch: (calls, _env, ctx) =>
ctx.tx.insertInto("sales").values(calls.map(toSale)).execute(),
});
// no onReorg, no checkpoint table, no SIGTERM block
  • Rows and cursor commit in one transaction; a crash mid-batch aborts both and the batch is simply re-read. Reorg rollback is automatic and unconditional, so forgetting onReorg can no longer skip reorgs silently.
  • ctx.tx is a fully typed Transaction<DB>; a table missing the height column fails loudly at startup, not silently at reorg time.
  • consumerHealth() answers your platform's liveness probe (page recency, never lag, since a genesis backfill is millions behind and healthy) and shutdownSignal() finishes the in-flight batch on SIGTERM.
  • On Streams, decoded: true delivers events as the same flat rows Index serves; the eleven guard+decode pairs are deprecated.
  • The flagship sales-index example dropped from 167 lines across three files to one 61-line file, and CI keeps it that way.

Write the filter once: on.*

One chain event used to be spelled differently on every surface: eventType on Index, types arrays on Streams, a sources entry on subgraphs, a trigger object on webhooks, with three incompatible minAmount types among them. The new @secondlayer/stacks/filters module is the single spelling, projected explicitly to each surface:

import { on } from "@secondlayer/stacks/filters";
const usdc = on.ftTransfer({ assetIdentifier: USDC, minAmount: 1_000_000n });
sl.index.events.list(usdc.toIndexParams({ limit: 100 }));
sl.streams.events.consume({ ...usdc.toStreamsParams(), onBatch });
sl.webhooks.create({ name, url, triggers: [usdc.toChainTrigger()] });
defineSubgraph({ sources: { usdc: usdc.toSubgraphSource() } });
  • A surface a filter can't reach is a missing method: a compile error, not a runtime surprise. A field a surface can't express throws at projection time with a message naming the surface that supports it.
  • Amounts are bigint end to end and stringify at exactly one boundary, so a spread bigint can never crash JSON.stringify again.
  • Typos fail at construction: a contract id where an asset identifier belongs, or a malformed principal, is an immediate error instead of a silent zero-row query.

Add an index without losing your data

Flipping indexed: true (or search) on a column of a populated subgraph now builds the index in place with CREATE INDEX CONCURRENTLY, so it no longer counts as a breaking change, so it no longer costs a DROP SCHEMA and a full reindex. The most routine operation in an indexer's life is now a few seconds of DDL instead of hours of backfill.

  • uniqueKeys changes are no longer invisible to deploys: additions run ALTER TABLE … ADD CONSTRAINT in place, and removals are correctly refused as breaking. Previously a uniqueKeys addition reported success, created nothing, and every later upsert failed against a constraint that didn't exist.
  • Handler-only redeploys now warn that new logic applies from the current tip: rows already indexed keep the previous handler's output unless you reindex.
  • The CLI confirms destructive deploys before sending the request. The old prompt fired after the server had already dropped your data.

findMany({ fields }) returns what it says

Requesting specific fields now narrows the row type to exactly those columns. The server always projected the SELECT, so unrequested fields were physically absent while TypeScript promised they existed, so reading one is now a compile error instead of a runtime undefined.

A 429 no longer kills your backfill

Both consume loops now retry the page fetch on rate limits, server errors, and network failures, honoring the server's Retry-After, so one transient error hours into a genesis sweep no longer costs you the process. Retry wraps only the fetch: your onBatch/onReorg code and real 4xx mistakes still fail fast.

  • Tune with retryCount / retryDelay (the same knobs as @secondlayer/stacks transports); watch with the onError observer.
  • One error family: RateLimitError, AuthError, and ValidationError are now ApiError subclasses thrown by every surface, so a single instanceof check works around Index, platform, and Streams calls alike.
  • Errors carry retryable, retryAfterSeconds, a docsUrl pointing at the fix, and walk() for cause-chain inspection.

finalizedOnly consumers can no longer skip events

Returning envelope.next_cursor from a finalizedOnly consumer's onBatch now throws instead of silently committing past events that were never delivered. The filtered unfinalized tail is re-read once it settles; a cursor above it would have dropped those events forever, with reorg handling skipped in that mode. The loop now catches the one wrong return it can detect.

  • fetchImpl is honored by every platform client, so you can inject a fetch for tests, proxies, or an x402-wrapped payer without monkey-patching the global.
  • x402 payments and proof verification no longer touch Buffer, so both work on edge runtimes like Cloudflare Workers.

Read PoX-5 events at /v1/index/pox5/events

The decoded pox-5 log is now queryable. One row per print event across all 19 topics, with the full decoded tuple in data, so nested shapes like btc-lockup, bond-rewards, and bond-periods come through intact rather than flattened away.

  • Filter by topic, staker, signer, signer_manager, bond_index, or reward_cycle, and page with the same <block_height>:<event_index> cursor the rest of Index uses.
  • ?confirmed=true clamps the window to the finality boundary, so settlement consumers only see rows past the reorg margin.
  • Every response carries tip and a reorgs[] array for the page's height range, so you can reconcile anything you already committed from a fork.

PoX-5 events, decoded from activation block one

The indexer now ships a PoX-5 decoder alongside the existing PoX-4 dataset. Every print event the new pox-5 boot contract emits, all 19 topics, from stake and register-for-bond through claim-rewards and signer grants, lands in a queryable pox5_events dataset with typed columns for stakers, signers, bonds, amounts, and cycle windows, plus the full event tuple as JSON.

  • Nothing to configure: the decoder is on by default and idles until the Epoch 4.0 fork activates at Bitcoin block 960,230. Your first pox-5 event is decoded the moment it exists.
  • Self-hosting? Raw events are always stored, so upgrading after the fork backfills the dataset with zero data loss, and no re-sync from your node.
  • @secondlayer/stacks now exports POX5_EVENT_TOPICS, Pox5EventTopic, and POX5_CONTRACT_ID_MAINNET for building your own event tooling against the same verified topic set.

Mirror the sBTC peg with a checkpointed consumer

index.sbtc.events.consume() and index.sbtc.deposits.consume() bring the decoded peg feed the same cursor commit, reorg rewind, and progress context every other Index loop has. Server-side topic filtering survives pagination, so following one topic no longer means pulling all six.

  • Building a durable sBTC index used to mean consuming raw print events off the registry contract and matching topics yourself, losing the typed columns.
  • withdrawals has no consume on purpose: the row is a lifecycle aggregate that mutates as a peg-out settles, and a forward-only cursor would commit it once and never see it change. Consume the append-only events instead; see sBTC settlement.

Follow a whole protocol on one cursor

contractId now takes an array on index.events and index.contractCalls. sBTC is four contracts; a v3 → v4 migration is two. One consumer, one checkpoint, one answer to "how far is my index complete".

  • Up to 20 ids, still mutually exclusive with trait; reach for trait when you mean "every contract of a standard", since it picks up contracts deployed after you ship.

Rows match the event type you asked for

index.events.list, .walk, and .consume are generic over the eventType literal, so consume({ eventType: "ft_transfer" }) hands onBatch an IndexFtTransfer[] with amount and sender reachable directly.

  • The if (e.event_type !== "ft_transfer") continue line every handler opened with, written only to satisfy the compiler, is gone.
  • A non-literal eventType still yields the union, so dynamic callers are untouched.

Every batch tells you how far along it is

onBatch now hands you height, tipHeight, and blocksBehind alongside cursor, on both the Index and Streams consumers. Answering "is my indexer still moving" no longer means tracking it yourself.

  • height survives empty pages, so a consumer caught up at the tip keeps reporting its position instead of dropping to nothing.
  • After a reorg it rolls back with the chain, to the last block still canonical, the case a hand-rolled at(-1) gets wrong.
  • Wire it straight into a health check: see Deploy for why your process needs one and what to gate it on.

Prove a Bitcoin payment without naming a contract

verifyBitcoinPayment resolves the reference spv-adapter on mainnet, so contract is now optional. Build a proof, pass it, get verified, with no adapter principal to look up, and no verifier contract of your own to deploy first.

  • The adapter is a read-only wrapper over the SIP-044 built-ins, so it goes live with the Epoch 4.0 fork at Bitcoin block 960,230. Reads before the fork still need Clarinet simnet.
  • Stacks testnet has no Epoch 4.0, so the built-ins don't exist there. Pass an explicit contract on testnet, and the thrown error now says exactly that instead of telling you to wait for an activation that isn't coming.
  • SPV_ADAPTER_CONTRACTS and getSpvAdapter(network) expose the principal if you'd rather pin it yourself.

One constant for the Epoch 4.0 activation height

EPOCH_4_ACTIVATION_BURN_HEIGHT_MAINNET is Bitcoin block 960,230. Epoch 4.0 carries both SIP-044 (the Bitcoin SPV built-ins) and SIP-045 (pox-5 Bitcoin Staking) share one fork and one height, so the bitcoin and pox5 modules read it from the same place and can't drift apart.

  • Only mainnet has a fixed height. Elsewhere, read it from the node with getPox5Activation or pass it explicitly.

Clients pick up SL_API_KEY from your environment

Export SL_API_KEY and new Index() uses it, the same variable the CLI and MCP server already read. No more constructing a client keyless by accident, then hitting 402 PAYMENT_REQUIRED the first time you reach past the free 24-hour window.

  • An explicit apiKey still wins. Pass apiKey: "" to force keyless reads on a machine that has a key exported.
  • Exported as resolveApiKey if you want the same precedence in your own code, and guarded for browsers and edge runtimes where process is undefined.

Name any decoded event, not just the union

IndexEvent is a union of eleven shapes, and until now you could name the union but none of its members. Every variant is exported from the package root: IndexFtTransfer, IndexNftTransfer, IndexStxTransfer, IndexStxMint, IndexStxBurn, IndexStxLock, IndexFtMint, IndexFtBurn, IndexNftMint, IndexNftBurn, and IndexPrint.

  • Write the handler signature you actually want, function onTransfer(e: IndexFtTransfer), instead of taking IndexEvent and re-narrowing inside every function.
  • The consumeIndexFeed types come with it: IndexFeedItem, IndexFeedEnvelope, and IndexFeedFetcher are all importable from @secondlayer/sdk, so you can type a custom fetcher or hold a feed item in a variable without reaching into a subpath.
  • Purely additive: nothing was renamed or removed.

PoX-5 events from sl.index.pox5

The decoded pox-5 feed has a typed client. sl.index.pox5.events.list() for a page, .walk() to stream every event across pages without writing cursor logic yourself.

  • Filters map to the endpoint one-to-one: topic, staker, signer, signerManager, bondIndex, rewardCycle, plus confirmed to stay behind the finality boundary.
  • amount_ustx and amount_sats stay strings, so stacking amounts past Number.MAX_SAFE_INTEGER survive the round trip intact.
  • Types are importable from the package root: IndexPox5Event, IndexPox5EventTopic, Pox5EventsEnvelope, and the list/walk param shapes.

Chain webhooks decode and narrow from one import

decodeChainWebhook is now reachable from the SDK root. Verify a delivery's signature, hand the same raw body to the decoder, and switch on data.trigger with your event fully narrowed, with no hand-rolled parser and no guessing which fields a print_event versus a contract_call delivery carries.

  • The result type comes with it: import type { ChainWebhookDelivery } from @secondlayer/sdk alone. Every piece the union composes is exported too: the per-trigger Chain*Data shapes, ChainEventEnvelope, ChainTxLevelEvent, ChainReorgRollbackDelivery, and the sBTC deposit and withdrawal payloads.
  • The decoder validates as it narrows. A body that isn't a chain.* delivery, or whose type disagrees with data.trigger, throws instead of quietly handing you a mistyped object.
  • WebhookHeaderInput and StandardWebhooksHeaders are exported as well, so you can type your own framework adapter around verifyWebhookSignature.

PoX-5 calls, fully typed

Every client.pox5.* action and read now runs through the module's committed pox-5 ABI: arguments are checked at compile time, so a wrong name or type is a tsc error rather than a failed transaction, and reads return plain JS values instead of raw Clarity envelopes.

  • Reads decode for you: uint becomes bigint, tuples become camelCase objects, and an optional read returns null when the record doesn't exist. getStakerInfo(...) hands you { amountUstx, firstRewardCycle, numCycles, signer } | null directly.
  • One breaking change: the 12 individual reads no longer return ClarityValue. getStakerState is unchanged.
  • The ABI is machine-extracted from the pox-5 boot contract and guarded by a simnet drift test, so a stacks-core contract change fails our build, not your integration.

Typed Clarity narrowing, cheaper signing

cvToJSON returns any, which switches the compiler off at the exact boundary where Clarity meets your code. Five new helpers (cvToBigInt, cvToString, cvToBuffer, cvToBoolean, cvToPrincipal) narrow a ClarityValue to a typed primitive or throw with a message naming the mismatch, so a wrong assumption fails at the call site instead of three functions later.

  • Signing is cheaper: transaction serialization is memoized per object, so multi-sig and sponsored flows no longer re-serialize the same transaction on every signer step. Wire bytes are unchanged.
  • Multi-sig metadata survives the wallet round-trip: _multisig signer keys set at build time carry through provider signing, so auto-detection keeps working after stx_signTransaction returns.
  • cvToJSON and cvToValue are untouched, so existing code keeps working.

PoX-5 Bitcoin Staking, two weeks before the fork

Build against SIP-045 today: the new @secondlayer/stacks/pox5 module ships every staking flow (bonds, STX staking, L1 lockup scripts, signer-key grants), pinned against the final pox-5 contract in stacks-core 4.0.0. Your integration gates itself: client.pox5.isActive() asks the chain, so code you ship now simply switches on when Bitcoin block 960,230 lands (~July 29).

  • 13 wallet actions that inherit fee tiers, nonce management, and typed errors; getStakerState() reads a staker's whole position in one batched call.
  • Off-chain tooling works pre-activation: CLTV lockup scripts and P2WSH addresses, SIP-018 signer grants, cycle and bond-phase math.
  • Not a hand-transcribed ABI: scripts and grant hashes are byte-compared against the actual boot contract in Clarinet simnet, and every action is pinned to the contract interface through the real signing path.

See the PoX-5 staking docs.

More node data, fewer silent failures

New actions round out the read surface, and a fixed-up transport catches errors it used to swallow: getContractSource, getRawBlock, getAccountHistory, getMempoolStats, and getNftHoldings join the SDK's action set, and every read now throws instead of quietly treating a failed request as real data.

  • getContractSource and getRawBlock read straight from a stacks-node (Clarity source, raw block headers) for cases the indexed extended API can't serve.
  • getAccountHistory, getMempoolStats, and getNftHoldings, promoted out of the AI-tools layer into first-class actions, also available on client.*.
  • The HTTP transport throws a typed error on any failed request and now retries rate limits (429), not just server errors, so a 404 body no longer gets parsed as if it were a successful response.

Bitcoin addresses without the Bitcoin baggage

Derive your paired BTC account, the bc1q…/bc1p… addresses Leather and Xverse show next to your Stacks address, from the same mnemonic, with zero new dependencies. And ask the chain, not a config file, where sBTC deposits go: client.sbtc.getSignersAddress() derives the signers' taproot address from the on-chain registry, encoded for whatever network your client points at.

  • mnemonicToBitcoinKeys covers BIP84 (native segwit) and BIP86 (taproot) paths, validated against the official spec vectors.
  • Pubkey→address helpers (publicKeyToP2trAddress, taprootTweakPubkey) ship in @secondlayer/stacks/bitcoin: pure derivation, no tx signing.
  • Network-aware end to end: mainnet, testnet, and regtest encodings, so a devnet integration never shows you a mainnet address by mistake.

Send, price, and confirm transactions in one flow

Pass fee: 'min' | 'low' | 'mid' | 'high' to any send action and await the result with client.waitForTransactionReceipt({ txid, confirmations }), with no more hand-rolled fee estimates or polling loops. 'min' is the node's relay floor (1 uSTX per byte), computed offline, and it's also the automatic fallback when the node can't produce an estimate, so your broadcast goes through instead of erroring.

  • waitForTransactionReceipt returns a normalized receipt with the decoded Clarity result, rejects with typed errors on abort/drop/timeout, and recounts confirmations if a reorg moves your transaction.
  • Status sources are pluggable: the default reads any extended-API host; indexTxSource() reads Secondlayer's index and gets the chain tip in the same response, so N-confirmation waits cost one request per poll.
  • sendTransaction({ wait: 2 }) broadcasts and confirms in one call; x402 settlement can now await the payment landing before reporting it.
  • Broadcast rejections are typed: BroadcastError.reason narrows across all 26 stacks-node rejection strings, with the node's reason_data attached.

Watch mode and dependency types for contract codegen

secondlayer contracts generate --watch regenerates your typed clients the moment a .clar file, your config, or Clarinet.toml changes, so leave it running next to your editor. And the clarinet() plugin now types the contracts you depend on, not just the ones you wrote: everything under [project.requirements] gets a typed client by default (includeRequirements: false to opt out).

  • Generated output now ships named type aliases per function (TokenTransferArgs, TokenTransferResult) and a TypedAbi-branded ABI const, so hovers and type errors name your types instead of dumping expanded conditionals.
  • Generated imports target real @secondlayer/stacks subpaths; previously emitted root imports didn't resolve.
  • Breaking: the actions() codegen plugin is gone. It generated code against an API surface that no longer exists; the generated contract objects and getContract cover the same ground, typed.

Reorg-safe contract lookups

GET /v1/contracts/:contractId no longer serves contracts that were reorged out of the canonical chain. A reorg flips the registry row non-canonical, and the moment the deploy reappears on the new fork, discovery restores it automatically, so your fetched ABIs survive the round trip.

  • Trait discovery (/v1/contracts?trait=) and as-of-block trait resolution already excluded reorged-out contracts; the by-id read now agrees with them.
  • The burnchain reward tables (burn_block_rewards, burn_block_reward_slots) drop their never-enforced canonical column; replace-per-height is the documented reorg contract for burnchain data. If your secondlayer index codegen mirror includes these tables, re-run codegen and drop the column; it was always true, so nothing is lost.

Named types on hover, unsigned transactions on demand

Your generated ABIs now carry their own type names. getContract picks up the TypedAbi brand emitted by secondlayer contracts generate, so hovering contract.call.transfer shows TokenTransferArgs, cmd-clickable and readable in errors, instead of a wall of inferred conditionals. Hand-written as const ABIs keep working exactly as before.

  • New contract.buildCall.* namespace builds unsigned transactions for wallet-signs-later flows; it never broadcasts, and fee and nonce auto-resolve when you omit them. Plus publicKeyToAddress in @secondlayer/stacks/utils.
  • jsToClarityValue takes pre-built ClarityValues anywhere, and buffer args accept hex strings, Uint8Array, or tagged { type, value } objects.
  • Read methods on (response ok err) outputs now type as the ok value, so no more unknown leaks out of typed reads.

SIP-045 staking post-conditions, ready before the fork

@secondlayer/stacks@2.10.0 speaks the Epoch 4.0 wire format ahead of the Bitcoin Staking hard fork (SIP-045, targeted ~July 29). The transaction codec decodes and encodes the two new post-condition types (0x03 Staking and 0x04 PoX), byte-identical to the reference implementation, so your decoders don't break the day pox-5 transactions hit the chain.

  • Protect staking calls with staking-postcondition (principal + amount bound, evaluated on stake, register-for-bond, stake-update) and pox-postcondition (will-not-perform / may-perform / will-perform, evaluated on unstake, announce-l1-early-exit, and other non-locking PoX calls).
  • The deserializer now fails loud on unknown post-condition types instead of silently misreading everything after them, so malformed or future-format transactions surface as errors, not corrupt data.
  • ClarityVersion.Clarity6 ships for Epoch 4.0 contract deploys (SIP-044).

Type names mirror stacks.js, so post-conditions written for either SDK are portable. See the Stacks SDK docs.

Build your app index with consume()

Index now ships the loop. index.events.consume() and index.contractCalls.consume() are checkpointed consumers over decoded rows, with no node, decoders, or sync loop to hand-roll.

  • Write your rows inside onBatch, return the cursor you committed, and crash-restart resumes exactly there.
  • Reorgs rewind the cursor to the fork point automatically.
  • finalizedOnly holds delivery to rows at or below tip.finalized_height (which the Index tip now reports).
  • fromHeight: 0 backfills from genesis.
  • Runnable example: sales-index indexes every Gamma marketplace sale into your own Postgres in ~50 lines, then graduates to the same table as a one-file Subgraph.

Empirical print-event schemas

Clarity ABIs don't describe print payloads, and the shape varies per topic within one contract. New GET /v1/index/contracts/:id/print-schema infers each topic's real payload schema from indexed history: sampled print events deserialized from raw Clarity, with exact Clarity types, TS types, and subgraph column types per field.

  • sl subgraphs create <name> --from-contract <id> scaffolds a fully typed subgraph (per-topic prints maps to typed event.data; --table-per-topic to normalize).
  • codegen --payloads emits the payload types as a .d.ts.
  • Deploys now warn when a handler reads a field never observed on-chain.
  • SDK: sl.index.printSchema(). MCP: index_print_schema.

PoX-4 stacking decoded

Index now decodes PoX-4 stacking. stack-stx, delegate-stx, and reward actions are normalized into typed rows at /v1/index/stacking; filter by stacker, caller, or PoX function with no node and no decoders of your own.

Keyless reads, plus pay-as-you-go credits

Index reads need no API key. Anonymous requests work with wildcard CORS, and a free-tier key reads Index too (never slower than anonymous).

  • Free and anonymous reads cover the recent 24-hour window; a read into older history returns 402 UPGRADE_REQUIRED.
  • Top up prepaid credits with a card (POST /api/billing/topup, packs $10–$100) to read older history without a plan.
  • A positive balance reads beyond the window, unthrottled, debited $5 per 1,000,000 rows across both Index and Streams from one shared balance, with the prepaid balance as a hard cap.
  • Public chain data stays public.

Trait-filtered event queries

Restrict any decoded event type or contract call to a SIP standard with ?trait=. sip-010, sip-009, and sip-013 scope results to conforming contracts without enumerating every contract id.

Aggregates

Roll up a subgraph table server-side instead of paging every row. The new /aggregate endpoint runs count, sum, min, max, and distinct counts over the same filters as the list endpoint.

const stats = await sl.subgraphs.sbtcFlows.transfers.aggregate({
where: { sender: "SP3PE7Q9..." },
sum: ["amount"],
countDistinct: ["recipient"],
});

sum/min/max are numeric-only (enforced at compile time) and return lossless strings; the result shape is inferred from the spec. Also available over REST and MCP. See aggregates.

Safer schema changes on your own database

For bring-your-own-database subgraphs, a breaking schema change (removed table/column, changed type, or a forced reindex) is now refused, keeping your data intact.

  • The deploy returns the exact migration plan: the DROP SCHEMA … CASCADE plus the rebuild DDL, so you can run it yourself and re-deploy.
  • sl subgraphs deploy prints the plan.
  • The SDK throws a typed ByoBreakingChangeError exposing reasons and plan.

Subscriptions went polymorphic. A subscription is now one of two kinds: subgraph (fires on subgraph table rows, as before) or chain, a webhook on raw chain events with no subgraph at all. The lambda for Stacks.

Direct chain subscriptions

Pass a triggers array instead of a subgraph table. A chain subscription is forward-looking: it starts at the chain tip and fires the moment a matching event lands, with no deploy and no backfill.

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" }),
],
});

trigger.* builders cover every event type (contractCall, contractDeploy, printEvent, and the stx*/ft*/nft* transfer/mint/burn variants), with * wildcards and a trait filter that scopes to a whole SIP. Over REST, POST /api/subscriptions accepts the same triggers array (1–50).

Apply / rollback delivery

Chain deliveries carry a typed envelope: a match sends chain.{type}.apply (with block_hash, block_height, tx_id, canonical, the matched trigger, and the event), and a reorg sends chain.reorg.rollback (with fork_point_height and the orphaned events) so you can cleanly undo. Delivery stays at-least-once and HMAC-signed; key your state on (tx_id, block_hash).

The raw-event availability layer matured across the board. @secondlayer/sdk@5.9.0, @secondlayer/cli@8.2.0, @secondlayer/shared@6.11.0.

Query

New payload filters on /v1/streams/events: sender, recipient, and asset_identifier (exact-match), alongside the existing contract_id and types. Event types that lack a field simply don't match, so the firehose narrows naturally.

$curl -H "Authorization: Bearer $SL_API_KEY" \
"https://api.secondlayer.tools/v1/streams/events?types=ft_transfer&sender=SP...&limit=50"

Finality

/v1/streams/tip now returns finalized_height, and every event carries a finalized flag. Finality is anchored to Bitcoin (burn-block) confirmations rather than a fixed count of Stacks blocks, so a finalized event will not reorg.

Caching

Fully-finalized pages (a closed range with to_height ≤ finalized_height) are served Cache-Control: public, max-age=31536000, immutable with a weak ETag and 304 on If-None-Match. Tip-spanning requests stay private, max-age=2, so historical reads are cheap while the live edge stays fresh.

Proofs

Streams responses are signed with ed25519. Each read carries an X-Signature (over the exact response body) and X-Signature-KeyId. Fetch the public key at GET /public/streams/signing-key and verify, or set verify on the SDK client; you can trust the data without trusting the server.

const streams = createStreamsClient({ apiKey, verify: true });

Bulk & backfill

Download all of it. Finalized history is published as bulk dumps with an ed25519-signed manifest; the SDK verifies that signature by default (verifyDumpsManifest) before trusting any per-file sha256 it lists.

  • SDK: client.dumps.list() / download(file) and a one-call events.replay({ from: "genesis", onDumpFile, onBatch }) that backfills cold history from dumps, then tails live from the manifest's finalized cursor with no gap or duplicate at the seam.
  • CLI: sl streams pull --to ./dump downloads finalized dumps locally (dumps are public, no API key).

Reliability

Reorgs now archive orphaned rows instead of deleting them, keeping the raw log intact and auditable. The indexer gained leader election, making it safe to run as multiple instances.

Edit on GitHub