<!-- source: https://secondlayer.tools/docs/rest-api -->

# 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](/docs/authentication)).
- On your own box: [Self-host](/docs/self-host#use-these-docs-on-your-box).

Where the detail lives:

- Per-endpoint parameters and response codes: [API reference](/docs/api-reference).
- Print-event schemas: [`get-print-schema`](/docs/api/index#get-print-schema).
- Proof routes (`/v1/proofs/*`, `/v1/index/transactions/:tx_id/proof`): [Verify](/docs/verification#proofs-api).

## Response envelope

```json response.json status=200
{
  "events": [{ "block_height": 8054704, "event_index": 12 }],
  "next_cursor": "8054704:12",
  "tip": { "block_height": 8054704, "finalized_height": 8054262 },
  "reorgs": []
}
```

```json response.json status=200
{
  "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.

## Errors

```json response.json status=400
{
  "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.

| Field | Is |
| --- | --- |
| `request_id` | Matches the `X-Request-Id` response header (sent on every response); quote it in any report |
| `feedback.url` | Where 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:

```bash wrap
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:

| Field | Holds |
| --- | --- |
| `kind_hint` | `missing_capability`, `bug`, `schema_gap`, `docs_mismatch`, or `perf` |
| `expected` | What you wanted back |
| `evidence` | `tx_id`, `block_height`, `contract_id`, `event_type`, `subgraph`, `table` |

An `Idempotency-Key` header makes retries safe.

## Pagination

| Surface | Page size | Ordering |
| --- | --- | --- |
| Subgraph tables | `_limit` (default 50, max 1000) | `_id` by default, or `_sort=<column>&_order=asc\|desc` for one column |
| Index | `limit` (default 200, max 1000) | Oldest first; `order=desc` for newest first on events and ft/nft transfers |
| Streams | `limit` (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](/docs/subgraphs#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`.

## Filtering

```bash
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](/docs/streams#read-it-directly) 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)

```bash
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](/docs/streams#real-time-firehose-sse), [Read rows](/docs/subgraphs#stream-rows-in-real-time).

### Aggregates

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

```bash
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"
```

```json response.json status=200
{
  "count": 42,
  "countDistinct": { "sender": 7 },
  "sum": { "amount": "12300000000" },
  "min": { "amount": "1000000" }
}
```

| Param | Effect |
| --- | --- |
| `_count=true` | `COUNT(*)` |
| `_countDistinct=col1,col2` | Distinct count per column |
| `_sum=col`, `_min=col`, `_max=col` | Comma-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 / 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 |

Typed as `sl.subgraphs.typed(def).<table>.aggregate(spec)`, or untyped as `sl.subgraphs.aggregate(name, table, params)`: signatures in the [SDK reference](/docs/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.

```bash
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`.

## Notes for agents

- Envelope: rows under a named key (events, calls, rows) plus top-level next_cursor, tip and reorgs.
- Pagination differs per surface: subgraph tables use _limit and _order (bare limit or order returns 400); Index uses limit plus cursor or from_height.
- Aggregates: '/v1/subgraphs/<name>/<table>/aggregate' with _count, _sum, _min, _countDistinct. Results are lossless strings. Errors: NON_NUMERIC_COLUMN, TOO_MANY_AGGREGATES.

## Setup

Ensure setup once, skipping any step already done:
- Skill: `bunx skills add ryanwaits/secondlayer --skill secondlayer -y`
- CLI: `bun add -g @secondlayer/cli`
- Instance: `secondlayer init` writes `INSTANCE_TOKEN`. Loopback `/v1` reads need no key.
- Hosted/archive: export `SECONDLAYER_API_KEY` (`sk-sl_*`) or buy credits with `secondlayer credits buy` (`balance`, `refill`), then `secondlayer whoami`
