Factuarea APIDevelopers

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

ParameterTypeDefaultRangeDescription
limitinteger251–100Number of items per page.
starting_afterstringnullid (UUID v7)Returns items created after the resource whose id is passed.
ending_beforestringnullid (UUID v7)Returns items created before the resource whose id is passed.
sortstring-createdper-resource fieldSort 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 to limit items, ordered by id DESC (equivalent to created_at DESC because we use UUID v7).
  • has_more: true if there are more items before the first one in data (newer) when paginating with starting_after, or after the last when paginating with ending_before.
  • next_cursor: id of the last item in data. Pass it as starting_after in 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
done

Iterate 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 everything
use 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.

OperationParametersEnvelopeOrder
GET /v1/companies/{company}/pdf-templatesq, state, document_type, purpose, format, page, per_pageitems and metaName, then id
GET /v1/companies/{company}/pdf-commercial-identitiesq, state, page, per_pageitems and metaName, then id
GET /v1/companies/{company}/{invoices|quotes|proformas|delivery-notes}/{id}/presentation-historypage, per_pagedata and metaCapture number, descending
  • page is an integer from 1 (default 1). per_page is an integer from 1 to 100 (default 25).
  • state is active (default), archived or all.
  • A page, per_page or filter outside its range answers 422; the API never trims it silently.
  • meta is 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 is 200 with an empty array and "total": 0; it is not an error.
  • There is no has_more, next_cursor or links: stop when page * per_page reaches total.
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:

ResourceAllowed sort fields
invoicescreated, total, number
quotescreated, total, number, valid_until
proformascreated, total, number, valid_until
delivery_notescreated, number, delivery_date
purchase_invoicescreated, total, issued_on, due_on
recurring_invoicescreated, 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

On this page

Need a hand?Contact support