Core surfaces / Subscriptions

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

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

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

HeaderPurpose
webhook-idUnique delivery id (also the signed prefix)
x-secondlayer-signatureed25519 signature, base64
x-secondlayer-signature-keyidId 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 kindReplay delivers as
Subgraph<subgraph>.<table>.replay, a distinct verb (not the live .created/.updated/.deleted types)
ChainThe 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):

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.

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:

FormatWhere data lives
rawthe body IS data, no wrapper
cloudevents{ specversion, type, source, id, time, datacontenttype, data }
inngesta 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 }:

Triggerevent.typeevent.data fields
stx_transferstx_transfer_eventsender?, recipient?, amount, memo
stx_mintstx_mint_eventrecipient?, amount
stx_burnstx_burn_eventsender?, amount
stx_lockstx_lock_eventlocked_address, locked_amount, unlock_height
ft_transferft_transfer_eventasset_identifier, sender?, recipient?, amount
ft_mintft_mint_eventasset_identifier, recipient?, amount
ft_burnft_burn_eventasset_identifier, sender?, amount
nft_transfernft_transfer_eventasset_identifier, sender?, recipient?, raw_value
nft_mintnft_mint_eventasset_identifier, recipient?, raw_value
nft_burnnft_burn_eventasset_identifier, sender?, raw_value
print_eventcontract_eventtopic, contract_identifier, value, raw_value

Three traps in this table

  • Fields marked ? are omitted entirely when not applicable — JSON.stringify drops them. A stx_mint delivery has no sender key at all; it is never sent as sender: null.
  • NFT deliveries carry only raw_value (canonical hex of the token-id Clarity value) — there's no decoded value field on nft_* events.
  • print_event's delivered event.type is the node's raw name contract_event, not print_event_event. Its contract field is contract_identifier, not contract_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.