Pagination
Cursor pagination with starting_after and ending_before. No ?page=, Stripe-style semantics.
Every list endpoint in the public API uses 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.
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
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/{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'])async function* iterate(endpoint) {
let cursor = null;
while (true) {
const url = new URL(`https://api.factuarea.com/v1/${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/invoices?limit=100"
else
url="https://api.factuarea.com/v1/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.
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/invoices \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "sort=-total"
# Quotes by expiry date, soonest first
curl -G https://api.factuarea.com/v1/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 there is no ?page=N offset parameter — the only way to page
through a list is 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(
'https://api.factuarea.com/v1/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