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.
print_event topic now filters
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.
- A contract that answers
errstill fails only that event. - See Reading the chain from a handler.
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.
| Old | Canonical |
|---|---|
SL_API_URL | SECONDLAYER_API_URL |
SL_API_KEY / SL_ARCHIVE_API_KEY | SECONDLAYER_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, andsubgraphs deploy --visibilityare unregistered. Deploy's success footer always prints the/api/subgraphsread path. - SDK:
subgraphs.publish()/subgraphs.unpublish()and theSubgraphPublishResult/SubgraphUnpublishResulttypes are removed.deploy()no longer accepts or returnsvisibility. - MCP:
subgraphs_publishandsubgraphs_unpublishare no longer registered, andsubgraphs_listno longer reportsvisibilityor apublicUrl. - HTTP:
POST /api/subgraphs/:name/publishand.../unpublishare deleted and 404 in every mode. ThePUBLIC_NAME_TAKENerror 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:
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.
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.
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.
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.
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:
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.
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.
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:
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:
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:
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 acceptedasset_identifierfor nft event reads but not ft. It now works on all six token event types, andftTransfers.listtakesassetIdentifierdirectly.pox5/eventsandsbtc/deposits500'd, andtransactionsreturned a corrupt cursor, whenfieldsomitted 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_idreturned a full page withnext_cursor: null, silently dropping the rest of your rows._idnow drives the cursor server-side whether or not you asked for it.
Field projection reaches the transfer feeds
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
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
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)
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
.mdto a docs URL (/docs/streams.md). Generated from the MDX itself, so it can't drift. ?mode=agentnow works. It was advertised inllms.txtbut 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.mdships inside@secondlayer/sdkwith 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, andwalk()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
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_URLon 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
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
typesnarrows its handler, soevent.payloadis typed without anevent_typeguard, and adding a label tofiltersis a compile error untilonhandles 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
subscribenarrows the same way.
Ask for the columns you want
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_timelets the server skip theblocksjoin entirely. It is not a stored column but ato_timestamp()off a join taken on every read, so this is a planner-level saving on wide sweeps. cursor,block_height, andevent_typealways 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
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:
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.
Print payloads are typed all the way down
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.
- Declaring
printsalso 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
abion a source is validated at deploy against the real Clarity ABI shape. A raw Hiro or Clarinet ABI used to pass and then mis-decodeevent.inputforever; 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 constABI and reference it, so handlers readevent.input.tokenIdinstead ofevent.args[0] as bigint. - Generated row types are derived from the schema instead of a second
hand-written map that answered
uintwithnumber, which didn't compile against the client it shipped beside, and truncateduint128.
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:
- 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
onReorgcan no longer skip reorgs silently. ctx.txis a fully typedTransaction<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) andshutdownSignal()finishes the in-flight batch on SIGTERM.- On Streams,
decoded: truedelivers 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:
- 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
bigintend to end and stringify at exactly one boundary, so a spread bigint can never crashJSON.stringifyagain. - 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.
uniqueKeyschanges are no longer invisible to deploys: additions runALTER TABLE … ADD CONSTRAINTin place, and removals are correctly refused as breaking. Previously auniqueKeysaddition reported success, created nothing, and every laterupsertfailed 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/stackstransports); watch with theonErrorobserver. - One error family:
RateLimitError,AuthError, andValidationErrorare nowApiErrorsubclasses thrown by every surface, so a singleinstanceofcheck works around Index, platform, and Streams calls alike. - Errors carry
retryable,retryAfterSeconds, adocsUrlpointing at the fix, andwalk()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.
fetchImplis 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, orreward_cycle, and page with the same<block_height>:<event_index>cursor the rest of Index uses. ?confirmed=trueclamps the window to the finality boundary, so settlement consumers only see rows past the reorg margin.- Every response carries
tipand areorgs[]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/stacksnow exportsPOX5_EVENT_TOPICS,Pox5EventTopic, andPOX5_CONTRACT_ID_MAINNETfor 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
printevents off the registry contract and matching topics yourself, losing the typed columns. withdrawalshas noconsumeon 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 fortraitwhen 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") continueline every handler opened with, written only to satisfy the compiler, is gone. - A non-literal
eventTypestill 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.
heightsurvives 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
contracton testnet, and the thrown error now says exactly that instead of telling you to wait for an activation that isn't coming. SPV_ADAPTER_CONTRACTSandgetSpvAdapter(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
getPox5Activationor 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
apiKeystill wins. PassapiKey: ""to force keyless reads on a machine that has a key exported. - Exported as
resolveApiKeyif you want the same precedence in your own code, and guarded for browsers and edge runtimes whereprocessis 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 takingIndexEventand re-narrowing inside every function. - The
consumeIndexFeedtypes come with it:IndexFeedItem,IndexFeedEnvelope, andIndexFeedFetcherare 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, plusconfirmedto stay behind the finality boundary. amount_ustxandamount_satsstay strings, so stacking amounts pastNumber.MAX_SAFE_INTEGERsurvive 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/sdkalone. Every piece the union composes is exported too: the per-triggerChain*Datashapes,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 whosetypedisagrees withdata.trigger, throws instead of quietly handing you a mistyped object. WebhookHeaderInputandStandardWebhooksHeadersare exported as well, so you can type your own framework adapter aroundverifyWebhookSignature.
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:
uintbecomesbigint, tuples become camelCase objects, and an optional read returnsnullwhen the record doesn't exist.getStakerInfo(...)hands you{ amountUstx, firstRewardCycle, numCycles, signer } | nulldirectly. - One breaking change: the 12 individual reads no longer return
ClarityValue.getStakerStateis unchanged. - The ABI is machine-extracted from the
pox-5boot 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:
_multisigsigner keys set at build time carry through provider signing, so auto-detection keeps working afterstx_signTransactionreturns. cvToJSONandcvToValueare 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.
getContractSourceandgetRawBlockread straight from a stacks-node (Clarity source, raw block headers) for cases the indexed extended API can't serve.getAccountHistory,getMempoolStats, andgetNftHoldings, promoted out of the AI-tools layer into first-class actions, also available onclient.*.- 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.
mnemonicToBitcoinKeyscovers 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.
waitForTransactionReceiptreturns 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.reasonnarrows across all 26 stacks-node rejection strings, with the node'sreason_dataattached.
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 aTypedAbi-branded ABI const, so hovers and type errors name your types instead of dumping expanded conditionals. - Generated imports target real
@secondlayer/stackssubpaths; 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 andgetContractcover 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-enforcedcanonicalcolumn; replace-per-height is the documented reorg contract for burnchain data. If yoursecondlayer index codegenmirror includes these tables, re-run codegen and drop the column; it was alwaystrue, 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. PluspublicKeyToAddressin@secondlayer/stacks/utils. jsToClarityValuetakes 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 theokvalue, so no moreunknownleaks 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 onstake,register-for-bond,stake-update) andpox-postcondition(will-not-perform/may-perform/will-perform, evaluated onunstake,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.Clarity6ships 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.
finalizedOnlyholds delivery to rows at or belowtip.finalized_height(which the Index tip now reports).fromHeight: 0backfills from genesis.- Runnable example:
sales-indexindexes 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-topicprintsmaps to typedevent.data;--table-per-topicto normalize).codegen --payloadsemits 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.
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 … CASCADEplus the rebuild DDL, so you can run it yourself and re-deploy. sl subgraphs deployprints the plan.- The SDK throws a typed
ByoBreakingChangeErrorexposingreasonsandplan.
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.
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.
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.
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-callevents.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 ./dumpdownloads 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.