.md

Webhooks

A signed POST to a URL you run, whenever a chain event you named happens. To act on your own subgraph rows, read them: React to your rows.

You getA signed POST per matched chain event
You writeAn endpoint that checks the signature

and you care about; each match posts to . A webhook starts at the chain tip and never backfills.

create-webhook.tsts
1import { trigger } from "@secondlayer/sdk";
2
3await sl.webhooks.create({
4 name: "amm-swaps",
5 url: "https://my-app.com/webhook",
6 triggers: [
7 trigger.contractCall({ contractId: "SP....amm", functionName: "swap-*" }),
8 trigger.ftTransfer({ trait: "sip-010", minAmount: "1000000" }),
9 ],
10});

7Wildcards are allowed.

8Scopes to a SIP trait.

What arrives and how to trust it: Receive and verify. To watch one contract's prints: Listen for Stacks contract events.

The CLI takes the same triggers as JSON.

$secondlayer webhooks create sbtc-deposits \
--url https://your-app.com/webhooks/sbtc \
--trigger '{"type":"sbtc_deposit"}'

Every delivery, in every format, carries an ed25519 signature. Fetch the public key once and cache it. A failed check is a 401, never a silent drop.

handler.tsts
import { verifySecondlayerSignature } from "@secondlayer/sdk";
// Public key (ed25519, SPKI PEM) from GET /public/streams/signing-key
const valid = verifySecondlayerSignature(rawBody, req.headers, publicKeyPem);
^Every delivery, every format, carries this.
if (!valid) return new Response("bad signature", { status: 401 });

Headers, HMAC and key rotation: Verify the signature.

Each Chainhook predicate becomes one webhook trigger. Create it with your key and one call.

Chainhook v1 scopeChainhook v2 event typeTriggerNote
contract_callcontract_calltrigger.contractCallcontract_identifier is contractId, method is functionName
print_eventcontract_logtrigger.printEventNarrow with topic or where
ft_event transfer, mint, burnft_transfer, ft_mint, ft_burntrigger.ftTransfer, ftMint, ftBurnasset_identifier is assetIdentifier
nft_event transfer, mint, burnnft_transfer, nft_mint, nft_burntrigger.nftTransfer, nftMint, nftBurnasset_identifier is assetIdentifier
stx_event transfer, mint, lockstx_transfer, stx_eventtrigger.stxTransfer, stxMint, stxLockAlso trigger.stxBurn
contract_deploymentcontract_deploytrigger.contractDeployScope by deployer or contractName
txidnonenoneRead the transaction from /v1/index/transactions

Convert one predicate

A v1 predicate and its trigger:

predicate.jsonjson
{
"if_this": {
"scope": "contract_call",
"contract_identifier": "SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.arkadiko-swap-v2-1",
"method": "swap-x-for-y"
},
"then_that": { "http_post": { "url": "https://my-app.com/hook" } }
}
$secondlayer webhooks create arkadiko-swaps \
--url https://my-app.com/hook \
--trigger '{"type":"contract_call","contractId":"SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR.arkadiko-swap-v2-1","functionName":"swap-x-for-y"}'

Deliveries fire on confirmed blocks, and chain.reorg.rollback covers orphaned ones. A webhook starts at the chain tip, so use replay for history.

Or map them all at once:

Ensure setup once, skipping any step already done:
- Skill: `bunx skills add ryanwaits/secondlayer --skill secondlayer -y`
- CLI: `bun add -g @secondlayer/cli`
- Instance: `secondlayer init` writes `INSTANCE_TOKEN`. Loopback `/v1` reads need no key.
- Hosted/archive: export `SECONDLAYER_API_KEY` (`sk-sl_*`) or buy credits with `secondlayer credits buy` (`balance`, `refill`), then `secondlayer whoami`

Webhooks deliver a signed POST to a URL you run whenever a chain event you named happens.

/secondlayer Read https://www.secondlayer.tools/docs/webhooks.md, then convert my Chainhook predicates to Secondlayer chain webhooks. I will paste each predicate as JSON. Map its `if_this` scope to a trigger:
- `contract_call` (contract_identifier, method) -> `trigger.contractCall({ contractId, functionName, caller, trait })`
- `print_event` (contract_identifier, contains or matches_regex) -> `trigger.printEvent({ contractId, topic, trait })`
- `ft_event` mint, transfer, burn -> `trigger.ftMint`, `trigger.ftTransfer`, `trigger.ftBurn` (assetIdentifier, minAmount, trait)
- `nft_event` mint, transfer, burn -> `trigger.nftMint`, `trigger.nftTransfer`, `trigger.nftBurn` (assetIdentifier, trait)
- `stx_event` mint, transfer, lock -> `trigger.stxMint`, `trigger.stxTransfer`, `trigger.stxLock` (recipient, sender, minAmount)
- `contract_deployment` (deployer, implement_trait) -> `trigger.contractDeploy({ deployer, contractName })`, or scope by `trait`
- Chainhooks v2 predicates use `filters.events[].type` instead of `if_this.scope`: `contract_call`, `contract_log` (print), `ft_transfer`, `ft_mint`, `ft_burn`, `nft_transfer`, `nft_mint`, `nft_burn`, `stx_transfer`, `contract_deploy` map to the same triggers; `contract_identifier`, `function_name`, `asset_identifier`, `receiver` become `contractId`, `functionName`, `assetIdentifier`, `recipient`. `ft_event`, `nft_event`, `stx_event` (any transfer, mint or burn) need one trigger per kind; `coinbase` and `tenure_change` have no trigger.
- `txid` has no trigger; read /v1/index by transaction instead.
Carry over names with `*` wildcards. Behavior to keep in mind: apply and rollback become `chain.{type}.apply` per matched event plus `chain.reorg.rollback` when a block is orphaned; a webhook starts at the chain tip and never backfills, so use `replay` over an existing webhook for history; test on devnet before mainnet. For each predicate, output one `secondlayer webhooks create <name> --url <url> --trigger '<json>'` command, using the raw trigger shape from the Triggers section of that page, and flag any scope with no trigger.

Every trigger, the protocol triggers, test deliveries and hosted limits.

Triggers

Use the trigger.* factories (as in Create a webhook) or the equivalent raw objects.

* 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, where, trait
trigger.sbtcDepositsbtc_depositsender, minAmount, maxAmount, bitcoinTxid, requestId
trigger.sbtcWithdrawalCreatesbtc_withdrawal_createsender, minAmount, maxAmount, requestId
trigger.sbtcWithdrawalAcceptsbtc_withdrawal_acceptrequestId, sweepTxid
trigger.sbtcWithdrawalRejectsbtc_withdrawal_rejectrequestId
trigger.sbtcWithdrawalSweptConfirmedsbtc_withdrawal_swept_confirmedrequestId, sweepTxid
trigger.runeEtchrune_etchrune
trigger.runeMintrune_mintrune
trigger.runeTransferrune_transferrune, address, minAmount
trigger.runeBurnrune_burnrune

Validation is strict per type:

  • Mints take recipient, never sender; burns take sender, never recipient.
  • Any field outside a type's set is rejected with a 400.

Protocol triggers

The sBTC and Runes triggers read the same decoded protocols as Index. PoX-5 has no trigger of its own: it is a print_event on the pox-5 contract, scoped to your signers with print fields.

  • sbtc_withdrawal_swept_confirmed fires once per sweep, when btc_confirmations first crosses the threshold (default 6). It is forward-only: settlements confirmed after you subscribe, never a backfill. A Bitcoin reorg that un-confirms a sweep never re-fires it.
  • The rune_* triggers match chain=bitcoin Streams events, not a Stacks block. rune takes an id or a name; a name is resolved to its id at create time, and an instance with no Runes data rejects a name with a hint to use the id.

where delivers only the prints that carry your values, so a busy contract sends you your rows, not everyone's. This webhook gets PoX-5 events for two signers and nothing else:

const managers = [
"SPMPMA1V6P430M8C91QS1G9XJ95S59JS1TZFZ4Q4.fastpool-max500-signer-manager",
"SP21YTSM60CAY6D011EZVEVNKXVW8FVZE198XEFFP.fastpool-1-signer-manager",
];
const pox5 = "SP000000000000000000002Q6VF78.pox-5";
const triggers = [
trigger.printEvent({ contractId: pox5, where: { signer: managers } }),
trigger.printEvent({ contractId: pox5, where: { "signer-manager": managers } }),
];

Two triggers, because some PoX-5 topics print only signer-manager. Triggers OR, and each print is delivered once per matching trigger.

Keys are the print tuple's own top-level keys, spelled as the contract prints them. A value matches the field in its delivered JSON form:

Clarity typeMatch with
principalSP… or SP….contract-name
uint, intDecimal string, "112"
bool"true" or "false"
buffer0x… hex
(some x)The value of x
  • Equality only: no wildcards, ranges or nested paths.
  • An array is any-of (up to 20 values); several keys must all match (up to 10).
  • Nested tuples, lists and none never match.
  • topic is shorthand for where: { topic }.

POST /api/webhooks accepts the same array. The CLI takes triggers two ways:

FlagTakes
--triggerOne JSON object; repeat the flag for more
--triggers-fileA JSON array
$secondlayer webhooks create amm-swaps \
--url https://my-app.com/webhook \
--trigger '{"type":"contract_call","contractId":"SP....amm","functionName":"swap-*"}' \
--trigger '{"type":"sbtc_deposit"}'

Each trigger is validated before provisioning; the signing secret prints once.

Test delivery

secondlayer webhooks test <idOrName> --post sends a logged test delivery:

delivery.jsonjson
{
"type": "chain.test.apply",
"timestamp": "2026-05-01T12:00:00.000Z",
"data": {
"test": true,
"message": "Secondlayer test delivery",
"webhook_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.

Hosted limits

Webhooks run on api.secondlayer.tools with an account key and prepaid credits, or on your own box. Delivery semantics (at-least-once, unordered, reorg rollback) are the same either way: Receive and verify.

LimitHosted
max_retriesCapped at 7
timeout_msCapped at 30000
Rate limitsPer account: create/update/delete 30/min, test 10/min, replay 5/hour, reads 600/min
EgressYour webhook receiver and Secondlayer's read APIs only. A tenant's compose network can't reach another tenant or internal services
Pricing$10 per 1M delivered events (webhook.event), plus your hosted stack's memory and storage. Webhooks only: $5/mo minimum while your stack runs. With subgraphs: $10/mo minimum. Pause or delete every webhook and it stops within minutes. Retries are never billed, and the rows read to match your triggers don't bill as rows.delivered
IsolationOne full stack per account, never a shared runtime. Your data never shares a database or process with anyone else's. The same stack runs your hosted subgraphs, with handlers in a gVisor sandbox

Check the signature, decode the body, and undo anything a reorg orphans.

Delivery is at-least-once and unordered: dedupe on (tx_id, event_index, block_hash) and don't assume arrival order matches chain order.

Verify the signature

On the default standard-webhooks format, every delivery carries a per-webhook HMAC. Pass the raw body first.

import { verifyWebhookSignature } from "@secondlayer/sdk";
const valid = verifyWebhookSignature(rawBody, req.headers, secret);
if (!valid) return new Response("bad signature", { status: 401 });

ed25519 signature

Every delivery, in every format, also carries an ed25519 signature over `${webhook-id}.${rawBody}`.

The other formats (listed below) have no HMAC, so this signature is their only auth.

HeaderPurpose
webhook-idUnique delivery id (also the signed prefix)
x-secondlayer-signatureed25519 signature, base64
x-secondlayer-signature-keyidId of the signing key used
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 and cache it; rotate on the keyid header. Failed deliveries retry with backoff, then the circuit opens so a downed endpoint can't block the queue.

Delivery envelope

A matched event delivers chain.{trigger}.apply. On the default format the body is { type, timestamp, data }:

delivery.jsonjson
{
"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,
"block_time": "2026-05-01T11:59:48.000Z",
"canonical": true,
}
}

block_time is when the block was produced (ISO 8601), or null when it wasn't recorded.

Other formats carry the exact same data value 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 } }; the key is payload, not data
cloudflare{ params: { ...data, _type, _outboxId } }, spread flat rather than nested

This is not a Streams event

A chain-webhook delivery and a Streams event are structurally unrelated, so don't parse one as the other. Streams events are { event_type, payload, cursor, block_height, … }; a chain-webhook delivery is { type, timestamp, data: { trigger, event } }.

Decode the delivery

After the signature verifies, decode:

webhook-handler.tsts
import { decodeChainWebhook, decodeClarityValue } from "@secondlayer/sdk";
const delivery = decodeChainWebhook(rawBody); // throws if the body isn't a chain.* delivery
if ("trigger" in delivery.data) {
switch (delivery.data.trigger) {
^Switch on this, never event.type.
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);
^Decode each raw argument yourself.
break;
}
}

decodeChainWebhook returns a discriminated union keyed on data.trigger.

  • The type is ChainWebhookDelivery; both are exported from @secondlayer/sdk.
  • It reads only the standard-webhooks envelope; unwrap other formats first.

Discriminate on data.trigger, never event.type

data.trigger is always the trigger you subscribed with; switch on it. event.type is not a consistent trigger name:

Triggerevent.type
stx_transferstx_transfer_event
contract_callcontract_call, the bare Stacks transaction type
print_eventcontract_event, the node's raw event name

Delivery is at-least-once, so key your state on (tx_id, event_index, block_hash).

  • One transaction can fire several event-level deliveries that share a tx_id.
  • event_index keeps them distinct; it is -1 for transaction-level triggers.

Per-trigger shapes for data.event: Event shapes.

Event shapes

What data.event holds, per trigger: event-level, tx-level, and sBTC.

Event-level triggers

Event-level triggers deliver data.event as { 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, and 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 is 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, with 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"
}

Call arguments arrive as raw Clarity hex, in call order. Decode each with decodeClarityValue from @secondlayer/sdk, as in Decode the delivery.

  • A contract_deploy trigger reports event.type as smart_contract, the underlying Stacks transaction type.
  • For a deploy, function_name, function_args and result_hex are all null: there is no function call to report.

sBTC triggers

The five sBTC triggers 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 one of:

  • completed-deposit
  • withdrawal-create
  • withdrawal-accept
  • withdrawal-reject
  • withdrawal-swept-confirmed

The settlement event is shaped differently:

  • It carries sweep_txid, btc_confirmations, btc_block_height, confirmed_at, amount, sender.
  • It has no bitcoin_txid, block_height or tx_id.

Reorg rollback

On a reorg, chain.reorg.rollback covers everything that may have reached your endpoint, meaning any row the emitter claimed or attempted, not only one the server confirmed as delivered. A delivery whose response never made it back still counts as "may have arrived," so undo every entry the rollback lists.

A reorg delivers chain.reorg.rollback so you can undo anything committed off an orphaned block. Each entry is { tx_id, event }, carrying 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 webhook.

  • If truncated is true, treat everything you committed at or above fork_point_height as orphaned rather than relying on the list.
  • Entries carry no trigger tag, because a rollback lists every earlier apply whatever trigger matched. You must know what you configured to read an entry's shape.

Replay

Re-deliver historical events over an existing webhook.

const { replayId, enqueuedCount, scannedCount } = await sl.webhooks.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. Replays are flagged is_replay and deliver 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.

Account webhooks

/account/webhooks lists a hosted account's webhooks. Each detail page shows:

  • 7 days of delivered, waiting and gave-up events, by the hour
  • The last 100 delivery attempts, each with a diagnosis
  • Failed events, held for resend
  • Per delivery: payload, response status and headers, and error

You can send a test event, pause or resume, rotate the signing secret, resend failed events, and delete. Create and edit stay on the CLI and SDK: the dashboard watches and fixes a webhook, it never writes a definition.

The SDK reads the same views:

const activity = await sl.webhooks.activity(id); // hourly counts, waiting, lastSuccessAt, byEventType
const { data } = await sl.webhooks.deliveries(id); // last 100 attempts, newest first
const one = await sl.webhooks.delivery(id, data[0].id); // payload, responseHeaders, outboxStatus
const { data: dead } = await sl.webhooks.dead(id);
await sl.webhooks.requeue(id, dead[0].id);

payload is null once the event leaves the outbox, 7 days after delivery, and on test deliveries.

secondlayer webhooks doctor <idOrName> and the dashboard's detail page run the same five rules against your last 100 delivery attempts. Every rule is deterministic: no model, and it only states what the numbers prove. Each fires on a fixed threshold, listed below, and each reports evidence you can check yourself.

Receiver rate-limited

Fires when at least 10 of the last 100 attempts are 429s, and 429s are at least half that window. Your receiver is answering, but throttling us.

Lower --concurrency on the webhook, or make your receiver honor the Retry-After header it's sending back:

$secondlayer webhooks update <idOrName> --concurrency 2

Receiver down

Fires when the 5 most recent attempts are all 5xx responses or got no response at all (timeout or connection failure). Check your receiver's health and logs, then confirm it's back with a test delivery:

$secondlayer webhooks test <idOrName>

Receiver rejects

Fires when the 5 most recent attempts are all 4xx responses other than 429. Your receiver is reachable and actively rejecting the request: check auth config or signature verification first.

$secondlayer webhooks test <idOrName>

Receiver slow

Fires when the median response time of the newest 20 attempts is at least half this webhook's timeoutMs. Speed up your receiver, or raise the timeout (capped at 30s hosted):

$secondlayer webhooks update <idOrName> --timeout-ms 30000

Delivery lag

Fires when the median gap between a block's time and when we dispatched its event, over the newest 20 attempts, exceeds 60 seconds. This is latency on our side, not your receiver: no action needed on your end.

Edit on GitHub