# Pagination

Some collections — typically `/v1/bookings` and `/v1/events` — can return very large amounts of data. These endpoints page results so you can read the collection at your own pace.

## Paginated endpoints are for ad-hoc use

Listing endpoints are designed for **ad-hoc requests**: rendering a list of events in a UI, generating an occasional report, or bootstrapping a local data store once. They are **not** designed as a continuous synchronization mechanism, and they do not return a consistent snapshot of the underlying collection.

If you need to keep an internal data store in sync with Understory, use [webhooks](/docs/usage/webhooks) instead. Use a listing endpoint to bootstrap your data once, and let webhooks drive ongoing updates from that point on.

## Using `cursor` and `limit`

Paginated collections accept two query parameters:

- `cursor`: an opaque string identifying where the next page begins. Omit it (or pass an empty string) to start at the beginning.
- `limit`: the maximum page size. Endpoints document their default and maximum (most default to `100` and cap at `500`).


Every paginated response is a JSON object with an `items` array and a `next` cursor:


```sh
$ curl https://api.understory.io/v1/bookings
{
  "next": "<cursor for the next page>",
  "items": [
    {...},
    {...}
  ]
}
```

If `next` is empty or absent, you have reached the end of the collection. Otherwise, repeat the request with `cursor=<value of next>` to fetch the next page.

Below is an example of paging through all bookings with a JavaScript `while` loop:

Paging through bookings

```javascript Paging through bookings
let next;
let bookings = [];

while (next) {
  const response = await fetch(
    `https://api.understory.io/v1/bookings?limit=100&cursor=${next}`,
    {
      headers: {
        authorization: `Bearer ${process.env.TOKEN}`,
        'user-agent': 'Understory API Demo',
      },
    }
  );

  if (response.status !== 200) {
    throw new Error(
      `Failed to fetch bookings: ${response.status} ${response.statusText}`
    );
  }

  bookings.push(response.items);

  next = response.next;
}

console.log(`Fetched ${bookings.length} bookings:`, bookings);
```

## Responses are not a snapshot

A paginated response is a **best-effort traversal**, not a transactional snapshot. Neither a single page nor the sum of all pages represents a locked state of the collection at any point in time:

- Two identical requests issued moments apart may return different totals.
- A `next` cursor remains valid (it will not error), but it does not guarantee that subsequent pages are consistent with the page that produced it.
- An item may appear on a later page, disappear before you reach it, or shift position if the field used to order results changes while you are paging.


Do not build logic that assumes the result set is stable across pages. Design for the case where the data shifts mid-traversal.

## Cursors are opaque and short-lived

A cursor encodes the ordering and position of a specific paginated request at the moment it was issued. Treat it as a one-shot token:

- **Do not parse or modify cursors.** Their format is internal and may change without notice.
- **Do not persist cursors across jobs, sessions, queues, or workflow state.** A stored cursor replayed later will not give a stable continuation — the underlying data may have shifted, and the cursor's interpretation is tied to the request that produced it. Reusing it can return inconsistent, incomplete, or misleading results.
- **Use the cursor immediately, then discard it.** A cursor is only meaningful for the next request in the same paging loop, issued promptly after receiving it. Always pass back the exact `next` value you received.


## Why this happens

Most listing endpoints sort results by a domain field rather than by an immutable insertion timestamp. For example, `GET /v1/events` is ordered by the first session's start time — the same field `from` and `to` filter on. This is intentional: we cannot both sort results by session start time *and* by a separate, stable insertion order at the same time, and sorting by start time is what makes the endpoint useful for the ad-hoc cases it is designed for.

The consequence is that if an event is created, updated, or has its start time changed inside your `from`/`to` window while you are paging, the ordering shifts and the result set you observe across pages can diverge from any single snapshot.

## For continuous synchronization: use webhooks

If you are using a listing endpoint to keep a local data store in sync — periodically re-paging to find out what changed — switch to [webhooks](/docs/usage/webhooks). That is the mechanism designed for the job:

- New resource -> `*.created` webhook -> insert into your store.
- Updated or rescheduled resource -> `*.updated` webhook -> update your store.
- Deleted or cancelled resource -> `*.deleted` or `*.cancelled` webhook -> reflect the change.


Use the listing endpoint to bootstrap your store once, ad-hoc. Webhooks keep it in sync from there.