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.
and you care about; each match posts to . A webhook starts at the chain tip and never backfills.
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.
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.
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 scope | Chainhook v2 event type | Trigger | Note |
|---|---|---|---|
contract_call | contract_call | trigger.contractCall | contract_identifier is contractId, method is functionName |
print_event | contract_log | trigger.printEvent | Narrow with topic or where |
ft_event transfer, mint, burn | ft_transfer, ft_mint, ft_burn | trigger.ftTransfer, ftMint, ftBurn | asset_identifier is assetIdentifier |
nft_event transfer, mint, burn | nft_transfer, nft_mint, nft_burn | trigger.nftTransfer, nftMint, nftBurn | asset_identifier is assetIdentifier |
stx_event transfer, mint, lock | stx_transfer, stx_event | trigger.stxTransfer, stxMint, stxLock | Also trigger.stxBurn |
contract_deployment | contract_deploy | trigger.contractDeploy | Scope by deployer or contractName |
txid | none | none | Read the transaction from /v1/index/transactions |
Convert one predicate
A v1 predicate and its trigger:
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.
| 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, where, trait |
trigger.sbtcDeposit | sbtc_deposit | sender, minAmount, maxAmount, bitcoinTxid, requestId |
trigger.sbtcWithdrawalCreate | sbtc_withdrawal_create | sender, minAmount, maxAmount, requestId |
trigger.sbtcWithdrawalAccept | sbtc_withdrawal_accept | requestId, sweepTxid |
trigger.sbtcWithdrawalReject | sbtc_withdrawal_reject | requestId |
trigger.sbtcWithdrawalSweptConfirmed | sbtc_withdrawal_swept_confirmed | requestId, sweepTxid |
trigger.runeEtch | rune_etch | rune |
trigger.runeMint | rune_mint | rune |
trigger.runeTransfer | rune_transfer | rune, address, minAmount |
trigger.runeBurn | rune_burn | rune |
Validation is strict per type:
- Mints take
recipient, neversender; burns takesender, neverrecipient. - 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_confirmedfires once per sweep, whenbtc_confirmationsfirst 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 matchchain=bitcoinStreams events, not a Stacks block.runetakes 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.
Print fields
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:
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 type | Match with |
|---|---|
| principal | SP… or SP….contract-name |
uint, int | Decimal string, "112" |
bool | "true" or "false" |
| buffer | 0x… 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
nonenever match. topicis shorthand forwhere: { topic }.
POST /api/webhooks accepts the same array. The CLI takes triggers two ways:
| Flag | Takes |
|---|---|
--trigger | One JSON object; repeat the flag for more |
--triggers-file | A JSON array |
Each trigger is validated before provisioning; the signing secret prints once.
Test delivery
secondlayer webhooks test <idOrName> --post sends a logged test delivery:
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.
| Limit | Hosted |
|---|---|
max_retries | Capped at 7 |
timeout_ms | Capped at 30000 |
| Rate limits | Per account: create/update/delete 30/min, test 10/min, replay 5/hour, reads 600/min |
| Egress | Your 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 |
| Isolation | One 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.
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.
| 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 |
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 }:
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:
| 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 } }; 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:
decodeChainWebhook returns a discriminated union keyed on data.trigger.
- The type is
ChainWebhookDelivery; both are exported from@secondlayer/sdk. - It reads only the
standard-webhooksenvelope; 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:
| Trigger | event.type |
|---|---|
stx_transfer | stx_transfer_event |
contract_call | contract_call, the bare Stacks transaction type |
print_event | contract_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_indexkeeps them distinct; it is-1for 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 }:
| 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, andJSON.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 is 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.
Tx-level triggers: contract_call / contract_deploy
data.event is flat, with no nested .data:
Call arguments arrive as raw Clarity hex, in call order. Decode each with decodeClarityValue from @secondlayer/sdk, as in Decode the delivery.
- A
contract_deploytrigger reportsevent.typeassmart_contract, the underlying Stacks transaction type. - For a deploy,
function_name,function_argsandresult_hexare allnull: there is no function call to report.
sBTC triggers
The five sBTC triggers deliver a flat, already-typed event keyed by topic:
topic is one of:
completed-depositwithdrawal-createwithdrawal-acceptwithdrawal-rejectwithdrawal-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_heightortx_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:
orphaned is capped at 500
orphaned is capped at 500 entries per webhook.
- If
truncatedistrue, treat everything you committed at or abovefork_point_heightas orphaned rather than relying on the list. - Entries carry no
triggertag, 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.
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:
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:
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:
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.
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):
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