Subscriptions
A subscription binds to a subgraph table: every row change that matches the filter fires a signed HTTP POST to your endpoint, with automatic retries and idempotency keys. Deliveries cover the full row lifecycle: inserts, updates, and deletes.
sl subscriptions create sbtc-webhook \
--no-scaffold \
--subgraph sbtc-flows \
--table transfers \
--url https://your-app.com/webhooks/sbtcOmit --no-scaffold to also scaffold a local receiver project (-r inngest | trigger | cloudflare | node) in a directory named after the subscription.
On the default standard-webhooks format, every delivery carries a per-subscription HMAC. Verify it against your subscription secret before trusting the payload: pass the raw body first, then the headers and secret.
Other formats use the universal signature
The other formats (raw, cloudevents, trigger, cloudflare, inngest) don't carry this HMAC. They're authenticated by the universal ed25519 signature (below), which rides on every delivery.
import { verifyWebhookSignature } from "@secondlayer/sdk";
const valid = verifyWebhookSignature(rawBody, req.headers, secret);
if (!valid) return new Response("bad signature", { status: 401 });The optional fourth argument is the timestamp tolerance in seconds (verifyWebhookSignature(rawBody, headers, secret, toleranceSeconds = 300)).
Failed deliveries retry with backoff; after repeated failures the subscription's circuit opens so a downed endpoint can't block the queue.
A subgraph subscription tracks the whole life of each matching row. The payload's type field encodes which mutation fired, as `<subgraph>.<table>.<verb>`:
| Verb | Fires on | Example type |
|---|---|---|
created | row insert | sbtc-flows.transfers.created |
updated | row update | sbtc-flows.transfers.updated |
deleted | row delete | sbtc-flows.transfers.deleted |
Beyond the per-subscription HMAC, every delivery in any format carries an authenticity signature proving it came from Secondlayer. Three headers ride on every POST:
| Header | Purpose |
|---|---|
webhook-id | Unique delivery id (also the signed prefix) |
x-secondlayer-signature | ed25519 signature, base64 |
x-secondlayer-signature-keyid | Id of the signing key used |
The signed content is `${webhook-id}.${rawBody}`. Verify it with the SDK against Secondlayer's published public key; one key proves authenticity for any format.
import { verifySecondlayerSignature } from "@secondlayer/sdk";
// Public key (ed25519, SPKI PEM) from GET /public/streams/signing-key
const valid = verifySecondlayerSignature(rawBody, req.headers, publicKeyPem);
if (!valid) return new Response("bad signature", { status: 401 });Fetch the public key once from GET /public/streams/signing-key and cache it (rotate on the keyid header).
Re-deliver historical rows over an existing subscription with replay. Replays are idempotent (re-running the same range is a no-op), historical only, and never move the live cursor; your forward stream keeps flowing untouched.
const { replayId, enqueuedCount, scannedCount } = await sl.subscriptions.replay(id, {
fromBlock: 8000000,
toBlock: 8050000,
});The range is capped at 100,000 blocks. Both kinds are flagged as replays (is_replay) so your handler can tell them apart from live traffic.
| Subscription kind | Replay delivers as |
|---|---|
| Subgraph | <subgraph>.<table>.replay, a distinct verb (not the live .created/.updated/.deleted types) |
| Chain | The standard chain.{type}.apply envelope |
Chain replay covers decoded events and the on-Stacks sBTC peg triggers (deposit, withdrawal create/accept/reject). The sbtc_withdrawal_swept_confirmed settlement trigger fires on a Bitcoin confirmation (not a Stacks block), so it is forward-only and not replayable — the CLI warns when you replay a subscription that uses it.
A subscription is one of two mutually-exclusive kinds:
subgraph: fires on the rows a subgraph handler writes. Shape:{ name, subgraphName, tableName, url, filter? }.chain: fires on raw chain events, with no subgraph deployed. Shape:{ name, url, triggers: [...] }. A webhook on a contract, event, function, or SIP trait, with no schema or handler. Chain subscriptions are forward-looking: they start at the chain tip, no backfill.
Triggers
A chain subscription takes a triggers array (1–50) instead of subgraphName/tableName. Build them with the trigger.* factories:
import { trigger } from "@secondlayer/sdk";
await sl.subscriptions.create({
name: "amm-swaps",
url: "https://my-app.com/webhook",
triggers: [
trigger.contractCall({ contractId: "SP....amm", functionName: "swap-*" }),
trigger.ftTransfer({ trait: "sip-010", minAmount: "1000000" }),
],
});The raw object form is equivalent: { type: "contract_call", contractId, functionName: "swap-*" }.
Trigger types and their fields (* wildcards are allowed; trait scopes a trigger to a SIP/trait):
| Builder | type | Fields |
|---|---|---|
trigger.contractCall | contract_call | contractId, functionName, caller, trait |
trigger.contractDeploy | contract_deploy | deployer, contractName |
trigger.ftTransfer | ft_transfer | assetIdentifier, sender, recipient, minAmount, trait |
trigger.ftMint | ft_mint | assetIdentifier, recipient, minAmount, trait |
trigger.ftBurn | ft_burn | assetIdentifier, sender, minAmount, trait |
trigger.nftTransfer | nft_transfer | assetIdentifier, sender, recipient, trait |
trigger.nftMint | nft_mint | assetIdentifier, recipient, trait |
trigger.nftBurn | nft_burn | assetIdentifier, sender, trait |
trigger.stxTransfer | stx_transfer | sender, recipient, minAmount, maxAmount |
trigger.stxMint | stx_mint | recipient, minAmount |
trigger.stxBurn | stx_burn | sender, minAmount |
trigger.stxLock | stx_lock | lockedAddress, minAmount |
trigger.printEvent | print_event | contractId, topic, trait |
Validation is strict per type: mints take recipient (no sender), burns take sender (no recipient); any field outside a type's set is rejected with a 400.
sBTC peg triggers (sbtcDeposit, sbtcWithdrawalCreate, sbtcWithdrawalAccept, sbtcWithdrawalReject) fire on the on-Stacks events; sbtcWithdrawalSweptConfirmed fires when a peg-out's BTC sweep confirms on Bitcoin — see sBTC settlement.
Via REST, POST /api/subscriptions accepts the same triggers array (1–50). Chain subscriptions are also created over the CLI, SDK, REST, or MCP.
From the CLI, pass --trigger (repeatable, one JSON object each) or --triggers-file (a JSON array) instead of --subgraph/--table:
sl subscriptions create amm-swaps \
--url https://my-app.com/webhook \
--trigger '{"type":"contract_call","contractId":"SP....amm","functionName":"swap-*"}' \
--trigger '{"type":"sbtc_deposit"}'--trigger/--triggers-file switches create into chain mode — no subgraph, no local scaffold; each trigger is validated before the subscription is provisioned. The signing secret is printed once on success.
Delivery envelope
A matched event delivers chain.{trigger}.apply. On the default standard-webhooks format, the full POST body is { type, timestamp, data } — action, trigger, and event all live under data:
{
"type": "chain.stx_transfer.apply",
"timestamp": "2026-05-01T12:00:00.000Z",
"data": {
"action": "apply",
"trigger": "stx_transfer",
"tx_id": "0x…",
"block_hash": "0x…",
"block_height": 8445086,
"canonical": true,
"event": {
"type": "stx_transfer_event",
"event_index": 2240,
"tx_id": "0x…",
"data": { "memo": "", "amount": "1853058049", "sender": "SM….pool", "recipient": "SP…" }
}
}
}Other formats wrap `data` differently
raw, cloudevents, inngest, trigger, and cloudflare all carry the exact same data value shown above — just under a different key:
| Format | Where data lives |
|---|---|
raw | the body IS data, no wrapper |
cloudevents | { specversion, type, source, id, time, datacontenttype, data } |
inngest | a JSON array: [{ name, data, id, ts, v }] |
trigger | { payload: data, options: { idempotencyKey } } — key is payload, not data |
cloudflare | { params: { ...data, _type, _outboxId } } — spread flat, not nested |
This is not a Streams event
A chain-subscription delivery and a Streams event are structurally unrelated — don't parse one as the other. Streams events are { event_type, payload, cursor, block_height, … }; a chain-subscription delivery is { type, timestamp, data: { trigger, event } }. Building a webhook parser against StreamsEvent's event_type/payload shape will silently fail to match every delivery.
Verify the signature, then decode:
import {
decodeChainWebhook,
decodeClarityValue,
verifyWebhookSignature,
} from "@secondlayer/sdk";
const valid = verifyWebhookSignature(rawBody, req.headers, secret);
if (!valid) return new Response("bad signature", { status: 401 });
const delivery = decodeChainWebhook(rawBody); // throws if the body isn't a chain.* delivery
if ("trigger" in delivery.data) {
switch (delivery.data.trigger) {
case "stx_transfer":
delivery.data.event.data.amount; // fully typed
break;
case "contract_call":
// event is flat here (no nested `.data`); function_args are raw hex
delivery.data.event.function_args?.map(decodeClarityValue);
break;
}
}decodeChainWebhook narrows to ChainWebhookDelivery, a discriminated union keyed on data.trigger (both exported from @secondlayer/sdk). It only understands the standard-webhooks envelope — unwrap other formats per the table above first.
Discriminate on data.trigger, never event.type
event.type is NOT a consistent trigger name — it's stx_transfer_event for stx_transfer, the bare Stacks tx type (contract_call) for contract_call, and the node's raw event name (contract_event) for print_event. data.trigger is the one field that's always the trigger you subscribed with; switch on it, not event.type.
Delivery is at-least-once and HMAC-signed exactly like subgraph subscriptions. Key your state on (tx_id, event_index, block_hash) so a redelivery is idempotent — a single tx can fire multiple event-level deliveries (e.g. two ft_transfer events in one tx) that share a tx_id, so event_index (-1 for tx-level triggers) is required to avoid collapsing them.
Event-level triggers
For stx_*, ft_*, nft_*, and print_event, data.event is { tx_id, type, event_index, data }:
| Trigger | event.type | event.data fields |
|---|---|---|
stx_transfer | stx_transfer_event | sender?, recipient?, amount, memo |
stx_mint | stx_mint_event | recipient?, amount |
stx_burn | stx_burn_event | sender?, amount |
stx_lock | stx_lock_event | locked_address, locked_amount, unlock_height |
ft_transfer | ft_transfer_event | asset_identifier, sender?, recipient?, amount |
ft_mint | ft_mint_event | asset_identifier, recipient?, amount |
ft_burn | ft_burn_event | asset_identifier, sender?, amount |
nft_transfer | nft_transfer_event | asset_identifier, sender?, recipient?, raw_value |
nft_mint | nft_mint_event | asset_identifier, recipient?, raw_value |
nft_burn | nft_burn_event | asset_identifier, sender?, raw_value |
print_event | contract_event | topic, contract_identifier, value, raw_value |
Three traps in this table
- Fields marked
?are omitted entirely when not applicable —JSON.stringifydrops them. Astx_mintdelivery has nosenderkey at all; it is never sent assender: null. - NFT deliveries carry only
raw_value(canonical hex of the token-id Clarity value) — there's no decodedvaluefield onnft_*events. print_event's deliveredevent.typeis the node's raw namecontract_event, notprint_event_event. Its contract field iscontract_identifier, notcontract_id.
"event": {
"type": "contract_event",
"event_index": 0,
"tx_id": "0x…",
"data": {
"topic": "print",
"contract_identifier": "SP….registry",
"value": { "updated": true },
"raw_value": "0x0c00000001…"
}
}Tx-level triggers: contract_call / contract_deploy
data.event is flat — no nested .data:
"event": {
"tx_id": "0x…",
"type": "contract_call",
"sender": "SP…",
"status": "success",
"contract_id": "SP….amm",
"function_name": "swap-x-for-y",
"function_args": ["0x0100000000000000000000000000002710"],
"result_hex": "0x0703"
}function_args are raw hex
function_args are RAW, undecoded Clarity-value hex strings in call order — decode them yourself with decodeClarityValue from @secondlayer/sdk. For a contract_deploy trigger, event.type is smart_contract (the underlying Stacks tx type) — not contract_deploy — and function_name/function_args/result_hex are all null (no function call to report).
sBTC triggers
sbtc_deposit, sbtc_withdrawal_create, sbtc_withdrawal_accept, sbtc_withdrawal_reject, and sbtc_withdrawal_swept_confirmed deliver a flat, already-typed event keyed by topic:
"event": {
"topic": "completed-deposit",
"request_id": 42,
"sender": "SP…",
"amount": "5000000",
"bitcoin_txid": "…",
"block_height": 123456,
"tx_id": "0x…"
}topic is completed-deposit (sbtc_deposit), withdrawal-create/withdrawal-accept/withdrawal-reject (the matching withdrawal triggers), or withdrawal-swept-confirmed (the settlement trigger — whose event instead carries sweep_txid, btc_confirmations, btc_block_height, confirmed_at, amount, sender, with no bitcoin_txid/block_height/tx_id). See sBTC settlement for the full peg lifecycle.
Reorg rollback
A reorg delivers chain.reorg.rollback so you can undo anything committed off an orphaned block. Each orphaned entry is { tx_id, event }, the event body from the original apply:
{
"type": "chain.reorg.rollback",
"timestamp": "2026-05-01T12:00:00.000Z",
"data": {
"action": "rollback",
"fork_point_height": 123450,
"orphaned": [
{ "tx_id": "0x…", "event": { "type": "stx_transfer_event", "event_index": 2240, "tx_id": "0x…", "data": { "…": "…" } } }
],
"truncated": false
}
}orphaned is capped at 500
orphaned is capped at 500 entries per subscription. If truncated is true, treat everything you committed at or above fork_point_height as orphaned rather than relying on the list. Also note: each entry's event doesn't carry a trigger tag — a subscription can have multiple triggers, and a rollback lists every previously-delivered apply regardless of which one matched, so you must already know what you subscribed to in order to interpret an orphaned entry's shape.
Test delivery
sl subscriptions test <idOrName> --post sends a logged test delivery instead of waiting for a real chain event to match:
{
"type": "chain.test.apply",
"timestamp": "2026-05-01T12:00:00.000Z",
"data": {
"test": true,
"message": "Secondlayer test delivery",
"subscription_id": "sub-…",
"sent_at": "2026-05-01T12:00:00.000Z"
}
}Not a real chain event — check data.test before treating a delivery as live.