<!-- source: https://www.secondlayer.tools/docs/webhooks -->

# 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](/docs/subgraphs#react-to-your-rows).

## Create a webhook

Name the calls and the transfers you care about; each match posts to your URL. A webhook starts at the chain tip and never backfills.

```ts create-webhook.ts figure
import { trigger } from "@secondlayer/sdk";

await sl.webhooks.create({
  name: "amm-swaps",
  url: "https://my-app.com/webhook",
  triggers: [
    // Wildcards are allowed.
    trigger.contractCall({ contractId: "SP....amm", functionName: "swap-*" }),
    // Scopes to a SIP trait.
    trigger.ftTransfer({ trait: "sip-010", minAmount: "1000000" }),
  ],
});
```

What arrives and how to trust it: [Receive and verify](#receive-and-verify). To watch one contract's prints: [Listen for Stacks contract events](/docs/guides/listen-for-contract-events).

## From the CLI

The CLI takes the same triggers as JSON.

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

## Check it

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.

```ts handler.ts
import { verifySecondlayerSignature } from "@secondlayer/sdk";

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

Headers, HMAC and key rotation: [Verify the signature](#verify-the-signature).

## Coming from Chainhook

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:

```json predicate.json
{
  "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" } }
}
```

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

## Triggers and limits

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

### Triggers

Use the `trigger.*` factories (as in [Create a webhook](#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`, 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](/docs/index#decoded-protocols). 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](#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.

### 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:

```ts
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 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 `none` never match.
- `topic` is shorthand for `where: { 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 |

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

```json delivery.json
{
  "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](/docs/self-host#what-differs-on-your-box). Delivery semantics (at-least-once, unordered, reorg rollback) are the same either way: [Receive and verify](#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 |

## Receive and verify

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**.

```ts
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](#delivery-envelope)) 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 |

```ts
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 }`:

```json delivery.json
{
  "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,
    "event": {
      "type": "stx_transfer_event",
      "event_index": 2240,
      "tx_id": "0x…",
      "data": { "memo": "", "amount": "1853058049", "sender": "SM….pool", "recipient": "SP…" }
    }
  }
}
```

`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](/docs/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:

```ts webhook-handler.ts
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 on this, never event.type.
  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
      // Decode each raw argument yourself.
      delivery.data.event.function_args?.map(decodeClarityValue);
      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:

| 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_index` keeps them distinct; it is `-1` for transaction-level triggers.

Per-trigger shapes for `data.event`: [Event shapes](#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, 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`.

```json
"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`:

```json
"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](#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`:

```json
"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:

```json
{
  "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 and recovery

### Replay

Re-deliver historical events over an existing webhook.

```ts
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`](/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:

```ts
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.

## Troubleshooting

`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 `429`s, and `429`s 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:

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

```bash
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.

```bash
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):

```bash
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.

## Notes for agents

- Create: 'secondlayer webhooks create <name> --url <url> --trigger '<json>'' or sl.webhooks.create with a triggers array built from trigger.* factories, e.g. trigger.contractCall({ contractId, functionName }) and trigger.ftTransfer({ assetIdentifier, minAmount }). Ask only for the HTTPS receiver URL and which chain events to match.
- The signing secret is shown once; capture it. Generate a signed test fixture only from a secret the user provides; never request or recover the stored one.
- Deliveries: 'chain.{type}.apply' per matched event, 'chain.reorg.rollback' when a block is orphaned.
- A webhook starts at the chain tip and never backfills; use replay over an existing webhook for history.
- Hosted billing: $10 per 1M delivered events plus the account's hosted stack (memory plus storage; $5/mo minimum while it runs for webhooks only, $10/mo with subgraphs). Pausing or deleting every webhook (with no subgraphs) stops the stack within minutes; the spend cap stops it too.
- PoX-5 has no trigger of its own: use two print_event triggers on SP000000000000000000002Q6VF78.pox-5, one with where: { signer: [...] } and one with where: { 'signer-manager': [...] }, because some topics print only signer-manager.
- print_event where: equality on top-level print tuple keys in the contract's own spelling (kebab-case, no camelCase alias). Values are strings: principals SP..., uints as decimal strings, bools 'true'/'false', buffers 0x.... Array = any-of (max 20), keys AND (max 10). Nested tuples and lists never match. topic filters the same way.
- sBTC: trigger.sbtcWithdrawalSweptConfirmed() fires once per sweep when btc_confirmations crosses the threshold (default 6). It is forward-only and never re-fires on a reorg.
- Runes: trigger.runeTransfer({ rune: '840000:3', minAmount: '1000000' }), with runeEtch, runeMint and runeBurn alongside. A rune name resolves to its id at create time.
- Diagnose a failing webhook: inspect detail, recent deliveries and dead-letter rows, highest-priority findings first. Inspect dead rows before requeueing selected ones. Never replay a block range until the user confirms exact from/to blocks.
- Chainhook migration: contract_call maps to trigger.contractCall, print_event to trigger.printEvent, ft/nft/stx events to the matching trigger.ft*/nft*/stx* factories, contract_deployment to trigger.contractDeploy. txid has no trigger; read /v1/index by transaction instead. Test on devnet before mainnet.

## Setup

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`
