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

# Pagination

> Cursors and incremental syncing.

Lists always return the same envelope:

```json theme={null}
{
  "object": "list",
  "data": [ /* ... */ ],
  "has_more": true,
  "next_cursor": "6a3d04709bcd4477a1bfe4b3"
}
```

To get the next page, repeat the call with `starting_after`:

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.ventry.es/v1/tickets?limit=100&starting_after=6a3d04709bcd4477a1bfe4b3" \
    -H "Authorization: Bearer $VENTRY_KEY"
  ```

  ```js Node.js theme={null}
  const query = new URLSearchParams({
    limit: "100",
    starting_after: "6a3d04709bcd4477a1bfe4b3",
  });

  await fetch(`https://api.ventry.es/v1/tickets?${query}`, {
    headers: { Authorization: `Bearer ${process.env.VENTRY_KEY}` },
  });
  ```

  ```python Python theme={null}
  requests.get(
      "https://api.ventry.es/v1/tickets",
      params={"limit": 100, "starting_after": "6a3d04709bcd4477a1bfe4b3"},
      headers={"Authorization": f"Bearer {os.environ['VENTRY_KEY']}"},
      timeout=10,
  )
  ```

  ```php PHP theme={null}
  $query = http_build_query([
      "limit" => 100,
      "starting_after" => "6a3d04709bcd4477a1bfe4b3",
  ]);

  $ch = curl_init("https://api.ventry.es/v1/tickets?$query");
  curl_setopt_array($ch, [
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("VENTRY_KEY")],
  ]);
  ```
</CodeGroup>

When `has_more` is `false`, `next_cursor` is `null` and you have reached the end.

| Parameter        | Value                                                                |
| ---------------- | -------------------------------------------------------------------- |
| `limit`          | 1–200. Defaults to 50.                                               |
| `starting_after` | The `next_cursor` from the previous page. Omit it on the first call. |

## Why cursors and not page numbers

Because the event carries on while you read. With `?page=2` and an `OFFSET`,
every new ticket shifts the contents and you end up skipping or repeating rows.
A cursor points at a specific element, so moving forward always means "whatever
comes after this one", no matter what happens behind you.

## Incremental syncing

Combine the cursor with a time filter. Store when your last pass finished and
start the next one from there.

<Tabs>
  <Tab title="Tickets">
    Use `updated_since`, which filters by last modification. That way you receive
    both new tickets and those whose state changed — for instance, the ones
    accredited since your last pass.

    ```bash theme={null}
    curl "https://api.ventry.es/v1/tickets?updated_since=2026-06-12T18:00:00Z&limit=200" \
      -H "Authorization: Bearer $VENTRY_KEY"
    ```
  </Tab>

  <Tab title="Sales, top-ups and access logs">
    Use `since`, which filters by **`occurred_at`**: when the event actually
    happened, not when it was synced.

    ```bash theme={null}
    curl "https://api.ventry.es/v1/transactions?since=2026-06-12T18:00:00Z&limit=200" \
      -H "Authorization: Bearer $VENTRY_KEY"
    ```
  </Tab>
</Tabs>

<Warning>
  **Overlap your window a little.** A terminal without connectivity may sync a sale
  hours after it happened, and that sale has an `occurred_at` earlier than your
  last pass: if you start exactly where you left off, you will never see it. Go
  back by whatever margin your operation tolerates — a few hours usually does — and
  deduplicate by `id` on your side.
</Warning>

## Walking a whole list

<CodeGroup>
  ```js Node.js theme={null}
  async function* paginate(path, params = {}) {
    let cursor = null;

    do {
      const query = new URLSearchParams({ ...params, limit: "200" });
      if (cursor) query.set("starting_after", cursor);

      const response = await fetch(`https://api.ventry.es/v1${path}?${query}`, {
        headers: { Authorization: `Bearer ${process.env.VENTRY_KEY}` },
      });
      const page = await response.json();

      for (const item of page.data) yield item;
      cursor = page.has_more ? page.next_cursor : null;
    } while (cursor);
  }

  for await (const ticket of paginate("/tickets", { status: "used" })) {
    console.log(ticket.code, ticket.checked_in_at);
  }
  ```

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

  def paginate(path, **params):
      cursor = None

      while True:
          query = {**params, "limit": 200}
          if cursor:
              query["starting_after"] = cursor

          page = requests.get(
              f"https://api.ventry.es/v1{path}",
              params=query,
              headers={"Authorization": f"Bearer {os.environ['VENTRY_KEY']}"},
              timeout=30,
          ).json()

          yield from page["data"]

          if not page["has_more"]:
              return
          cursor = page["next_cursor"]

  for ticket in paginate("/tickets", status="used"):
      print(ticket["code"], ticket["checked_in_at"])
  ```

  ```php PHP theme={null}
  <?php
  function paginate(string $path, array $params = []): Generator
  {
      $cursor = null;

      do {
          $query = array_merge($params, ["limit" => 200]);
          if ($cursor !== null) {
              $query["starting_after"] = $cursor;
          }

          $ch = curl_init("https://api.ventry.es/v1$path?" . http_build_query($query));
          curl_setopt_array($ch, [
              CURLOPT_RETURNTRANSFER => true,
              CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("VENTRY_KEY")],
          ]);
          $page = json_decode(curl_exec($ch), true);
          curl_close($ch);

          yield from $page["data"];

          $cursor = $page["has_more"] ? $page["next_cursor"] : null;
      } while ($cursor !== null);
  }

  foreach (paginate("/tickets", ["status" => "used"]) as $ticket) {
      echo $ticket["code"], " ", $ticket["checked_in_at"], PHP_EOL;
  }
  ```
</CodeGroup>

<Note>
  A page may come back with fewer items than the `limit` you asked for and still
  have `has_more: true`. That happens when your key is restricted to certain ticket
  types and the page contained records outside your scope. **Trust `has_more`, not
  the item count**: stopping because a page came back short would leave data
  unread.
</Note>
