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; barelimit/orderare rejected with400. - 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-levelnext_cursorto resume (index and/v1/subgraphs).- Index also accepts
from_cursor(exclusive-start alias, mutually exclusive withcursor) andfrom_height/to_heightheight-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_identifieron/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/_maxaccept 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> }. countandcountDistinct.colare JSON numbers.sum/min/maxare lossless strings: the underlyingNUMERIC/BIGINTcasts to text so large values never lose precision.sumover an empty set is"0";min/maxarenullover an empty/all-null set.
Error codes
| Status / code | Cause |
|---|---|
400 NON_NUMERIC_COLUMN | a non-numeric _sum/_min/_max column |
400 INVALID_COLUMN | an unknown column |
400 TOO_MANY_AGGREGATES | past 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=300with a weakETag; sendIf-None-Matchfor304. - 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 / code | Meaning |
|---|---|
404 PROOF_UNAVAILABLE | the transaction or its block was not found |
503 PROOF_TX_SET_INCOMPLETE | the 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_UNAVAILABLE | the 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.