Skip to content

Paginación

Todos los endpoints de listado (index) soportan tres modos de paginación: productos, categorías de producto, marcas, clientes, leads, cuentas, ingresos, egresos, automatizaciones, usuarios, y los del calendario (eventos, reservas y ligas de agenda). Para catálogos grandes (≥ 5 000 ítems) o sincronizaciones que recorren toda la tabla, usa cursores: la latencia es plana sin importar qué tan profunda vaya la consulta.

Parámetros

QueryDefaultMáxDescripción
limit25100Items por página. Pedir más de 100 se recorta a 100 silenciosamente.
starting_afterCursor (id) — devuelve los registros siguientes (más viejos en el orden default DESC).
ending_beforeCursor (id) — devuelve los registros anteriores (más nuevos).
include_totalfalseSi es true, agrega total y total_pages a meta. Cuenta todo el catálogo ignorando filtros. Tiene costo extra; ver más abajo.
include_filtered_totalfalseSi es true, agrega filtered_total y filtered_total_pages a meta, contando el resultado después de aplicar los filtros (q/search, brand, category). Ver más abajo.
page1200Legacy offset. Limitado a 200 — más profundo devuelve 400 offset_too_deep.

starting_after, ending_before y page son mutuamente excluyentes; si mandas más de uno, gana ending_before > starting_after > page.

Los registros se ordenan created_at DESC con desempate por _id DESC, así que la lista te llega del más nuevo al más viejo y el mismo cursor siempre apunta al mismo registro aunque dos documentos compartan el created_at al milisegundo.

Forma del response

json
{
  "data": [ { "id": "65a0...", "...": "..." } ],
  "meta": {
    "limit": 25,
    "has_more": true,
    "next_cursor": "65a0c0f1234567890abcdef0",
    "previous_cursor": "65a0c0e0fedcba9876543210"
  }
}

Siempre presentes:

  • limit — el límite efectivo (después de aplicar el cap de 100).
  • has_moretrue si hay al menos un registro más allá de esta página. Lo calculamos peekeando limit + 1 filas, así que es gratis.
  • next_cursor — id del último item; pásalo como ?starting_after= para la siguiente página. null en página vacía.
  • previous_cursor — id del primer item; pásalo como ?ending_before= para retroceder.

Condicionales:

  • page — solo presente si llamaste con ?page=.
  • total, total_pages — solo si llamaste con ?include_total=true (cuenta todo el catálogo, ignora filtros).
  • filtered_total, filtered_total_pages — solo si llamaste con ?include_filtered_total=true (cuenta el resultado ya filtrado por q/search, brand, category).

Patrón recomendado: cursor forward

Cubre el caso "sincronizar todo el catálogo" o "obtener todo lo nuevo desde X":

javascript
async function fetchAllProducts(secret) {
  const headers = { Authorization: `Bearer ${secret}` }
  const all = []
  let cursor = null

  while (true) {
    const url = new URL("https://developers.fi-nova.com/api/v1/products")
    url.searchParams.set("limit", "100")
    if (cursor) url.searchParams.set("starting_after", cursor)

    const res  = await fetch(url, { headers })
    const body = await res.json()
    all.push(...body.data)

    if (!body.meta.has_more) break
    cursor = body.meta.next_cursor
  }

  return all
}

Esto se mantiene en ~5 ms por request aunque la tabla tenga un millón de filas.

Cuándo pedir include_total

Pedir el total escanea toda la rebanada de la colección que pertenece a la company (~250 ms a 1M docs). No lo pidas en cada request — pídelo solo cuando lo necesites para una UI tipo "Mostrando 1–25 de 1 372" y, idealmente, una sola vez al cargar la pantalla. El servidor memoiza el total por tenant durante 60 s para absorber polling rápido, así que llamadas consecutivas dentro de ese minuto son baratas.

bash
curl 'https://developers.fi-nova.com/api/v1/products?limit=25&include_total=true' \
  -H 'Authorization: Bearer finova_sk_TU_SECRETO'
json
{
  "data": [ "..." ],
  "meta": {
    "limit": 25, "has_more": true,
    "next_cursor": "...", "previous_cursor": "...",
    "total": 1372,
    "total_pages": 55
  }
}

total vs filtered_total (cuando filtras)

include_total cuenta todo el catálogo de la company e ignora cualquier filtro (q/search, brand, category). Es el número correcto para "tu catálogo tiene 1 372 productos", pero no para "Mostrando 12 de N resultados" cuando estás filtrando.

Para ese caso usa include_filtered_total: cuenta el resultado después de aplicar los filtros activos y agrega filtered_total + filtered_total_pages a meta. No se cachea por tenant (depende de tus filtros), pero las páginas idénticas sí entran al cache de respuestas si lo activaste.

bash
curl 'https://developers.fi-nova.com/api/v1/products?brand=acme&include_filtered_total=true' \
  -H 'Authorization: Bearer finova_sk_TU_SECRETO'
json
{
  "data": [ "..." ],
  "meta": {
    "limit": 25, "has_more": false,
    "next_cursor": "...", "previous_cursor": "...",
    "filtered_total": 12,
    "filtered_total_pages": 1
  }
}

Puedes pedir ambos en la misma llamada (include_total=true&include_filtered_total=true) para mostrar "12 de 1 372".

Nota sobre búsqueda de texto: cuando hay q/search activo, filtered_total está acotado por el límite de candidatos de Atlas (100). Si la búsqueda matchea más de 100, el conteo se satura en 100. Los filtros estructurales (brand, category) sí cuentan exacto.

Filtrar el listado de productos

GET /products acepta filtros estructurales combinables con la búsqueda y la paginación:

QueryAceptaDescripción
brand_idObjectIdFiltra por marca (id).
brandObjectId o slugFiltra por marca; el slug matchea el snapshot denormalizado en el producto (sin lookup extra). Se ignora si mandas brand_id.
product_category_idObjectIdFiltra por categoría de catálogo (id).
categoryObjectId o slugFiltra por categoría de catálogo. Se ignora si mandas product_category_id.

Un id o slug desconocido devuelve lista vacía (el filtro nunca se ignora silenciosamente). Combínalos con q/search y con include_filtered_total=true para paginar resultados filtrados:

bash
curl 'https://developers.fi-nova.com/api/v1/products?category=lentes&brand=acme&q=premium&include_filtered_total=true' \
  -H 'Authorization: Bearer finova_sk_TU_SECRETO'

Paginación legacy con page

Sigue funcionando para mantener compatibilidad con clientes ya integrados; recomendado solo para tenants chicos (< 5 000 ítems) porque skip(N) en MongoDB cuesta O(N).

bash
curl 'https://developers.fi-nova.com/api/v1/products?page=2&limit=50' \
  -H 'Authorization: Bearer finova_sk_TU_SECRETO'

Si pides una página más profunda que 200 (?page=201), el server responde:

json
{
  "error": "offset_too_deep",
  "message": "Offset pagination is capped at page 200. Use cursor pagination (?starting_after=) for deeper traversal."
}

con status 400 Bad Request. Es una señal directa de migrar a cursores: una llamada ?page=201&limit=100 te costaría ~3 s y al server mucho más, así que la cortamos.

¿Por qué cambiaron las defaults?

En Fase 1 todos los listados devolvían total y total_pages por default. Eso obliga a countDocuments() en cada request y se vuelve el bottleneck del endpoint al pasar de ~50 k registros. La nueva forma — has_more siempre, total opt-in, cursores como camino recomendado — mantiene GET /products debajo de 50 ms p99 incluso con un millón de productos por company.

El campo total no desapareció: solo dejó de ser default. Si tu integración lo necesita, pasa ?include_total=true y todo sigue funcionando igual.

Cache de respuestas

Si activas el cache de respuestas en el dashboard, las páginas idénticas dentro del TTL se sirven desde Redis sin tocar Mongo (header X-Cache: HIT) — incluido el total cacheado. La cobertura del cache aplica a /products, /brands, /customers, /leads, /automations, y los del calendario (/calendar/events, /calendar/bookings, /calendar/booking-links).

Hecho con cuidado por Finova.