Subgraphs· Index your contract

Your contract, as a table.

Write one TypeScript file. Get Postgres tables, a REST API and client types, backfilled from your start block and held at tip.

Reading your tables is free

1 filetables · REST API · client types
sbtc-flows.ts1 filedefineSubgraph({sources: { transfers: ft_transfer },schema: { transfers: { … } },handlers: { transfers: insert },})secondlayer subgraphs deploy_block_heightsenderamount9,137,002SP3PE…X44J697,078,8289,137,004SP2J6…R4K25,000,0009,137,007SP3F1…Q9A250,000transfers · example rowsnewest

A schema change breaks the build

Handlers and client types come from the same file, so a renamed column fails in your editor, not in production.

Reorgs roll your rows back

An orphaned block takes its rows with it, and the canonical ones replace them. No cleanup job to write.

Tested on real blocks first

subgraphs test runs your handlers over a real block range. Events in and zero rows out fails the run.

The file

Sources, schema, handlers. That's the whole thing.

Name the events you care about, declare the table, and write one handler per source. Contract sets, contracts a factory deploys later, and every token of a standard all fit the same shape.

subgraphs/sbtc-flows.tsts
1import { defineSubgraph } from "@secondlayer/subgraphs";
2
3export default defineSubgraph({
4 name: "sbtc-flows",
5 startBlock: 8000000,
6 sources: {
7 transfers: { type: "ft_transfer", assetIdentifier: SBTC },
8 },
9 schema: {
10 transfers: { columns: {
11 amount: { type: "uint" },
12 sender: { type: "principal" },
13 recipient: { type: "principal" },
14 } },
15 },
16 handlers: {
17 transfers: (event, ctx) =>
18 ctx.insert("transfers", event),
19 },
20});

5backfill starts here

6these events, nothing else

18the write: one row per event

The deploy

Queryable while it fills

The first deploy backfills from your start block, then new blocks stream in. Go live at the tip first if your handlers only insert, and let history catch up behind.

~/app
$secondlayer subgraphs deploy subgraphs/sbtc-flows.ts \
--start-block 8000000
✓ Subgraph "sbtc-flows" created → v1.0.0
REST $SECONDLAYER_API_URL/v1/subgraphs/sbtc-flows/transfers
$secondlayer subgraphs status sbtc-flows
Status reindexing
Sync 14.2% (1,174,885 / 8,263,594 blocks)
Gaps none
Table Rows transfers: 4,874
The read

Read it typed, or over REST

The SDK knows your columns. REST pages the same rows with a cursor and reports how far the table is behind the tip, in the same envelope the index uses.

GET /v1/subgraphs/sbtc-flows/transfersts
1const { rows, next_cursor, tip } =
2 await sl.subgraphs.rows("sbtc-flows", "transfers",
3 { limit: 10, order: "desc" });
4
5{
6 "rows": [{ "_id": 48138, "block_height": 8054704,
7 "amount": 697078828, "sender": "SP3PE…X44J" }],
8 "next_cursor": "48137",
9 "tip": { "block_height": 8054704, "blocks_behind": 0 }
10}

8pass back as ?cursor

9how far behind the table is

Reading your tables$0

Every read of your own subgraph, REST, SDK or stream. Point a dashboard at it and leave it running.

Indexing$5 / 1M rows

Event and transaction rows the subgraph reads to fill itself. The first 1M each month are free, so set a start block.

Hosted stack$10/mo minimum

Memory plus storage, $10/mo minimum while it runs. Delete every subgraph and pause every webhook, and it stops within minutes.

Where it runs

Your box or ours, the same file

Hosted is one private stack per account in a sandbox. On your own box any source runs, with the same CLI and the same routes.

HostedSelf-host
SourcesEvent filters, contract_call, contract_deploy. Anything else is refused at deploy, naming the source.Any source
IsolationHandlers run in a gVisor sandbox with 512 MB, reaching only the secondlayer API and the public internetYour machine, your rules
VisibilityPrivate to your accountPrivate to your network
FailureA stalled handler restarts. Three deaths at the same height marks it error: fix and redeploySame, in your logs
Costindexing rows + stack memory + storagefree · MIT
Rows anyone can recompute

Check the table without asking us

A subgraph built only from contract state writes earns the state level. Anyone can rebuild its rows from block proofs and compare them with what the API serves.

stateState writes only. Handlers must be deterministic, or the deploy is refused with the line that broke the rule.
eventsEvents, prints or contract calls. Deploys as usual; rows stay advisory until event proofs exist.
noneTrait scope or tip-first backfill. Advisory.

Not covered yet: which transaction made a write, and writes overwritten inside one block. State-write history on hosted is coming soon

~/app
$secondlayer verify subgraph pool-reserves \
--replay --from 1230000 --to 1231000
✓ pin 7f3c…a91e bundle, schema, startBlock
✓ blocks 1,001 proven from the checkpoint
✓ txs 4,812 match their tx merkle roots
✓ inputs 1,001 diffs fully named
✓ handlers 37 events, deterministic realm
✓ rows reserves: 14 equal digest 9a1f…03be
React to your rows

Your rows, your trigger

The table is yours, so reacting to it is too. Use what your database already does, hold a stream open, or get pushed by the chain itself.

Self-host · your Postgres

A trigger on the table

Rows land in your own database. LISTEN/NOTIFY tells your app the moment one is written.

create function notify_transfer()
returns trigger as $$ begin
  perform pg_notify('transfers',
    row_to_json(new)::text);
  return new;
end $$ language plpgsql;

create trigger on_transfer
  after insert on transfers
  for each row
  execute function notify_transfer();
Hosted · the row stream

Tail the table

Server-sent events from the tip, or replay from a height and keep going. Same filters as a read.

const stop = subgraph.transfers.subscribe(
  (row) => onTransfer(row),
  { since: 9137000,
    where: { sender: "SP3PE…X44J" } },
);

// or page with ?cursor=next_cursor
// on your own schedule
Chain events · Webhooks

Pushed to your server

When the trigger is something on-chain, skip the table. A webhook delivers the event itself, signed, retried and logged.

secondlayer webhooks create sbtc-in \
  --url https://app.example/hook \
  --trigger '{"type":"sbtc_deposit"}'
/webhooks →

Run it yourself

Hosted is api.secondlayer.tools. Point SECONDLAYER_API_URL at your own instance and the same subgraphs deploy writes to your Postgres. Codegen for Prisma, Drizzle or Kysely reads those tables directly.

Subgraph questions

Do I get to pick the API shape?
No. A subgraph serves its tables over one REST shape with cursors, filters and sorting. If you need your own endpoints, read the Index and build them.
What happens on a reorg?
Rows written from the orphaned block are rolled back and the canonical block's rows are written. Contracts a factory discovered on the orphaned fork stop matching too.
How is a hosted subgraph billed?
Indexing bills the event and transaction rows it reads, at $5 per 1M past the free 1M each month. The stack bills memory plus storage, $10/mo minimum while it runs (webhooks only: $5/mo), and stops on its own once you have no subgraphs or active webhooks. Reading your own tables is free.
Can I get a webhook when a row changes?
Not from us. On your own box, a Postgres trigger does it in a few lines. Hosted, tail the table's row stream. If what you care about is an on-chain event, a chain webhook delivers it directly.
Can I test before I deploy?
secondlayer subgraphs test runs your local handlers over a real block range. The first run records a cassette, so later runs are free and offline.

Deploy your first subgraph today