> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alterscope.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Pagination, Filtering & Sorting

> How list endpoints page with a cursor, when to stop, and the filter and sort parameters the API actually accepts.

List endpoints return one page at a time and hand you a cursor to fetch the next. There is no offset/page-number paging — cursors are stable under inserts, so you never skip or double-read a row while paging a live feed.

Two cursor conventions exist in the API, depending on the endpoint:

* **Envelope cursor** — most list endpoints (e.g. `GET /v2/yield/opportunities`) return paging state inside `meta`: an opaque `cursor` plus a `has_more` boolean.
* **Replay cursor** — the event replay endpoint (`GET /v2/events/replay`) walks forward by event ID and signals the end with an empty `next_cursor`.

Both are documented below. Always treat the cursor as opaque: don't parse it, build it, or persist it across schema versions — pass back exactly what the previous response gave you.

## Envelope cursor (`meta.cursor` + `meta.has_more`)

`GET /v2/yield/opportunities` is the reference example. The response wraps its rows in the standard envelope and carries paging state in `meta`:

```json theme={null}
{
  "data": [ /* page of opportunities */ ],
  "meta": {
    "total": 412,
    "cursor": "eyJpZCI6MTAwfQ==",
    "has_more": true,
    "limit": 50
  }
}
```

| Field           | Meaning                                                              |
| --------------- | -------------------------------------------------------------------- |
| `meta.cursor`   | Opaque cursor for the **next** page. Pass it back as `page[cursor]`. |
| `meta.has_more` | `true` while more pages remain; `false` on the last page.            |
| `meta.limit`    | The page size that was applied.                                      |
| `meta.total`    | Total rows matching your filters across all pages.                   |

### Page size

Set the page size with `page[limit]`. For `/v2/yield/opportunities` the default is **50** and the maximum is **200**; a value above the maximum is clamped down rather than rejected. Page-size defaults and caps vary by endpoint — the **Endpoints** group in the sidebar documents each one. Request the next page by passing `page[cursor]` set to the previous response's `meta.cursor`.

<Note>
  Other list families that use the envelope cursor (for example point-in-time snapshots) expose the same `cursor` / `has_more` pair in `meta`. The field names are identical; only the per-endpoint `limit` default and cap differ.
</Note>

### Loop until `has_more` is false

Keep fetching while `meta.has_more` is `true`, feeding `meta.cursor` back as `page[cursor]` each time:

<CodeGroup>
  ```bash cURL theme={null}
  cursor=""
  while : ; do
    url="https://api.alterscope.org/v2/yield/opportunities?page[limit]=200"
    [ -n "$cursor" ] && url="${url}&page[cursor]=${cursor}"

    page=$(curl -s "$url" -H "Authorization: Bearer sk_live_...")

    echo "$page" | jq '.data[]'

    has_more=$(echo "$page" | jq -r '.meta.has_more')
    cursor=$(echo "$page" | jq -r '.meta.cursor')
    [ "$has_more" = "true" ] || break
  done
  ```

  ```python Python theme={null}
  import requests

  BASE = "https://api.alterscope.org"
  headers = {"Authorization": "Bearer sk_live_..."}

  cursor = None
  rows = []
  while True:
      params = {"page[limit]": 200}
      if cursor:
          params["page[cursor]"] = cursor

      resp = requests.get(f"{BASE}/v2/yield/opportunities", headers=headers, params=params)
      resp.raise_for_status()
      body = resp.json()

      rows.extend(body["data"])

      meta = body["meta"]
      cursor = meta.get("cursor")
      if not meta.get("has_more"):
          break

  print(f"fetched {len(rows)} opportunities")
  ```

  ```typescript TypeScript theme={null}
  const BASE = "https://api.alterscope.org";
  const headers = { Authorization: "Bearer sk_live_..." };

  let cursor: string | undefined;
  const rows: unknown[] = [];

  while (true) {
    const url = new URL(`${BASE}/v2/yield/opportunities`);
    url.searchParams.set("page[limit]", "200");
    if (cursor) url.searchParams.set("page[cursor]", cursor);

    const resp = await fetch(url, { headers });
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    const body = await resp.json();

    rows.push(...body.data);
    cursor = body.meta.cursor;
    if (!body.meta.has_more) break;
  }

  console.log(`fetched ${rows.length} opportunities`);
  ```
</CodeGroup>

<Note>
  Each page counts as one request against your [rate limit](/develop/get-started/rate-limits). Use the largest `limit` the endpoint allows to minimize round-trips, and watch `X-RateLimit-Remaining` to throttle before you hit `429`.
</Note>

## Replay cursor (`next_cursor`)

`GET /v2/events/replay` recovers events you missed during a WebSocket disconnect. It is cursor-based on event ID — not timestamp ranges. Walk forward by passing the last event's ID as `since_event_id`, and stop when `next_cursor` comes back empty.

| Parameter        | Default | Notes                                                                                                                                   |
| ---------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `since_event_id` | —       | A ULID event ID. The walk returns events **after** this one. Omit it for the first page. A non-ULID value returns `400 INVALID_CURSOR`. |
| `limit`          | `100`   | Page size, capped at **1000**. Out-of-range or non-numeric values fall back to the default.                                             |
| `type`           | —       | Optional single event-type filter (e.g. `peg.depeg.start`). An unknown type returns `400 INVALID_EVENT_TYPE`.                           |
| `subject_id`     | —       | Optional filter to a single subject (e.g. a specific vault or feed).                                                                    |

The replay window is tier-gated. A cursor older than your tier's window returns `410 REPLAY_WINDOW_EXPIRED`, and the Free tier — which has no replay window — returns `404 REPLAY_NOT_AVAILABLE`. Per-tier replay windows are in [Rate limits & tiers](/develop/get-started/rate-limits).

The response keeps its fields at the top level (it is not wrapped under `data`/`meta`):

```json theme={null}
{
  "events": [
    {
      "event_id": "01HXY2K8ZQ9F3M7VN0ABCDEFGH",
      "type": "peg.depeg.start",
      "severity": "high",
      "chain": "ethereum",
      "protocol": "morpho",
      "subject_id": "0x...",
      "ts": "2026-05-31T12:00:00Z",
      "data": { /* event-specific payload */ }
    }
  ],
  "next_cursor": "01HXY2K8ZQ9F3M7VN0ABCDEFGH",
  "window_days": 7
}
```

### Loop until `next_cursor` is empty

Pass `next_cursor` back as `since_event_id` until it comes back empty:

<CodeGroup>
  ```bash cURL theme={null}
  cursor=""
  while : ; do
    url="https://api.alterscope.org/v2/events/replay?limit=100"
    [ -n "$cursor" ] && url="${url}&since_event_id=${cursor}"

    page=$(curl -s "$url" -H "Authorization: Bearer sk_live_...")

    echo "$page" | jq '.events[]'

    cursor=$(echo "$page" | jq -r '.next_cursor')
    [ -n "$cursor" ] && [ "$cursor" != "null" ] || break
  done
  ```

  ```python Python theme={null}
  import requests

  BASE = "https://api.alterscope.org"
  headers = {"Authorization": "Bearer sk_live_..."}

  cursor = None
  events = []
  while True:
      params = {"limit": 100}
      if cursor:
          params["since_event_id"] = cursor

      resp = requests.get(f"{BASE}/v2/events/replay", headers=headers, params=params)
      resp.raise_for_status()
      body = resp.json()

      events.extend(body["events"])

      cursor = body.get("next_cursor")
      if not cursor:
          break

  print(f"replayed {len(events)} events")
  ```

  ```typescript TypeScript theme={null}
  const BASE = "https://api.alterscope.org";
  const headers = { Authorization: "Bearer sk_live_..." };

  let cursor: string | undefined;
  const events: unknown[] = [];

  while (true) {
    const url = new URL(`${BASE}/v2/events/replay`);
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("since_event_id", cursor);

    const resp = await fetch(url, { headers });
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    const body = await resp.json();

    events.push(...body.events);

    cursor = body.next_cursor;
    if (!cursor) break;
  }

  console.log(`replayed ${events.length} events`);
  ```
</CodeGroup>

<Note>
  Replay is for recovery, not for live streaming. For ongoing updates, subscribe over [WebSockets](/develop/realtime/websockets) or [webhooks](/develop/realtime/webhooks) and use replay only to backfill the gap after a reconnect.
</Note>

## Filtering

Filters narrow a list before it's paged. The set below is what `GET /v2/yield/opportunities` accepts — other endpoints expose their own filters, documented in the **Endpoints** group. Filter parameters use the `filter[...]` bracket form; comma-separate values for the multi-value filters.

| Parameter                           | Example                 | Effect                                            |
| ----------------------------------- | ----------------------- | ------------------------------------------------- |
| `filter[venue]`                     | `aave,morpho`           | Restrict to one or more protocols.                |
| `filter[chain]`                     | `ethereum,stellar`      | Restrict to one or more chains.                   |
| `filter[asset]`                     | `ETH,USDC`              | Restrict to opportunities involving these assets. |
| `filter[strategy]`                  | `lending,vault_managed` | Restrict to one or more strategy types.           |
| `filter[riskBand]`                  | `1,2`                   | Restrict to specific risk bands.                  |
| `filter[maxRiskBand]`               | `2`                     | Cap the risk band (anything above is excluded).   |
| `filter[tier]`                      | `1,2`                   | Restrict to data-tier `1`, `2`, or `3`.           |
| `filter[directionality]`            | `market_neutral`        | Restrict by directionality.                       |
| `filter[minApy]` / `filter[maxApy]` | `5` / `50`              | Bound net APY (percent).                          |
| `filter[minLiquidityScore]`         | `60`                    | Minimum liquidity score.                          |
| `filter[minTvl]`                    | `1000000`               | Minimum TVL in USD.                               |
| `search`                            | `ETH`                   | Full-text search across name and asset.           |

```bash theme={null}
curl -s "https://api.alterscope.org/v2/yield/opportunities?filter[chain]=ethereum&filter[venue]=morpho&filter[minApy]=5&page[limit]=50" \
  -H "Authorization: Bearer sk_live_..."
```

<Note>
  Filters apply before pagination, so `meta.total` reflects the filtered set, not the whole catalog. Apply the same filters on every page of a paging loop — changing them mid-loop invalidates the cursor.
</Note>

## Sorting

`GET /v2/yield/opportunities` accepts a single `sort` parameter. Prefix the field with `-` for descending order. The default sort is `-tvlUsd` (largest TVL first).

```bash theme={null}
curl -s "https://api.alterscope.org/v2/yield/opportunities?sort=-riskAdjustedScore" \
  -H "Authorization: Bearer sk_live_..."
```

Sort and pagination compose: the cursor encodes your sort order, so paging stays consistent as long as you keep the same `sort` (and filters) across the loop.

## See also

<CardGroup cols={2}>
  <Card title="API reference overview" icon="book-open" href="/api-reference/overview">
    Base URL, auth, and the response envelope.
  </Card>

  <Card title="Rate limits & tiers" icon="gauge-high" href="/develop/get-started/rate-limits">
    Per-tier request rates, quotas, and replay windows.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/develop/get-started/errors">
    The standard error envelope and status codes.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/develop/realtime/webhooks">
    Push delivery for live events.
  </Card>
</CardGroup>
