Pagination
Cursor pagination with starting_after and ending_before, Stripe-style, plus the page-based lists of the template library and presentation history.
Most list endpoints in the public API use cursor pagination. Same
semantics as Stripe / Linear: you paginate by an opaque identifier (the
resource id), not by page number. This guarantees stable results even
when new resources are created during iteration. A short set of lists paginates by page
number instead; see Page-based lists.
List endpoints live on their axis: company resources under
/v1/companies/{company}/… and account resources under
/v1/accounts/{account}/…. The snippets below read that opaque id from a
COMPANY_ID variable — GET /v1/me enumerates the companies your credential
reaches, each with its id and its tax_id.
Parameters
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
limit | integer | 25 | 1–100 | Number of items per page. |
starting_after | string | null | id (UUID v7) | Returns items created after the resource whose id is passed. |
ending_before | string | null | id (UUID v7) | Returns items created before the resource whose id is passed. |
sort | string | -created | per-resource field | Sort field; - prefix for descending (e.g. -total). See Order. |
starting_after and ending_before are mutually exclusive. Sending
both in the same request responds 422 with a invalid_request_error
envelope.
Response shape
{
"data": [
{ "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b", "...": "..." },
{ "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0c", "...": "..." }
],
"has_more": true,
"next_cursor": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0c"
}data: array of up tolimititems, ordered byidDESC (equivalent tocreated_atDESC because we use UUID v7).has_more:trueif there are more items before the first one indata(newer) when paginating withstarting_after, or after the last when paginating withending_before.next_cursor:idof the last item indata. Pass it asstarting_afterin the next request to move forward.
When there are no more items has_more is false and next_cursor is
null.
Iterate all results
import os, requests
COMPANY_ID = os.environ['COMPANY_ID']
def iterate(endpoint):
cursor = None
while True:
params = {'limit': 100}
if cursor:
params['starting_after'] = cursor
resp = requests.get(
f'https://api.factuarea.com/v1/companies/{COMPANY_ID}/{endpoint}',
params=params,
headers={'Authorization': f"Bearer {os.environ['FACTUAREA_API_KEY']}"},
)
resp.raise_for_status()
body = resp.json()
yield from body['data']
if not body['has_more']:
break
cursor = body['next_cursor']
for invoice in iterate('invoices'):
print(invoice['id'], invoice['number'])const COMPANY_ID = process.env.COMPANY_ID;
async function* iterate(endpoint) {
let cursor = null;
while (true) {
const url = new URL(
`https://api.factuarea.com/v1/companies/${COMPANY_ID}/${endpoint}`,
);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('starting_after', cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.FACTUAREA_API_KEY}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
for (const item of body.data) yield item;
if (!body.has_more) break;
cursor = body.next_cursor;
}
}
for await (const invoice of iterate('invoices')) {
console.log(invoice.id, invoice.number);
}cursor=""
while : ; do
if [ -z "$cursor" ]; then
url="https://api.factuarea.com/v1/companies/$COMPANY_ID/invoices?limit=100"
else
url="https://api.factuarea.com/v1/companies/$COMPANY_ID/invoices?limit=100&starting_after=$cursor"
fi
resp=$(curl -s -H "Authorization: Bearer $FACTUAREA_API_KEY" "$url")
echo "$resp" | jq -c '.data[]'
has_more=$(echo "$resp" | jq -r '.has_more')
cursor=$(echo "$resp" | jq -r '.next_cursor')
[ "$has_more" = "true" ] || break
doneIterate with the official SDK
The TypeScript and PHP SDKs hide the cursor entirely: list methods return an iterator that walks every page for you.
const page = await factuarea.invoices.list({ status: "paid", limit: 50 });
// iterate every item across every page — cursors handled internally
for await (const invoice of page) {
console.log(invoice.id, invoice.number);
}
// or walk page by page
page.data; // items on this page
page.hasMore; // boolean
page.nextCursor; // opaque cursor or null
const next = await page.getNextPage(); // Page | null
const all = await page.toArray(); // collect everythinguse Factuarea\Sdk\Custom\Pagination\PageIterator;
use Factuarea\Sdk\Models\Operations\PublicApiV1InvoicesListRequest;
$pages = new PageIterator(
fn (?string $cursor) => $factuarea->invoices->publicApiV1InvoicesList(
new PublicApiV1InvoicesListRequest(startingAfter: $cursor),
)->rawResponse,
);
foreach ($pages->items() as $invoice) {
echo $invoice['id'], PHP_EOL;
}See SDKs › Paginating with the SDK for the full surface.
Page-based lists
A short set of lists does not use the cursor. The template library and the presentation history of a document are small, ordered by a fixed key and need the total, so they paginate by page number.
| Operation | Parameters | Envelope | Order |
|---|---|---|---|
GET /v1/companies/{company}/pdf-templates | q, state, document_type, purpose, format, page, per_page | items and meta | Name, then id |
GET /v1/companies/{company}/pdf-commercial-identities | q, state, page, per_page | items and meta | Name, then id |
GET /v1/companies/{company}/{invoices|quotes|proformas|delivery-notes}/{id}/presentation-history | page, per_page | data and meta | Capture number, descending |
pageis an integer from1(default1).per_pageis an integer from1to100(default25).stateisactive(default),archivedorall.- A
page,per_pageor filter outside its range answers422; the API never trims it silently. metais always{ "page", "per_page", "total" }, three integers: the page you asked for, its size and the total number of rows that match. An empty list is200with an empty array and"total": 0; it is not an error.- There is no
has_more,next_cursororlinks: stop whenpage * per_pagereachestotal.
curl -G https://api.factuarea.com/v1/companies/$COMPANY_ID/pdf-templates \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "state=all" \
--data-urlencode "per_page=50" \
--data-urlencode "page=2"{
"items": [],
"meta": { "page": 2, "per_page": 50, "total": 31 }
}The example is the answer when the list holds 31 templates: page 2 of 50 is past the
end, so items is empty and total still tells you where the list stops.
Other operations that return a bounded history, such as
GET /v1/companies/{company}/invoices/{invoice}/administrative-revisions, are not paginated: they return
the whole history in one answer ordered by version. A few older lists also take
page and per_page; read the response schema of each operation in the
API Reference before you rely on its envelope.
Order
Pass ?sort=<field> to order a list. A bare field sorts ascending; a
- prefix sorts descending (e.g. ?sort=-total). When omitted, the
default is -created — equivalent to id DESC, a stable total order
because we use UUID v7 (which encodes a timestamp in the high 48 bits, so
"most recently created first" needs no extra created_at column).
The cursor (starting_after / ending_before) keeps working with the
order you choose: the chosen field is the primary sort and the resource
id is a stable secondary sort, so pagination stays deterministic even
when rows share the same value (Stripe-style).
Allowed sort fields are scoped per resource — sending an
unsupported field responds 422 with a invalid_request_error envelope:
| Resource | Allowed sort fields |
|---|---|
invoices | created, total, number |
quotes | created, total, number, valid_until |
proformas | created, total, number, valid_until |
delivery_notes | created, number, delivery_date |
purchase_invoices | created, total, issued_on, due_on |
recurring_invoices | created, next_run_at |
# Invoices, highest total first
curl -G https://api.factuarea.com/v1/companies/$COMPANY_ID/invoices \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "sort=-total"
# Quotes by expiry date, soonest first
curl -G https://api.factuarea.com/v1/companies/$COMPANY_ID/quotes \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "sort=valid_until"Why not ?page=?
Numeric pages have problems when the dataset changes during iteration:
- Creating a resource between pages → duplicates rows.
- Deleting a resource between pages → drops rows.
COUNT(*)is expensive past a few thousand rows.
Cursor pagination with UUID v7 eliminates both: the cursor points to a stable position in time, not a variable offset.
That's why the cursor lists have no ?page=N offset parameter — they page only
with the starting_after / ending_before cursor. An
invalid cursor value responds 422 with a invalid_request_error
envelope (code: parameter_invalid_cursor):
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid_cursor",
"message": "The provided cursor is not a valid resource id.",
"param": "starting_after",
"request_id": "req_..."
}
}Use case: fetch only new results
If your integration polls every N minutes, save the next_cursor (the
newest id you've seen) between pollings. On the next pass use
ending_before=<saved_cursor> to fetch only items newer than that
point.
last_seen = load_last_cursor() # resource id stored in your DB
resp = requests.get(
f'https://api.factuarea.com/v1/companies/{COMPANY_ID}/invoices',
params={'limit': 100, 'ending_before': last_seen} if last_seen else {'limit': 100},
headers={'Authorization': f"Bearer {API_KEY}"},
)
new_invoices = resp.json()['data']
if new_invoices:
save_last_cursor(new_invoices[0]['id']) # the newest one