Core surfaces / Subscriptions

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/sbtc

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

VerbFires onExample type
createdrow insertsbtc-flows.transfers.created
updatedrow updatesbtc-flows.transfers.updated
deletedrow deletesbtc-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:

HeaderPurpose
webhook-idUnique delivery id (also the signed prefix)
x-secondlayer-signatureed25519 signature, base64
x-secondlayer-signature-keyidId 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 kindReplay delivers as
Subgraph<subgraph>.<table>.replay — a distinct verb, not the live .created/.updated/.deleted types
ChainThe 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):

BuildertypeFields
trigger.contractCallcontract_callcontractId, functionName, caller, trait
trigger.contractDeploycontract_deploydeployer, contractName
trigger.ftTransferft_transferassetIdentifier, sender, recipient, minAmount, trait
trigger.ftMintft_mintassetIdentifier, recipient, minAmount, trait
trigger.ftBurnft_burnassetIdentifier, sender, minAmount, trait
trigger.nftTransfernft_transferassetIdentifier, sender, recipient, trait
trigger.nftMintnft_mintassetIdentifier, recipient, trait
trigger.nftBurnnft_burnassetIdentifier, sender, trait
trigger.stxTransferstx_transfersender, recipient, minAmount, maxAmount
trigger.stxMintstx_mintrecipient, minAmount
trigger.stxBurnstx_burnsender, minAmount
trigger.stxLockstx_locklockedAddress, minAmount
trigger.printEventprint_eventcontractId, 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.