Subscriptions
Push semantics. Subscribe to your subgraph table writes — the server delivers signed webhooks.
A subscription binds to a subgraph table. Every row change that matches the filter triggers a signed HTTP POST to your endpoint, with automatic retries and idempotency keys.
Subscriptions deliver the full row lifecycle — inserts, updates, and deletes — not just the initial write.
sl subscriptions create sbtc-webhook \
--no-scaffold \
--subgraph sbtc-flows \
--table transfers \
--url https://your-app.com/webhooks/sbtcOmit --no-scaffold to also scaffold a local receiver project (-r inngest | trigger | cloudflare | node) in a directory named after the subscription.
On the default standard-webhooks format, every delivery carries a per-subscription HMAC. Verify it against your subscription secret before trusting the payload — pass the raw body first, then the headers and secret.
Other formats use the universal signature
The other formats — raw, cloudevents, trigger, cloudflare, inngest — don't carry this HMAC. They're authenticated by the universal ed25519 signature in Universal webhook signature below, which rides on every delivery regardless of format.
import { verifyWebhookSignature } from "@secondlayer/sdk";
const valid = verifyWebhookSignature(rawBody, req.headers, secret);
if (!valid) return new Response("bad signature", { status: 401 });The optional fourth argument is the timestamp tolerance in seconds (verifyWebhookSignature(rawBody, headers, secret, toleranceSeconds = 300)).
Failed deliveries retry with backoff; after repeated failures the subscription's circuit opens so a downed endpoint can't block the queue.
A subgraph subscription tracks the whole life of each matching row, not just the insert. The payload's type field encodes which mutation fired, as `<subgraph>.<table>.<verb>`:
| Verb | Fires on | Example type |
|---|---|---|
created | row insert | sbtc-flows.transfers.created |
updated | row update | sbtc-flows.transfers.updated |
deleted | row delete | sbtc-flows.transfers.deleted |
A downstream mirror should apply created and updated and remove the row on deleted, so your copy stays in lockstep with the materialized table.
Beyond the per-subscription HMAC above, every delivery — in any format (standard-webhooks — the default — raw, cloudevents, trigger, cloudflare, inngest) — also carries an authenticity signature that proves it came from Secondlayer. Three headers ride on every POST:
| 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 |
The signed content is `${webhook-id}.${rawBody}`. Verify it with the SDK against Secondlayer's published public key — one key proves authenticity for any format.
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 from GET /public/streams/signing-key and cache it (rotate on the keyid header).
Re-deliver historical rows over an existing subscription with replay. Replays are idempotent (re-running the same range is a no-op), historical only, and never move the live cursor — your forward stream keeps flowing untouched.
const { replayId, enqueuedCount, scannedCount } = await sl.subscriptions.replay(id, {
fromBlock: 8000000,
toBlock: 8050000,
});The range is capped at 100,000 blocks. Both kinds are flagged as replays (is_replay) so your handler can tell them apart from live traffic.
| Subscription kind | Replay delivers as |
|---|---|
| Subgraph | <subgraph>.<table>.replay — a distinct verb, not the live .created/.updated/.deleted types |
| Chain | The standard chain.{type}.apply envelope |
Subscriptions are polymorphic. A subscription is one of two mutually-exclusive kinds:
subgraph— fires on the rows a subgraph handler writes. Shape:{ name, subgraphName, tableName, url, filter? }.chain— fires on raw chain events, with no subgraph deployed. Shape:{ name, url, triggers: [...] }. It's the "lambda for Stacks": a webhook on a contract, event, function, or SIP trait — no schema, no handler. Chain subscriptions are forward-looking (they start at the chain tip; there's no backfill).
Triggers
A chain subscription takes a triggers array (1–50) instead of subgraphName/tableName. Build them with the trigger.* factories from the SDK root:
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" }),
],
});The raw object form is equivalent — { type: "contract_call", contractId, functionName: "swap-*" }.
Trigger types and their fields (* wildcards are allowed; trait scopes a trigger 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, trait |
Validation is strict per type — mints take recipient (no sender), burns take sender (no recipient); any field outside a type's set is rejected with a 400 validation error.
Via REST, POST /api/subscriptions accepts the same triggers array (1–50) in place of subgraphName/tableName. Chain subscriptions are created over the SDK, REST, or MCP — the CLI's create is subgraph-only.
Delivery envelope
Chain deliveries carry a small typed envelope. A matched event delivers chain.{type}.apply:
{
"action": "apply",
"block_hash": "0x…",
"block_height": 123456,
"tx_id": "0x…",
"canonical": true,
"trigger": "contract_call",
"event": { /* the raw chain event */ }
}A reorg delivers chain.reorg.rollback, so you can undo anything you committed off an orphaned block:
{
"action": "rollback",
"fork_point_height": 123450,
"orphaned": [
{ "tx_id": "0x…", "event": { /* event body from the original apply */ } }
],
"truncated": false
}Each orphaned entry is { tx_id, event } — the event body from the original apply, not the full apply envelope.
orphaned is capped at 500
orphaned is capped at 500 entries per subscription. If truncated is true, treat everything you committed at or above fork_point_height as orphaned rather than relying on the list.
Delivery is at-least-once and HMAC-signed exactly like subgraph subscriptions — verify the signature, and key your state on (tx_id, block_hash) so a redelivery is idempotent.