---
title: Pagination
description: Lists return one page at a time. Ask for up to 100 records with first, then follow page_info.end_cursor with after until has_next_page is false. Cursors must be URL-encoded.
canonical: https://developers.coevera.com/concepts/pagination/
section: concepts
verification: behaviour
lastVerified: 2026-10-15
publisher: Coevera
---

# Pagination

**Answer:** Send first (default 30, maximum 100) and read page_info in the response. While has_next_page is true, send page_info.end_cursor as after, URL-encoded. In GraphQL the fields are pageInfo.hasNextPage and endCursor; always send first to getByCriteria, at most 100.

A list request returns one page of records, the total, and the cursors that fetch the pages
around it. Loop until the response says there is no next page. Every list operation in the
[REST reference](/reference/rest/), such as [list accounts](/reference/rest/accounts/#list), takes
the parameters below; the [pipeline report](/recipes/pipeline-report/) recipe pages through
opportunities with a filter and `expand`.

## How cursors work

A cursor is an opaque string that marks a position in the result. Keep the same filter and sort
while you follow it, and never build or parse a cursor yourself.

| Parameter        | Meaning                                                                                                                                                           |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `first`          | Page size, counted from the start or from `after`. Default 30, maximum 100.                                                                                       |
| `after`          | A cursor: return the records after this position                                                                                                                  |
| `last`, `before` | The same, backwards: `last=2` returns the last two records, `before` the records before a cursor. With `first`, `last` takes the last records of that first page. |

## Read page_info

Every list response carries `total`, the number of all matching records rather than of this page,
and the cursors of the page you received:

**Recorded response · REST · 200 OK**

```json
{
  "success": true,
  "total": 3,
  "page_info": {
    "start_cursor": "WyJDb250b3NvIEZyZWlnaHQiLCAiMGEwMDAwMDAtMDAwMC00MDAwLWEwMDAtMDAwMDAwMDAwMDAxIl0=",
    "end_cursor": "WyJGYWJyaWthbSBSb2JvdGljcyIsICIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ==",
    "has_previous_page": false,
    "has_next_page": true
  },
  "data": [
    {
      "id": "0a000000-0000-4000-a000-000000000001",
      "city": "Tacoma",
      "name": "Contoso Freight"
    },
    {
      "id": "0a000000-0000-4000-a000-000000000002",
      "city": "Vancouver",
      "name": "Fabrikam Robotics"
    }
  ]
}
```

**Recorded response · GraphQL · 200 OK**

```json
{
  "data": {
    "entities": {
      "account": {
        "getByCriteria": {
          "totalCount": 3,
          "pageInfo": {
            "hasNextPage": true,
            "endCursor": "WyJGYWJyaWthbSBSb2JvdGljcyIsICIwYTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDIiXQ=="
          },
          "edges": [
            {
              "node": {
                "id": "0a000000-0000-4000-a000-000000000001",
                "name": "Contoso Freight",
                "city": "Tacoma"
              }
            },
            {
              "node": {
                "id": "0a000000-0000-4000-a000-000000000002",
                "name": "Fabrikam Robotics",
                "city": "Vancouver"
              }
            }
          ]
        }
      }
    }
  }
}
```

_Recorded on 2026-10-15 against API 6.5.2 in the docs sandbox, de-identified._

| Field                                         | Meaning                                     |
| --------------------------------------------- | ------------------------------------------- |
| `page_info.has_next_page`                     | More records follow: ask again with `after` |
| `page_info.end_cursor`                        | The value to send as `after`                |
| `page_info.start_cursor`, `has_previous_page` | The same for paging backwards with `before` |

## Loop through every page

This loop lists the accounts in Tacoma, Portland and Vancouver, two at a time so that these three
accounts of the docs world span two pages. Use up to 100 per page in your own code:

**REST · cURL**

```bash
COEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"

# 2, so that the three accounts span two pages; send up to 100.
PAGE_SIZE=2
cursor=""
after=()
while :; do
  # The same filter, sort and page size on every page; only after changes.
  page=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Accounts" \
    -d "filter[city]=Tacoma,Portland,Vancouver" \
    -d order-by=name \
    -d load-only=id,name,city \
    -d "first=$PAGE_SIZE" \
    "${after[@]}" \
    -u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
  jq -r '.data[] | "\(.name) (\(.city))"' <<<"$page"
  [ "$(jq -r '.page_info.has_next_page' <<<"$page")" = true ] || break
  next=$(jq -r '.page_info.end_cursor' <<<"$page")
  # An unchanged cursor would ask for the same page again: stop rather than loop forever.
  [ "$next" != "$cursor" ] || break
  cursor=$next
  # --data-urlencode: a raw "+" in the cursor would arrive as a space.
  after=(--data-urlencode "after=$cursor")
done
```

**REST · TypeScript**

```ts
import { Buffer } from "node:buffer";
import { env } from "node:process";

const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");

// 2, so that the three accounts span two pages; send up to 100.
const PAGE_SIZE = 2;
// The same filter, sort and page size on every page. The commas in filter[city] stay raw: a raw
// comma separates OR values, while %2C (what URLSearchParams sends) is a literal comma.
const query = `filter[city]=Tacoma,Portland,Vancouver&order-by=name&load-only=id,name,city&first=${PAGE_SIZE}`;
let cursor: string | null = null;
do {
  // encodeURIComponent: a raw "+" in the cursor would arrive as a space.
  const after: string = cursor ? `&after=${encodeURIComponent(cursor)}` : "";
  const res = await fetch(`${base}/entities/Accounts?${query}${after}`, {
    headers: { Authorization: `Basic ${auth}` },
  });
  const page = await res.json();
  if (!res.ok) throw new Error(`HTTP ${res.status} ${page.name}: ${page.message}`);
  for (const account of page.data) console.log(`${account.name} (${account.city})`);
  const next: string | null = page.page_info.has_next_page ? page.page_info.end_cursor : null;
  // An unchanged cursor would ask for the same page again: stop rather than loop forever.
  cursor = next === cursor ? null : next;
} while (cursor);
```

**REST · Python**

```python
import os
import sys
from urllib.parse import quote

import requests

BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])

# 2, so that the three accounts span two pages; send up to 100.
PAGE_SIZE = 2
# The same filter, sort and page size on every page. The commas in filter[city] stay raw: a raw
# comma separates OR values, while %2C (what params= sends) is a literal comma.
query = f"filter[city]=Tacoma,Portland,Vancouver&order-by=name&load-only=id,name,city&first={PAGE_SIZE}"
cursor = None
while True:
    # quote(..., safe=""): a raw "+" in the cursor would arrive as a space.
    after = f"&after={quote(cursor, safe='')}" if cursor else ""
    response = session.get(f"{BASE}/entities/Accounts?{query}{after}", timeout=30)
    page = response.json()
    if not response.ok:
        sys.exit(f"HTTP {response.status_code} {page.get('name')}: {page.get('message')}")
    for account in page["data"]:
        print(f"{account['name']} ({account['city']})")
    if not page["page_info"]["has_next_page"]:
        break
    # An unchanged cursor would ask for the same page again: stop rather than loop forever.
    if page["page_info"]["end_cursor"] == cursor:
        break
    cursor = page["page_info"]["end_cursor"]
```

**GraphQL · cURL**

```bash
COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public"

# 2, so that the three accounts span two pages; send up to 100.
PAGE_SIZE=2
QUERY=$(cat <<'GRAPHQL'
query Accounts($first: Int!, $after: String) {
  entities {
    account {
      getByCriteria(
        first: $first
        after: $after
        filter: { city: { eq: ["Tacoma", "Portland", "Vancouver"] } }
        orderBy: [{ name: Asc }]
      ) {
        totalCount
        pageInfo { hasNextPage endCursor }
        edges { node { id name city } }
      }
    }
  }
}
GRAPHQL
)
cursor=null
while :; do
  page=$(jq -n --arg query "$QUERY" --argjson first "$PAGE_SIZE" --argjson after "$cursor" \
      '{ query: $query, variables: { first: $first, after: $after } }' |
    curl -sS --fail-with-body "$COEVERA_GRAPHQL" \
      -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
      -H "Content-Type: application/json" \
      -d @-)
  # GraphQL reports errors in the body, often with HTTP 200.
  if jq -e '.errors' <<<"$page" >/dev/null; then
    echo "$page" >&2
    exit 1
  fi
  jq -r '.data.entities.account.getByCriteria.edges[].node | "\(.name) (\(.city))"' <<<"$page"
  [ "$(jq -r '.data.entities.account.getByCriteria.pageInfo.hasNextPage' <<<"$page")" = true ] || break
  cursor=$(jq '.data.entities.account.getByCriteria.pageInfo.endCursor' <<<"$page")
done
```

**GraphQL · TypeScript**

```ts
import { Buffer } from "node:buffer";
import { env } from "node:process";

const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");

// gql: one GraphQL request. GraphQL reports errors in the body, often with HTTP 200: check both.
async function gql(query: string, variables: object = {}) {
  const res = await fetch(endpoint, {
    method: "POST",
    headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  const body = await res.json();
  if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`);
  return body.data;
}

// 2, so that the three accounts span two pages; send up to 100.
const PAGE_SIZE = 2;
const ACCOUNTS = `query Accounts($first: Int!, $after: String) {
  entities {
    account {
      getByCriteria(
        first: $first
        after: $after
        filter: { city: { eq: ["Tacoma", "Portland", "Vancouver"] } }
        orderBy: [{ name: Asc }]
      ) {
        totalCount
        pageInfo { hasNextPage endCursor }
        edges { node { id name city } }
      }
    }
  }
}`;
let after: string | null = null;
do {
  const data = await gql(ACCOUNTS, { first: PAGE_SIZE, after });
  const page = data.entities.account.getByCriteria;
  for (const edge of page.edges) console.log(`${edge.node.name} (${edge.node.city})`);
  after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (after);
```

**GraphQL · Python**

```python
import json
import os
import sys

import requests

ENDPOINT = (
    "https://{region}.api.coevera.com/api/v100/app/space/"
    "{space_id}/graphql/public"
)
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])


def gql(query, variables=None):
    """One GraphQL request. GraphQL reports errors in the body, often with HTTP 200: check both."""
    response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30)
    body = response.json()
    if not response.ok or body.get("errors"):
        sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}")
    return body["data"]


# 2, so that the three accounts span two pages; send up to 100.
PAGE_SIZE = 2
ACCOUNTS = """
query Accounts($first: Int!, $after: String) {
  entities {
    account {
      getByCriteria(
        first: $first
        after: $after
        filter: { city: { eq: ["Tacoma", "Portland", "Vancouver"] } }
        orderBy: [{ name: Asc }]
      ) {
        totalCount
        pageInfo { hasNextPage endCursor }
        edges { node { id name city } }
      }
    }
  }
}
"""
after = None
while True:
    page = gql(ACCOUNTS, {"first": PAGE_SIZE, "after": after})["entities"]["account"]["getByCriteria"]
    for edge in page["edges"]:
        print(f"{edge['node']['name']} ({edge['node']['city']})")
    if not page["pageInfo"]["hasNextPage"]:
        break
    after = page["pageInfo"]["endCursor"]
```

> **URL-encode the cursor:** Cursors are base64 and can contain `+`, `/` and `=`. Query values are URL-decoded, and a raw `+`
> becomes a space, which breaks the cursor. Encode it as the sample does: `--data-urlencode`,
> `encodeURIComponent` or `urllib.parse.quote`.

## GraphQL connections

Use a repository's `getByCriteria`: it returns a connection with `totalCount`, `pageInfo` and
`edges`, and takes `first` and `after`. `getAll` takes `limit` and `offset` and has no cursor;
prefer `getByCriteria` for anything you loop over.

Always send `first`, at most 100.

## Legacy limit and offset

`limit` and `offset` still work on REST lists, up to 100 per page, but they are deprecated: the
response has `total` and `data` and no `page_info`. Use cursors.

## Limits

| Limit                       | Value | What happens above it                                                              |
| --------------------------- | ----- | ---------------------------------------------------------------------------------- |
| Page size (`first`, `last`) | 100   | HTTP 400, code 117 `ERROR_PARAMETER_INVALID`: "Limit must not be greater than 100" |
| Default page size           | 30    | Used when you send no `first`                                                      |
| Legacy `limit` / `offset`   | 100   | Accepted, but the response has no `page_info`; use cursors                         |
| GraphQL `getByCriteria`     | 100   | Send `first` on every call                                                         |
