Factuarea APIDevelopers

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ámetroTipoPor defectoRangoDescripción
limitinteger251–100Número de elementos por página.
starting_afterstringnullid (UUID v7)Devuelve los elementos creados después del recurso cuyo id se pasa.
ending_beforestringnullid (UUID v7)Devuelve los elementos creados antes del recurso cuyo id se pasa.
sortstring-createdcampo por recursoCampo 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 hasta limit elementos, ordenados por id DESC (equivalente a created_at DESC porque usamos UUID v7).
  • has_more: true si hay más elementos antes del primero de data (más nuevos) al paginar con starting_after, o después del último al paginar con ending_before.
  • next_cursor: id del último elemento de data. Pásalo como starting_after en 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
done

Iterar 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 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;
}

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ónParámetrosEnvoltorioOrden
GET /v1/companies/{company}/pdf-templatesq, state, document_type, purpose, format, page, per_pageitems y metaNombre y después id
GET /v1/companies/{company}/pdf-commercial-identitiesq, state, page, per_pageitems y metaNombre y después id
GET /v1/companies/{company}/{invoices|quotes|proformas|delivery-notes}/{id}/presentation-historypage, per_pagedata y metaNúmero de captura, descendente
  • page es un entero desde 1 (por defecto 1). per_page es un entero de 1 a 100 (por defecto 25).
  • state es active (por defecto), archived o all.
  • Un page, per_page o filtro fuera de rango responde 422; la API nunca lo recorta en silencio.
  • meta siempre 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 es 200 con un array vacío y "total": 0; no es un error.
  • No hay has_more, next_cursor ni links: para cuando page * per_page alcance 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 }
}

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:

RecursoCampos sort permitidos
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
# 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

En esta página

¿Te echamos una mano?Contactar con soporte