Reference / REST API

REST API

The shared envelope and cursor grammar across the read surfaces (index, streams, and subgraph tables), with per-surface filter params.

Index reads return rows under a named key (events, calls, …) plus a top-level resumable next_cursor and the indexing tip, including reorgs.

{
  "events": [{ "block_height": 8054704, "event_index": 12 }],
  "next_cursor": "8054704:12",
  "tip": { "block_height": 8054704, "finalized_height": 8054262 },
  "reorgs": []
}

The cursor format is <block_height>:<event_index>.

tip.finalized_height is the highest height past the burn-confirmation finality boundary; rows at or below it never reorg, which is what a finalizedOnly consumer gates on.

/v1/subgraphs speaks a cursor envelope instead: rows, a resumable next_cursor, and the indexing tip:

{
  "rows": [{ "_id": 48138, "block_height": 8054704 }],
  "next_cursor": "48137",
  "tip": { "block_height": 8054704, "subgraph_height": 8054704, "blocks_behind": 0 }
}

Page-size and ordering params are per-surface:

  • Subgraph tables: _limit (default 50, max 1000) and _order=asc|desc; bare limit/order are rejected with 400.
  • Index: limit (default 200, max 1000); ordering is fixed by the keyset cursor (no order param).
  • Streams: limit (default 100, max 1000).

Cursors:

  • cursor: pass the previous response's top-level next_cursor to resume (index and /v1/subgraphs).
  • Index also accepts from_cursor (exclusive-start alias, mutually exclusive with cursor) and from_height/to_height height-range params.

How a surface accepts filters depends on whether it has a fixed schema:

  • Subgraph tables: filter on any column by passing it as a query param; columns are validated against the table's schema.
  • Index and streams: a fixed set of documented filter params per endpoint (e.g. types, not_types, contract_id, sender, recipient, asset_identifier on /v1/streams/events).

Comma lists, ranges, cursor + _limit/_order are shared across surfaces.

Unknown params are rejected

Any query param not recognized for an endpoint returns 400; the error suggests the closest valid param.

curl https://api.secondlayer.tools/v1/subgraphs/sbtc-flows/transfers \
  -G \
  -d "sender=SP3PE7Q9...X44J" \
  -d "_order=desc"

Public subgraphs are anon-readable on /v1/subgraphs: wildcard CORS, no key. Private subgraphs need the owning account's sk-sl_ bearer; anonymous requests get 404 (no existence leak).

  • GET /v1/subgraphs: list public subgraphs (plus your own with a bearer).
  • GET /v1/subgraphs/:name: metadata: tables, columns, indexing tip.
  • GET /v1/subgraphs/:name/:table: rows; /count, /aggregate, /:id, /stream (SSE) alongside.

Pagination is _id-keyset: ?cursor=<next_cursor> resumes, _order=asc|desc sets direction. _limit, _fields, _search, and column filters work as everywhere else.

Dashboard-only params

_offset and _sort are rejected with 400 on /v1; they live only on the authed /api/subgraphs dashboard surface.

Rate limits: anonymous 100 rps, keyed 50 rps; watch the X-RateLimit-* headers.

Two endpoints push rows in real time as Server-Sent Events (Content-Type: text/event-stream) over a single long-lived connection. Both emit a ping keepalive every 20s.

  • GET /v1/streams/events/stream: the raw chain event firehose. Bearer auth. Accepts the same filters as /v1/streams/events (types, not_types, contract_id, sender, recipient, asset_identifier) and a start position (cursor/from_cursor/from_height); with no start position it live-tails from the reorg-clamped tip.
  • GET /v1/subgraphs/:name/:table/stream: new rows for a subgraph table. Anon for public subgraphs, sk-sl_ bearer for your private ones. Accepts ?since=<block_height> (replay from a height, then tail) plus the same column filters as the table's REST read.
curl -N -H "Authorization: Bearer $SL_API_KEY" \
  "https://api.secondlayer.tools/v1/streams/events/stream?types=ft_transfer"

See Streams and the SDK for ergonomic clients.

GET /v1/subgraphs/:name/:table/aggregate runs scalar aggregates over the same filtered set as the list/count endpoints: anon for public subgraphs, sk-sl_ bearer for your private ones. Pass any of the column filters above plus aggregate control params:

  • _count=true: COUNT(*).
  • _countDistinct=col1,col2: distinct count per column.
  • _sum=col, _min=col, _max=col: comma-separated columns; _sum/_min/_max accept numeric columns only (uint/int, plus the system _block_height).
curl https://api.secondlayer.tools/v1/subgraphs/sbtc-flows/transfers/aggregate \
  -G \
  -d "status=active" \
  -d "_count=true" \
  -d "_sum=amount" \
  -d "_min=amount" \
  -d "_countDistinct=sender"
{
  "count": 42,
  "countDistinct": { "sender": 7 },
  "sum": { "amount": "12300000000" },
  "min": { "amount": "1000000" }
}

Result types:

  • With no aggregate param the response is { "count": <n> }.
  • count and countDistinct.col are JSON numbers.
  • sum/min/max are lossless strings: the underlying NUMERIC/BIGINT casts to text so large values never lose precision.
  • sum over an empty set is "0"; min/max are null over an empty/all-null set.

Error codes

Status / codeCause
400 NON_NUMERIC_COLUMNa non-numeric _sum/_min/_max column
400 INVALID_COLUMNan unknown column
400 TOO_MANY_AGGREGATESpast 32 aggregate columns per request

The SDK exposes this as a typed client.aggregate(spec) whose result shape is inferred from the spec.

The /api/subgraphs/* equivalents remain the authed dashboard/control-plane surface (session or key).

GET /v1/index/contracts/:contract_id/print-schema returns per-topic print payload schemas inferred empirically from the contract's indexed history: exact Clarity types per field, the TypeScript type, and a subgraph column_type. See Index for the response shape.

curl "https://api.secondlayer.tools/v1/index/contracts/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry/print-schema"
  • Anonymous and unmetered, like the other read endpoints.
  • Cached: Cache-Control: public, max-age=300 with a weak ETag; send If-None-Match for 304.
  • Takes no query params; any param is rejected with 400 VALIDATION_ERROR.

GET /v1/index/transactions/:tx_id/proof returns a trustless inclusion proof: the raw transaction, raw header, and merkle path needed to verify, client-side, that a transaction is in a Stacks (Nakamoto) block, plus the reward set, when resolvable, to confirm the signers attested to it.

curl "https://api.secondlayer.tools/v1/index/transactions/0x<tx_id>/proof"
{
  "txid": "<hex>",
  "index_block_hash": "<hex>",
  "block_height": 8199502,
  "tx_index": 0,
  "raw_tx": "<hex>",
  "raw_header": "<hex>",
  "tx_merkle_path": [{ "position": "left", "hash": "<hex>" }],
  "consensus": {
    "reward_cycle": 136,
    "reward_set": { "signers": [{ "signing_key": "<hex>", "weight": 51 }], "total_weight": 3862 }
  }
}

consensus is present only when the reward set could be resolved; otherwise the proof is anchored-only. Open during beta, no key required, like the other read endpoints.

Error codes

Status / codeMeaning
404 PROOF_UNAVAILABLEthe transaction or its block was not found
503 PROOF_TX_SET_INCOMPLETEthe server could not reproduce the block's tx_merkle_root from its stored transaction set, so it refuses to emit a proof that wouldn't verify (fail-safe)
503 PROOF_NODE_UNAVAILABLEthe stacks-node that serves signed block headers could not be reached, so the proof cannot be assembled; retry later

See Verification for the trust levels and the @secondlayer/sdk verifier.