Paginación
Paginación por cursor con starting_after y ending_before, al estilo Stripe, y las listas por página de la biblioteca de plantillas y del historial de presentaciones.
La mayoría de los endpoints de listado de la API pública usan paginación por cursor.
Misma semántica que Stripe / Linear: paginas por un identificador opaco (el
id del recurso), no por número de página. Esto garantiza resultados estables
incluso cuando se crean nuevos recursos durante la iteración. Un conjunto reducido de listas
pagina por número de página; consulta Listas por página.
Los endpoints de listado viven en su eje: los recursos de empresa bajo
/v1/companies/{company}/… y los de cuenta bajo /v1/accounts/{account}/….
Los snippets de abajo leen ese id opaco de una variable COMPANY_ID —
GET /v1/me enumera las empresas a las que llega tu credencial, cada una con
su id y su tax_id.
Parámetros
| Parámetro | Tipo | Por defecto | Rango | Descripción |
|---|---|---|---|---|
limit | integer | 25 | 1–100 | Número de elementos por página. |
starting_after | string | null | id (UUID v7) | Devuelve los elementos creados después del recurso cuyo id se pasa. |
ending_before | string | null | id (UUID v7) | Devuelve los elementos creados antes del recurso cuyo id se pasa. |
sort | string | -created | campo por recurso | Campo de orden; prefijo - para descendente (p.ej. -total). Ver Orden. |
starting_after y ending_before son mutuamente excluyentes. Enviar
ambos en la misma petición responde 422 con un envoltorio de error
invalid_request_error.
Forma de la respuesta
{
"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 de hastalimitelementos, ordenados poridDESC (equivalente acreated_atDESC porque usamos UUID v7).has_more:truesi hay más elementos antes del primero dedata(más nuevos) al paginar constarting_after, o después del último al paginar conending_before.next_cursor:iddel último elemento dedata. Pásalo comostarting_afteren la siguiente petición para avanzar.
Cuando no hay más elementos, has_more es false y next_cursor es
null.
Iterar todos los resultados
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
doneIterar con el SDK oficial
Los SDK de TypeScript y PHP ocultan el cursor por completo: los métodos de listado devuelven un iterador que recorre todas las páginas por ti.
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;
}Consulta SDKs › Paginar con el SDK para ver la superficie completa.
Listas por página
Un conjunto reducido de listas no usa el cursor. La biblioteca de plantillas y el historial de presentaciones de un documento son pequeños, van ordenados por una clave fija y necesitan el total, así que paginan por número de página.
| Operación | Parámetros | Envoltorio | Orden |
|---|---|---|---|
GET /v1/companies/{company}/pdf-templates | q, state, document_type, purpose, format, page, per_page | items y meta | Nombre y después id |
GET /v1/companies/{company}/pdf-commercial-identities | q, state, page, per_page | items y meta | Nombre y después id |
GET /v1/companies/{company}/{invoices|quotes|proformas|delivery-notes}/{id}/presentation-history | page, per_page | data y meta | Número de captura, descendente |
pagees un entero desde1(por defecto1).per_pagees un entero de1a100(por defecto25).stateesactive(por defecto),archivedoall.- Un
page,per_pageo filtro fuera de rango responde422; la API nunca lo recorta en silencio. metasiempre es{ "page", "per_page", "total" }, tres enteros: la página que pediste, su tamaño y el total de filas que coinciden. Una lista vacía es200con un array vacío y"total": 0; no es un error.- No hay
has_more,next_cursornilinks: para cuandopage * per_pagealcancetotal.
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 }
}El ejemplo es la respuesta cuando la lista tiene 31 plantillas: la página 2 de 50 queda
más allá del final, así que items está vacío y total te sigue diciendo dónde acaba la lista.
Otras operaciones que devuelven un historial acotado, como
GET /v1/companies/{company}/invoices/{invoice}/administrative-revisions, no se paginan: devuelven todo el
historial en una sola respuesta ordenada por versión. Algunas listas más antiguas también
aceptan page y per_page; lee el esquema de respuesta de cada operación en la
API Reference antes de fiarte de su envoltorio.
Orden
Pasa ?sort=<campo> para ordenar un listado. Un campo a secas ordena de
forma ascendente; el prefijo - ordena de forma descendente
(p.ej. ?sort=-total). Si se omite, el valor por defecto es
-created — equivalente a id DESC, un orden total y estable porque
usamos UUID v7 (que codifica una marca de tiempo en los 48 bits altos, así
que "los más recientemente creados primero" no necesita ninguna columna
created_at adicional).
El cursor (starting_after / ending_before) sigue funcionando con el
orden que elijas: el campo escogido es el criterio principal y el id del
recurso es un criterio secundario estable, de modo que la paginación se
mantiene determinista incluso cuando varias filas comparten el mismo valor
(al estilo Stripe).
Los campos sort permitidos están acotados por recurso — enviar un
campo no soportado responde 422 con un envoltorio de error
invalid_request_error:
| Recurso | Campos sort permitidos |
|---|---|
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 |
# Facturas, mayor total primero
curl -G https://api.factuarea.com/v1/companies/$COMPANY_ID/invoices \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "sort=-total"
# Presupuestos por fecha de validez, los que vencen antes primero
curl -G https://api.factuarea.com/v1/companies/$COMPANY_ID/quotes \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "sort=valid_until"¿Por qué no ?page=?
Las páginas numéricas tienen problemas cuando el conjunto de datos cambia durante la iteración:
- Crear un recurso entre páginas → duplica filas.
- Eliminar un recurso entre páginas → omite filas.
COUNT(*)es costoso pasadas unos pocos miles de filas.
La paginación por cursor con UUID v7 elimina ambos: el cursor apunta a una posición estable en el tiempo, no a un desplazamiento variable.
Por eso las listas por cursor no tienen un parámetro de desplazamiento ?page=N — solo
paginan con el cursor starting_after / ending_before. Un
valor de cursor inválido responde 422 con un envoltorio de error
invalid_request_error (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_..."
}
}Caso de uso: obtener solo resultados nuevos
Si tu integración hace polling cada N minutos, guarda el next_cursor (el
id más reciente que has visto) entre cada sondeo. En la siguiente pasada usa
ending_before=<saved_cursor> para obtener solo los elementos más nuevos que ese
punto.
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