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.
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");
}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.
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.
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.
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.
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.
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.
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.
| Format | Where data lives | |
|---|---|---|
| standard-webhooks | { type, timestamp, data } | default · HMAC |
| raw | the 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.
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-processorNothing 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.
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.
const { replayId, enqueuedCount } = await sl.subscriptions.replay(id, {
fromBlock: 8000000,
toBlock: 8050000,
});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.