Pagination
How list responses, page metadata, and truncation flags work across the Parsel API.
Paginated list endpoints in the Parsel API return a uniform { data, metadata } envelope. This page documents the shared shape and how to iterate through pages, using the Billing API as the running example.
The list envelope
Every list endpoint wraps its rows in data and its page state in metadata. For example, GET /billing/invoices/{id}/line_items:
{
"data": [
/* zero or more items */
],
"metadata": {
"current_page": 1,
"page_size": 10,
"total_count": 35,
"total_pages": 4,
"has_next_page": true,
"has_previous_page": false
}
}| Field | Type | Meaning |
|---|---|---|
current_page | integer or null | The 1-indexed page returned. null for cursor-based pagination. |
page_size | integer | Items per page. Defaults to 50. |
total_pages | integer | Total number of pages at the current page_size. Best-effort — see note below. |
total_count | integer | Total items across all pages. Best-effort — see note below. |
has_next_page | boolean | Whether there's another page (or cursor page) after this one. |
has_previous_page | boolean | Whether there's a page (or cursor page) before this one. |
Pass ?page=N&page_size=M on the request to control the slice. Defaults are page=1 and page_size=50.
total_pages and total_count are best-effort: for requests using cursor-based pagination, or for certain filter combinations where computing an exact count would be too expensive, both fields come back as 0 rather than a real total. Don't rely on comparing current_page to total_pages to detect the last page - check has_next_page instead, which is always accurate regardless of pagination style or whether a count was computed.
Iterate through every page
When you need every record — for an export, a reconciliation job, or a backfill — increment page until you've consumed all total_pages.
async function fetchAll() {
const all = [];
let page = 1;
while (true) {
const url = new URL("https://api.parsel.app/billing/invoices");
url.searchParams.set("page", String(page));
url.searchParams.set("page_size", "100");
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.PARSEL_TOKEN}` },
});
if (!res.ok) throw new Error(`Parsel API ${res.status}`);
const { data, metadata } = await res.json();
all.push(...data);
if (!metadata.has_next_page) break;
page += 1;
}
return all;
}def fetch_all():
all_rows = []
page = 1
while True:
resp = requests.get(
"https://api.parsel.app/billing/invoices",
headers={"Authorization": f"Bearer {os.environ['PARSEL_TOKEN']}"},
params={"page": page, "page_size": 100},
)
resp.raise_for_status()
body = resp.json()
all_rows.extend(body["data"])
if not body["metadata"]["has_next_page"]:
break
page += 1
return all_rowsThe same loop pattern works for any paginated endpoint.
Cursor-based pagination
Some list endpoints also support cursor-based pagination as an alternative to page/page_size. Currently available on: GET /shipments.
Request parameters
| Parameter | Type | Purpose |
|---|---|---|
first | integer | Number of items to fetch after the after cursor (or from the start, if after is omitted). |
after | string | Cursor to fetch items after. Use metadata.end_cursor from a previous response. |
last | integer | Number of items to fetch before the before cursor. |
before | string | Cursor to fetch items before. Use metadata.start_cursor from a previous response. |
page/page_size and first/after/last/before are mutually exclusive - use one style or the other in a given request, not both.
Response metadata
Cursor-paginated responses use the same metadata object as offset pagination, with two additional fields:
| Field | Type | Purpose |
|---|---|---|
end_cursor | string or null | Cursor for the last item on this page. Pass as after to fetch the next page. |
start_cursor | string or null | Cursor for the first item on this page. Pass as before to fetch the previous page. |
current_page, total_pages, and total_count are not meaningful for cursor pagination - current_page comes back null, and total_pages/total_count come back 0. Use has_next_page/has_previous_page to know whether to keep paging.
Paging forward through all results
GET /shipments?first=50
→ metadata: { has_next_page: true, end_cursor: "abc123", ... }
GET /shipments?first=50&after=abc123
→ metadata: { has_next_page: true, end_cursor: "def456", ... }
GET /shipments?first=50&after=def456
→ metadata: { has_next_page: false, end_cursor: "ghi789", ... }Stop once has_next_page is false.
Single-resource envelope
Single-resource endpoints (e.g. GET /billing/invoices/{id}) wrap the resource in data with no metadata block:
{
"data": {
"id": "0a6bb80e-3d62-4663-b82f-11840b83850b",
"invoice_number": "INV-VXW8LQ-20260401-20260415",
"status": "OPEN"
}
}Truncation signals
Some endpoints inline a related collection up to a fixed cap and surface a boolean flag when truncated. The Billing API's show endpoint, for instance, inlines up to 250 line items and sets line_items_has_next_page: true when the invoice has more. When that flag is true, switch to the dedicated paginated endpoint (GET /billing/invoices/{id}/line_items) to read the remainder.
The flag and the 250-item cap are specific to billing line items, but the pattern — inline-with-cap plus a *_has_next_page flag — is the convention any future endpoint will follow.
Empty pages
A page value past total_pages returns 200 with data: []. There is no special "out of range" error.