.md

REST API

Every /v1 surface answers with the same envelope, cursor, and filter grammar, so one client shape reads Index, Streams, and subgraphs.

  • Served at SECONDLAYER_API_URL, with your SECONDLAYER_API_KEY as a bearer token (Keys and billing).
  • On your own box: Self-host.

Where the detail lives:

200response.jsonjson
{
"events": [{ "block_height": 8054704, "event_index": 12 }],
"next_cursor": "8054704:12",
"tip": { "block_height": 8054704, "finalized_height": 8054262 },
"reorgs": []
}
200response.jsonjson
{
"rows": [{ "_id": 48138, "block_height": 8054704 }],
"next_cursor": "48137",
"tip": { "block_height": 8054704, "subgraph_height": 8054704, "blocks_behind": 0 }
}
  • The cursor is <block_height>:<event_index>.
  • tip.finalized_height is the burn-confirmation finality boundary. Rows at or below it never reorg, which is what a finalizedOnly consumer gates on.
400response.jsonjson
{
"error": "unknown query param: limt (did you mean limit?)",
"code": "VALIDATION_ERROR",
"request_id": "req_3f9a1c0b7d2e4a6f8b1c2d3e",
"feedback": { "url": "/v1/feedback" }
}

Branch on code, not on the message.

FieldIs
request_idMatches the X-Request-Id response header (sent on every response); quote it in any report
feedback.urlWhere to report a gap

Report a problem (hosted)

Every error body on api.secondlayer.tools carries a request_id. When a call fails and you think the API is wrong or missing something, send that id back with one line on what you were trying to do:

$curl -X POST "$SECONDLAYER_API_URL/v1/feedback" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"request_id":"req_3f9a1c0b7d2e4a6f8b1c2d3e","intent":"list aeUSDC swaps for one sender since block 165000"}'

The server attaches its own record of the failed call (method, path, status, code; kept 24 hours), so you don't resend it. Optional fields:

FieldHolds
kind_hintmissing_capability, bug, schema_gap, docs_mismatch, or perf
expectedWhat you wanted back
evidencetx_id, block_height, contract_id, event_type, subgraph, table

An Idempotency-Key header makes retries safe.

SurfacePage sizeOrdering
Subgraph tables_limit (default 50, max 1000)_id by default, or _sort=<column>&_order=asc|desc for one column
Indexlimit (default 200, max 1000)Oldest first; order=desc for newest first on events and ft/nft transfers
Streamslimit (default 100, max 1000)Fixed by the keyset cursor, no order param

Pass the previous response's next_cursor back as cursor, on Index and subgraph routes. Subgraph routes, sorting and search: Read rows.

On /v1 subgraph tables these return 400, not a clamp:

  • A _limit that is a non-integer, 0, negative, or over 1000.
  • An _order other than asc or desc.
  • A bare limit, order, or sort.

Index also takes:

  • from_cursor: exclusive start, not with cursor.
  • from_height, to_height.
$curl "$SECONDLAYER_API_URL/v1/subgraphs/sbtc-flows/transfers" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY" \
-G \
-d "sender=SP3PE7Q9...X44J" \
-d "_order=desc"

Subgraph tables filter on any column, validated against the table's schema. Index and streams take a fixed set per endpoint; the Streams filter table lists the ones on /v1/streams/events.

Unknown params are rejected

Any unrecognized query param returns 400; the error suggests the closest valid param.

Streaming (SSE)

$curl -N "$SECONDLAYER_API_URL/v1/streams/events/stream?types=ft_transfer" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY"

Two endpoints push rows as Server-Sent Events (Content-Type: text/event-stream) over one long-lived connection, each with a ping keepalive every 20s.

  • GET /v1/streams/events/stream: raw chain event firehose. Same filters as /v1/streams/events, plus a start position (cursor/from_cursor/from_height); with none it live-tails from the reorg-clamped tip.
  • GET /v1/subgraphs/:name/:table/stream: new subgraph-table rows. Takes ?since=<block_height> (replay from a height, then tail) plus the table's column filters.

Ergonomic clients: Streams, Read rows.

Aggregates

GET /v1/subgraphs/:name/:table/aggregate runs scalar aggregates over the same filtered set as the list and count endpoints.

$curl "$SECONDLAYER_API_URL/v1/subgraphs/sbtc-flows/transfers/aggregate" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY" \
-G \
-d "status=active" \
-d "_count=true" \
-d "_sum=amount" \
-d "_min=amount" \
-d "_countDistinct=sender"
200response.jsonjson
{
"count": 42,
"countDistinct": { "sender": 7 },
"sum": { "amount": "12300000000" },
"min": { "amount": "1000000" }
}
ParamEffect
_count=trueCOUNT(*)
_countDistinct=col1,col2Distinct count per column
_sum=col, _min=col, _max=colComma-separated; numeric columns only (uint/int, plus the system _block_height)

Result types:

  • count and countDistinct.col are JSON numbers.
  • sum, min and max are lossless strings.
  • Over an empty set sum is "0", and min and max are null.
  • No aggregate param → { "count": <n> }.
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

Typed as sl.subgraphs.typed(def).<table>.aggregate(spec), or untyped as sl.subgraphs.aggregate(name, table, params): signatures in the SDK reference.

Batch reads

POST /v1/batch runs up to 10 /v1 reads in one round trip, for agents and dashboards that pay per request in latency.

$curl -X POST "$SECONDLAYER_API_URL/v1/batch" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY" \
-H "content-type: application/json" -d '{
"requests": [
{ "path": "/v1/index/pox5/events", "params": { "topic": "stake", "limit": 5 } },
{ "path": "/v1/streams/tip" }
]
}'

Results come back in order as { "results": [{ "path", "status", "body" }] }.

  • Each item runs through the same auth and limits as a direct call, with your Authorization header forwarded.
  • Only paths under /v1/index/, /v1/subgraphs, /v1/streams/ and /v1/contracts are allowed. Anything else is a per-item 400.
Edit on GitHub