# Paginación y filtros

Cursores, filtros por fecha y cómo hacer una sincronización incremental confiable.

## Listas

Todos los endpoints que listan (`GET /products`, `GET /sales`, `GET /events`…) responden igual:

```json
{
  "object": "list",
  "data": [ { "object": "sale", "id": "…" } ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wOS0yOFQxNDo1…"
}
```

- El orden es siempre **`created_at` descendente** (lo más reciente primero), con el `id` para desempatar.
- `limit` va de **1 a 100**; por defecto, **25**.
- Si `has_more` es `true`, pide la página siguiente pasando `next_cursor` **tal cual** en `starting_after`. Cuando `has_more` es `false`, `next_cursor` es `null` y terminaste.
- El cursor es opaco: no lo construyas ni lo modifiques. Uno alterado responde `400 INVALID_CURSOR`.

```sh
curl "https://api.posdata.so/public/v1/sales?limit=100&starting_after=eyJjIjoiMjAyNi0wOS0yOFQxNDo1…" \
  -H "Authorization: Bearer $POSDATA_API_KEY"
```

Como el orden es por fecha de creación, un registro que se edita **no cambia de posición**: puedes recorrer todas las páginas mientras el negocio sigue vendiendo sin saltarte ni repetir registros.

## Filtros por fecha

| Parámetro       | Qué hace                                                                                       |
| --------------- | ---------------------------------------------------------------------------------------------- |
| `created_since` | Registros creados en esa fecha o después (incluye).                                            |
| `created_until` | Registros creados antes de esa fecha (excluye).                                                |
| `updated_since` | Registros modificados en esa fecha o después. Es el filtro para la sincronización incremental. |

Las fechas van en **ISO 8601 con zona horaria**: `2026-09-28T00:00:00Z` o `2026-09-28T00:00:00-05:00`. Una fecha sin zona horaria se rechaza con `400 VALIDATION_ERROR`, porque no hay forma de saber a qué hora te refieres. En la URL, codifica el `+` de una zona positiva como `%2B`.

`GET /events` no tiene `updated_since`: los eventos no se modifican.

Además, cada lista tiene sus propios filtros (`status`, `customer_id`, `sku`, `q`, `occurred_since`…): los encuentras en la [referencia](https://developers.posdata.so/referencia/). La búsqueda `q` es literal: `%` y `_` se buscan como esos caracteres, no como comodines. Un filtro que no existe (o mal escrito) se **rechaza** con `400 VALIDATION_ERROR`; nunca se ignora en silencio, porque ignorarlo te devolvería más datos de los que pediste.

## Registros eliminados

Productos, categorías y clientes aceptan `include_deleted=true` en la lista y en la consulta por id. Sin él, los eliminados no aparecen; con él, aparecen con `deleted_at` distinto de `null`. Úsalo en tu sincronización incremental para enterarte de lo que se eliminó en las cajas.

## Receta: sincronización incremental

Para mantener una copia de, por ejemplo, los productos en tu sistema:

1. **Carga inicial**: recorre `GET /products?limit=100&include_deleted=true` página por página hasta que `has_more` sea `false`. Antes de empezar, anota la hora de inicio (`T0`).
2. **Cada corrida siguiente**: pide `GET /products?updated_since=<T_anterior>&limit=100&include_deleted=true`, recorre todas las páginas y haz _upsert_ por `id`. Si `deleted_at` no es `null`, márcalo como eliminado en tu sistema.
3. Guarda como nuevo punto de partida la hora en que **empezó** la corrida, restándole un margen (por ejemplo, 5 minutos). Procesar dos veces un registro es inofensivo si haces _upsert_; saltarte uno no.

```js
// Node.js 20+ — incremental product sync (sketch)
const BASE = 'https://api.posdata.so/public/v1';
const headers = { Authorization: `Bearer ${process.env.POSDATA_API_KEY}` };

async function syncProducts(since) {
  const startedAt = new Date();
  let cursor = null;
  do {
    const url = new URL(`${BASE}/products`);
    url.searchParams.set('limit', '100');
    url.searchParams.set('include_deleted', 'true');
    if (since) url.searchParams.set('updated_since', since.toISOString());
    if (cursor) url.searchParams.set('starting_after', cursor);

    const res = await fetch(url, { headers });
    if (!res.ok) throw new Error(`Posdata ${res.status} ${res.headers.get('Request-Id')}`);
    const page = await res.json();
    for (const product of page.data) await upsertProduct(product);
    cursor = page.has_more ? page.next_cursor : null;
  } while (cursor);
  // Next run starts 5 minutes before this one began.
  return new Date(startedAt.getTime() - 5 * 60 * 1000);
}

async function upsertProduct(product) {
  // Save it in your system, keyed by product.id.
}
```

> **Las ventas llegan cuando la caja sincroniza**
>
> En las ventas, `created_at` es cuando la venta **llegó al servidor**; cuándo se registró en la caja lo dice `occurred_at`. Una caja que trabajó sin internet sube sus ventas al reconectarse, con un `created_at` de ese momento. Por eso, para la sincronización incremental, filtra por `created_since`/`updated_since` (sí te traen esas ventas en la siguiente corrida), no por `occurred_since`. Usa `occurred_at` para tus reportes. Lee [Cómo se sincroniza con las cajas](https://developers.posdata.so/sincronizacion/#las-ventas-traen-dos-fechas).

---

Fuente: https://developers.posdata.so/paginacion/ · Índice para agentes: https://developers.posdata.so/llms.txt
