Subscriptions
No polling loop: matching rows and chain events arrive at your endpoint as signed POSTs.
secondlayer subscriptions create sbtc-webhook \
--no-scaffold \
--subgraph sbtc-flows \
--table transfers \
--url https://your-app.com/webhooks/sbtcOmit --no-scaffold to scaffold a local receiver (-r inngest | trigger | cloudflare | node) named after the subscription. What arrives and how to trust it: Receiving deliveries.
A subgraph subscription tracks the whole life of each row. The payload type is `<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 |
A subscription is one of two mutually-exclusive kinds:
subgraph: the rows a subgraph handler writes.{ name, subgraphName, tableName, url, filter? }.chain: raw chain events, no subgraph deployed.{ name, url, triggers: [...] }. Starts at the chain tip, no backfill.
A chain subscription takes a triggers array (1–50) instead of subgraphName/tableName. Use the trigger.* factories or the equivalent raw objects.
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" }),
],
});* wildcards are allowed; trait scopes 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.
Five sBTC peg triggers (sbtcDeposit, sbtcWithdrawalCreate, sbtcWithdrawalAccept, sbtcWithdrawalReject, sbtcWithdrawalSweptConfirmed) track the sBTC settlement lifecycle.
POST /api/subscriptions accepts the same array. From the CLI, pass --trigger (repeatable, one JSON object each) or --triggers-file (a JSON array) instead of --subgraph/--table:
secondlayer 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, with no subgraph and no scaffold. Each trigger is validated before provisioning; the signing secret prints once.
Re-deliver historical rows over an existing subscription.
const { replayId, enqueuedCount, scannedCount } = await sl.subscriptions.replay(id, {
fromBlock: 8000000,
toBlock: 8050000,
});Replays are idempotent, historical only, and never move the live cursor. The range is capped at 100,000 blocks. Both kinds are flagged is_replay.
| 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 |
sbtc_withdrawal_swept_confirmed fires on a Bitcoin confirmation rather than a Stacks block, so it is forward-only and never replayed. The CLI warns.
secondlayer subscriptions test <idOrName> --post sends a logged test delivery:
{
"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, so check data.test before treating a delivery as live.