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

# Pagination

> Cursor paging, why pages can come back short, and why there is no total count.

Every list endpoint — `/offers`, `/rentals`, `/rentals/{id}/events` — is
cursor-paginated and returns the same envelope:

```json theme={null}
{
  "data": [ /* … */ ],
  "meta": { "next_cursor": "eyJvIjoiMjQ5Iiwi…" }
}
```

Follow `meta.next_cursor` until it is `null`.

```bash theme={null}
# First page
curl -s -G https://api.gpuoutlet.ai/v1/offers \
  -H "Authorization: Bearer $GPUOUTLET_API_KEY" \
  -d gpu_family=h100-80gb -d limit=50

# Next page — same filters, plus the cursor
curl -s -G https://api.gpuoutlet.ai/v1/offers \
  -H "Authorization: Bearer $GPUOUTLET_API_KEY" \
  -d gpu_family=h100-80gb -d limit=50 -d cursor='eyJvIjoiMjQ5Iiwi…'
```

<CodeGroup>
  ```python Python theme={null}
  def all_offers(session, **filters):
      cursor = None
      while True:
          params = {**filters, "limit": 100}
          if cursor:
              params["cursor"] = cursor
          page = session.get(f"{API}/offers", params=params).json()
          yield from page["data"]
          cursor = page["meta"]["next_cursor"]
          if cursor is None:      # the ONLY end condition
              return
  ```

  ```javascript Node theme={null}
  async function* allOffers(filters = {}) {
    let cursor = null;
    do {
      const qs = new URLSearchParams({ ...filters, limit: '100', ...(cursor ? { cursor } : {}) });
      const page = await get(`/offers?${qs}`);
      yield* page.data;
      cursor = page.meta.next_cursor; // the ONLY end condition
    } while (cursor !== null);
  }
  ```
</CodeGroup>

## A short page is not the last page

<Warning>
  A page can come back with fewer items than `limit` while `next_cursor` is still
  set. **Stop when `next_cursor` is `null`, never when a page looks short.**
</Warning>

Some filters are applied after the page is read from the database, so a page of
50 rows can arrive as 12 after filtering. The loop that stops on
`len(page) < limit` will silently drop the rest of your results — and it will do
it intermittently, which is worse than doing it always.

## Cursors are bound to their query

A cursor encodes the position *within a specific query*. Replay it against
different filters or a different sort and you get `400 invalid_cursor` rather
than a silently restarted or scrambled result set:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_cursor",
    "message": "The cursor does not match the current filters or sort order. Cursors are only valid for the exact query that produced them.",
    "doc_url": "https://docs.gpuoutlet.ai/api/errors#invalid_cursor",
    "request_id": "req_1b8e4d3f7a09"
  }
}
```

In practice: keep the filters in a variable and send the same ones on every page,
as both examples above do. Do not rebuild the query string between pages.

Cursors are **opaque**. Today's happens to be base64; that is not a promise.
Do not parse, construct, or store them beyond the life of the loop.

## Limits

|         |     |
| ------- | --- |
| Default | 50  |
| Maximum | 200 |
| Minimum | 1   |

Above 200 you get `400 invalid_request`. Very deep paging is refused with
`pagination_limit_exceeded` — at that depth the answer is a narrower filter, not
more pages.

## There is no total count

Deliberately. A count over a live marketplace is stale the moment it is computed
— offers appear and disappear as suppliers publish and buyers rent — so a
`total: 412` would be a number we cannot stand behind, printed with the authority
of one we can.

If you need a count for display, count what you actually received and say so
("showing 87 offers"). If you need it for a progress bar, prefer a spinner: an
honest indeterminate beats a precise fiction.

## Ordering

| Endpoint               | Order                                                            |
| ---------------------- | ---------------------------------------------------------------- |
| `/offers`              | By `sort` — default `price_asc`                                  |
| `/rentals`             | Newest first                                                     |
| `/rentals/{id}/events` | **Oldest first** — it is a history, and a history reads forwards |

Within a single cursor walk, ordering is stable. Across walks it is not: the
catalog changes underneath you, and an offer that was on page 1 an hour ago may
be gone entirely.
