Core surfaces / Subgraphs

Subgraphs

A subgraph is your schema on our indexer: one TypeScript file declares the contract events you care about plus a handler that shapes each event into rows. Deploy gives you hosted Postgres tables behind the same public /v1 read API as Index, backfilled from history and kept live as new blocks arrive.

Paid plan or trial required

Deploying a subgraph requires a paid plan or a 14-day trial; a free account gets 403 PLAN_REQUIRED.

  • Declare sources: the contracts and events you want to track.
  • Write a handler: turn each matched event into one or more rows.
  • Deploy: Secondlayer backfills history from startBlock, then streams new blocks into your table in real time.

A subgraph is one config file: named source filters, a table schema, and handlers keyed by source name. startBlock is top-level:

import { defineSubgraph } from "@secondlayer/subgraphs";

export default defineSubgraph({
  name: "sbtc-flows",
  startBlock: 8000000,
  sources: {
    transfers: {
      type: "ft_transfer",
      assetIdentifier: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
    },
  },
  schema: {
    transfers: {
      columns: {
        tx_id: { type: "string" },
        block_height: { type: "uint" },
        amount: { type: "uint" },
        sender: { type: "string" },
        recipient: { type: "string" },
      },
    },
  },
  handlers: {
    transfers: (event, ctx) => {
      ctx.insert("transfers", {
        tx_id: ctx.tx.txId,
        block_height: ctx.block.height,
        amount: event.amount,
        sender: event.sender,
        recipient: event.recipient,
      });
    },
  },
});

Handlers are values in the handlers map, keyed by source name (or "*" for a catch-all). Each receives (event, ctx):

  • event: flat, typed per source (event.amount, event.sender, no wrapper object).
  • ctx.tx / ctx.block: transaction and block metadata.
  • ctx.insert/update/upsert/delete/patch: the mutators your writes go through.
import { defineSubgraph } from "@secondlayer/subgraphs";

export default defineSubgraph({
  name: "sbtc-flows",
  startBlock: 8000000,
  sources: {
    transfers: {
      type: "ft_transfer",
      assetIdentifier: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
    },
  },
  schema: { /* transfers table */ },
  handlers: {
    // key matches the source name; each matched event becomes one row
    transfers: (event, ctx) => {
      ctx.insert("transfers", {
        tx_id: ctx.tx.txId,
        block_height: ctx.block.height,
        amount: event.amount,
        sender: event.sender,
        recipient: event.recipient,
      });
    },
  },
});

Clarity ABIs don't describe print payloads, so event.data on a print_event source is untyped by default. Declare a prints map (per-topic field to column type) and event.data becomes fully typed, discriminated on event.topic:

sources: {
  registry: {
    type: "print_event",
    contractId: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry",
    topic: "completed-deposit",
    prints: {
      "completed-deposit": { bitcoinTxid: "text", amount: "uint" },
    },
  },
},
handlers: {
  registry: (event, ctx) => {
    event.data.bitcoinTxid; // string — typed, no casting
    event.data.amount;      // bigint
  },
},

Don't guess print fields

Payload shape varies per topic, and a guessed field silently nulls forever. Scaffold from the contract's observed history instead.

sl subgraphs create sbtc-registry \
  --from-contract SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry

This calls the print-schema endpoint for per-topic schemas inferred empirically from indexed print events. It writes one print_event source per topic with its prints map, plus a single wide table with per-column "null except on topics: …" comments (--table-per-topic for one table per topic).

sl subgraphs codegen <file> --payloads emits a .d.ts of the per-topic payload types next to the definition.

Unobserved-field warnings

Deploys return advisory warnings when a handler reads an event.data field never observed on-chain for its source's topic (pinned contractId sources only; wildcard and trait sources are skipped). Warn-only, never blocks; the CLI prints them.

Deploy from the CLI. The first deploy backfills history from startBlock; subsequent blocks stream in automatically.

Tip-first deploys

Add --tip-first (or backfillMode: "concurrent" in the definition) to go live at the chain tip immediately: rows are queryable in seconds while history backfills in the background. Use it only when handlers tolerate out-of-order processing (commutative writes like balances and counters, or insert-only tables). "Latest-value-wins" handlers should keep the default blocking backfill.

No account?

When the x402 pay-per-call rail is enabled, POST /v1/subgraphs accepts an x402 payment ($2) in place of an API key: the subgraph is owned by the paying wallet, indexes forward from deploy, and expires after 7 days unless renewed (POST /v1/subgraphs/{name}/renew, $0.50) or the account is claimed.

sl subgraphs deploy ./subgraph.config.ts

The deploy ends with your table's public URL:

 Subgraph "gamma-sales" created v1.0.0
    Dashboard: https://app.secondlayer.tools/platform/subgraphs/gamma-sales
    Read:      https://api.secondlayer.tools/v1/subgraphs/gamma-sales/sales
    Share:     https://api.secondlayer.tools/v1/subgraphs/gamma-sales (public  no key needed)
    Watch:     sl subgraphs status gamma-sales

sl subgraphs status shows a genesis backfill draining while the table already serves reads:

Status             reindexing
Sync               reindexing 14.2% (1174885 / 8263594 blocks, target #8263594)
Reindex Remaining  7088709
Gaps               none
Rows Indexed       4,874
Table Rows         balances: 4874

gamma-sales is the sales-index example expressed as one defineSubgraph() file: the same table, but we run the loop instead of your hand-run index.contractCalls.consume().

Every subgraph has a visibility:

  • Managed deploys default public: anon-readable on /v1.
  • BYO-database deploys (--database-url) default private; existing subgraphs were migrated private.

Override at deploy, or flip later:

sl subgraphs deploy ./subgraph.config.ts --visibility private
sl subgraphs publish sbtc-flows     # make public
sl subgraphs unpublish sbtc-flows   # make private again

The SDK exposes the same as sl.subgraphs.publish(name) / unpublish(name), and MCP as subgraphs_publish / subgraphs_unpublish (plus a visibility param on subgraphs_deploy).

Public names are a single global namespace, claimed on publish; a taken name returns 409 PUBLIC_NAME_TAKEN.

Private subgraphs don't leak

Private subgraphs stay readable on /v1 with your sk-sl_ bearer; anonymous requests to a private name return 404 (no existence leak).

Your table is immediately available over REST at /v1/subgraphs: anon-readable for public subgraphs, wildcard CORS. Pages are _id-keyset: pass ?cursor=<next_cursor> to resume, _order=asc|desc for direction. No _offset/_sort on /v1.

curl https://api.secondlayer.tools/v1/subgraphs/sbtc-flows/transfers \
  -G -d "_limit=10" -d "_order=desc"
{
  "rows": [
    {
      "_id": 48138,
      "block_height": 8054704,
      "tx_id": "0x5ee90c4f8a21d25b",
      "amount": 697078828,
      "sender": "SP3PE7Q9...X44J"
    }
  ],
  "next_cursor": "48137",
  "tip": { "block_height": 8054704, "subgraph_height": 8054704, "blocks_behind": 0 }
}

GET /v1/subgraphs lists public subgraphs (plus your own with a bearer); GET /v1/subgraphs/sbtc-flows returns metadata (tables, columns, indexing tip). Row-by-id and counts live at /:table/:id and /:table/count.

Roll up a table server-side: count, sum, min, max, and distinct counts over the same filters as the list endpoint.

curl https://api.secondlayer.tools/v1/subgraphs/sbtc-flows/transfers/aggregate \
  -G -d "_count=true" -d "_sum=amount" -d "_countDistinct=sender"

Anon for public subgraphs; private subgraphs need the owning account's sk-sl_ bearer (anon gets 404).

_sum/_min/_max take numeric columns only. The typed SDK exposes the same as <table>.aggregate(spec); see SDK aggregates and the REST API reference for the full parameter set.

A source can target a token standard instead of a fixed contract. trait resolves to every contract Secondlayer classifies as that standard, including ones deployed after you ship the subgraph, so you index a whole ecosystem with no address list to maintain:

sources: {
  // every SIP-010 token transfer on-chain
  tokens: { type: "ft_transfer", trait: "sip-010" },
},

Supported traits: sip-009, sip-010, sip-013. On paid plans, a reindex backfills each contract's full history from its deploy block even if it was classified later; without a plan, reindexes are clamped to the registered start. See Contract discovery to query the matching set directly.

Tail a table over Server-Sent Events as rows are indexed. The endpoint streams from the current tip by default, or pass ?since=<block_height> to replay from a height and then tail live. Column filters are accepted as query params; a periodic ping event keeps the connection alive.

curl -N https://api.secondlayer.tools/v1/subgraphs/sbtc-flows/transfers/stream \
  -G -d "since=8050000"

Anon for public subgraphs; private ones need the owning account's sk-sl_ bearer.

From the SDK, subscribe wraps the stream with the same where filters as findMany and returns an unsubscribe function:

const unsubscribe = subgraph.transfers.subscribe(
  (row) => console.log(row),
  { where: { sender: "SP3PE7Q9...X44J" }, since: 8050000, onError: console.error },
);

subscribe uses the global EventSource, so it runs in the browser or Node ≥ 22 only.

Deploy with --database-url and your rows land in your Postgres. Then generate a typed schema for your ORM and query it with full joins and relations:

sl subgraphs deploy ./subgraph.config.ts --database-url "$DATABASE_URL"
sl subgraphs codegen ./subgraph.config.ts --target prisma -o prisma/schema.prisma
npx prisma generate

Prisma, Drizzle, and Kysely all have first-class generators (--target prisma|drizzle|kysely).

Treat BYO tables as read-only

Secondlayer owns the table DDL; treat the tables as read-only and verify with prisma db pull (never migrate).

Want pushes instead of polling? Bind a Subscription to this table and Secondlayer POSTs every matching row change to your webhook (inserts, updates, and deletes, with the verb encoded in the payload type).

Breaking changes on your own database

Breaking changes never drop your data

For managed subgraphs a breaking schema change (removed table/column, changed column type, or a forced reindex) auto-reindexes by dropping and rebuilding the schema. On a BYO database that drop would destroy your data, so the deploy is refused (HTTP 422); nothing on your database is touched.

The refusal carries the exact migration plan. sl subgraphs deploy prints the breaking reasons plus the DROP SCHEMA … CASCADE + rebuild DDL to run yourself, then re-deploy:

 Refusing breaking schema change on BYO subgraph (no data dropped).
Breaking changes:
 transfers: removed columns [amount]
To rebuild manually, run on YOUR database:
  DROP SCHEMA IF EXISTS "sg_my_subgraph" CASCADE;
  CREATE SCHEMA "sg_my_subgraph";
  CREATE TABLE

The SDK throws a typed ByoBreakingChangeError exposing details.reasons and details.plan (dropStatement, statements, grantScript) for programmatic handling.

No --force on BYO

A destructive --force rebuild on your database is not yet supported; run the DROP manually.