ConceptsPagination
Pagination
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.
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, such as list accounts, takes
the parameters below; the 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:
{
"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"
}
]
}{
"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"
}
}
]
}
}
}
}
}Show all 31 linesShow less
| Field | Meaning |
|---|---|
page_ |
More records follow: ask again with after |
page_ |
The value to send as after |
page_, has_ |
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:
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")
doneimport { 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);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"]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")
doneimport { 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);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_: "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_; use cursors |
GraphQL getByCriteria |
100 | Send first on every call |