Build / Subscriptions

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/sbtc

Omit --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>`:

VerbFires onExample type
createdrow insertsbtc-flows.transfers.created
updatedrow updatesbtc-flows.transfers.updated
deletedrow deletesbtc-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.

BuildertypeFields
trigger.contractCallcontract_callcontractId, functionName, caller, trait
trigger.contractDeploycontract_deploydeployer, contractName
trigger.ftTransferft_transferassetIdentifier, sender, recipient, minAmount, trait
trigger.ftMintft_mintassetIdentifier, recipient, minAmount, trait
trigger.ftBurnft_burnassetIdentifier, sender, minAmount, trait
trigger.nftTransfernft_transferassetIdentifier, sender, recipient, trait
trigger.nftMintnft_mintassetIdentifier, recipient, trait
trigger.nftBurnnft_burnassetIdentifier, sender, trait
trigger.stxTransferstx_transfersender, recipient, minAmount, maxAmount
trigger.stxMintstx_mintrecipient, minAmount
trigger.stxBurnstx_burnsender, minAmount
trigger.stxLockstx_locklockedAddress, minAmount
trigger.printEventprint_eventcontractId, 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 kindReplay delivers as
Subgraph<subgraph>.<table>.replay, a distinct verb (not the live .created/.updated/.deleted types)
ChainThe 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.