Factuarea API
Conceptos clave

Paginación

Paginación por cursor con starting_after y ending_before. Sin ?page=, semántica al estilo Stripe.

Todos 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.

Parámetros

ParámetroTipoPor defectoRangoDescripción
limitinteger251100Nú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

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
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.

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/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/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 no hay un parámetro de desplazamiento ?page=N — la única forma de paginar por una lista es 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(
    '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

En esta página