Seventeen triggers, no subgraph required

Only what matches.
On our infra or yours.

A subscription POSTs matching subgraph rows — or raw chain events with no subgraph at all — straight to your endpoint. Signed, retried, rolled back on a fork. Run it hosted, or bring the whole stack up on your own hardware.

webhook.ts
import { verifyWebhookSignature, decodeChainWebhook } from "@secondlayer/sdk";

// Raw body first — the signature covers bytes, not parsed JSON.
export async function POST(req: Request) {
  const raw = await req.text();
  if (!verifyWebhookSignature(raw, req.headers, secret))
    return new Response("bad signature", { status: 401 });

  const delivery = decodeChainWebhook(raw);

  if ("trigger" in delivery.data) {
    // Discriminated on data.trigger — typed from here down.
    switch (delivery.data.trigger) {
      case "sbtc_deposit": await credit(delivery.data.event); break;
      case "ft_transfer": await ledger(delivery.data.event.data); break;
    }
  } else {
    // A fork names its own casualties — undo them, then move on.
    await undo(delivery.data.orphaned, delivery.data.fork_point_height);
  }

  return new Response("ok");
}
sl subscriptions logs sbtc-hook
200 chain.sbtc_deposit.apply · 142ms
200 chain.ft_transfer.apply · 96ms
chain.reorg.rollback · fork at #8,249,711
   orphaned: 3
200 rollback acknowledged
$

Two kinds of subscription.
One with a subgraph. One without.

Subscribe to the rows your own handler writes, or skip the subgraph entirely and match raw chain events as they land. Same delivery guarantees either way.

Subgraph — every change to a table you own

Fires on the row lifecycle your handler drives: created, updated, deleted. The payload type is <subgraph>.<table>.<verb>, so one receiver can route the whole table. Backfills from history and replays on demand.

$ sl subscriptions create sbtc-webhook \
   --subgraph sbtc-flows \
   --table transfers \
   --url https://your-app.com/webhooks
sbtc-flows.transfers.created

Chain — raw events, no subgraph deployed

Give it up to 50 triggers and it starts matching at the tip. Nothing to deploy, nothing to index, no handler to write — the right shape when you want a notification, not a dataset.

$ sl subscriptions create amm-swaps \
   --url https://my-app.com/webhook \
   --trigger '{"type":"contract_call"…}'
starts at tip · no backfill

Delivery you don't have to babysit.
Signed, retried, and reversible.

The hard part of webhooks isn't sending them. It's proving they came from us, surviving your endpoint being down, and telling you when the chain takes an event back.

Read the Subscriptions docs

Signed

Every delivery, in every format, carries an ed25519 signature over the body. On standard-webhooks you also get a per-subscription HMAC.

webhook-id: msg_2rF9…
x-secondlayer-signature:
  MEUCIQDx8k…
…-signature-keyid: k_04
verified

Retried

Failures back off and retry. If your endpoint stays down, the circuit opens so one dead receiver can't block the queue behind it.

503 attempt 1 · retry in 2s
503 attempt 2 · retry in 8s
503 attempt 3 · retry in 32s
⊘ circuit open · queue unblocked
200 recovered · resuming

Reversible

A reorg delivers chain.reorg.rollback listing every orphaned event you were already sent — so your state can be corrected, not just appended to.

"action": "rollback"
"fork_point_height": 8249711
"orphaned": [
  { tx_id: "0x9a…" },
  { tx_id: "0x3c…" } ]
"truncated": false

Seventeen triggers.
Wildcards, traits and amount floors included.

Match a contract call by name pattern, an FT transfer above a threshold, a print event on a topic, or the full sBTC peg lifecycle. Validation is strict per type — a mint takes a recipient, a burn takes a sender, anything else is a 400 before you ever deploy.

contractCallcontractDeployftTransferftMintftBurnnftTransfernftMintnftBurnstxTransferstxMintstxBurnstxLockprintEventsbtcDepositsbtcWithdrawalCreatesbtcWithdrawalAcceptsbtcWithdrawalSweptConfirmed
triggers.ts
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" }),
  ],
});

It arrives in the shape your stack already reads.
Six formats, one payload.

Point a subscription at Inngest, Trigger.dev or a Cloudflare Worker and the body lands in the shape that runtime expects. Same data value in every one, just a different wrapper.

FormatWhere data lives
standard-webhooks{ type, timestamp, data }default · HMAC
rawthe body is data — no wrapper
cloudevents{ specversion, type, source, id, time, data }
inngest[{ name, data, id, ts, v }]drop-in
trigger{ payload: data, options: { idempotencyKey } }drop-in
cloudflare{ params: { …data, _type, _outboxId } }drop-in

The whole thing is MIT.
Including the part that delivers your webhooks.

Hosted is the default and most people should stay there. But subscriptions aren't a service you can only rent from us — docker compose up runs the indexer, API and subgraph processor on your own hardware, with every surface attached: Index, Streams, Subgraphs, Subscriptions.

Read the self-host guide
docker/oss
git clone https://github.com/ryanwaits/secondlayer.git
cd secondlayer/docker/oss && cp .env.example .env

docker compose up -d postgres migrate api indexer subgraph-processor
Inside your own perimeter

Nothing between the chain and your database

Run delivery on your own hardware when that's what the work calls for — a compliance boundary, an air-gapped network, or simply a preference. Same API, same SDK, same triggers; the only difference is whose machine it runs on, so moving between hosted and self-hosted is a config change, not a rewrite.

And locally

Devnet is a first-class target

sl devnet connect wires a Clarinet devnet in one step, so you can fire real subscriptions at a local contract and watch them land — signatures, retries, replay and all — before anything touches mainnet.

Ship the fix, then replay the history.

Re-deliver a past block range over an existing subscription. Replays are idempotent, historical only, and never move your live cursor — so catching up can't cost you the tip. Capped at 100,000 blocks and flagged is_replay.

replay.ts
const { replayId, enqueuedCount } = await sl.subscriptions.replay(id, {
  fromBlock: 8000000,
  toBlock: 8050000,
});
The catch, stated plainly

Delivery is at-least-once

We would rather send a webhook twice than lose it once, so your receiver has to be idempotent. Key your state on (tx_id, event_index, block_hash) — one transaction can fire several event-level deliveries that share a tx_id, and event_index is what keeps them apart. It is -1 for tx-level triggers.

Two more worth knowing before you build: orphaned in a rollback caps at 500 entries, so if truncated is true, treat everything at or above the fork point as gone rather than trusting the list. And discriminate on data.trigger, never event.type— the latter is the node's raw event name and does not match what you subscribed with.

Point it at your endpoint.
Forget about it.

Signed, retried, and honest about forks. One command to create, one function to verify — on our hardware or yours.