Paginación y sincronización
Cómo recorrer un listado entero y cómo traer sólo lo que cambió, sin perder ni duplicar nada.
Cómo es un listado
Todo listado devuelve el mismo sobre:
{
"object": "list",
"data": [
"…"
],
"has_more": true,
"next_cursor": "eyJrIjpb…"
}limit: cuántos objetos por página, de 1 a 500. Sinlimit, 200.next_cursor: pasalo tal cual encursorpara la página siguiente, con los mismos filtros. Si cambiás un filtro, el cursor ya no vale (400 invalid_cursor): empezá sin cursor. Es opaco: no lo armes ni lo leas.has_more: falseynext_cursor: nullquieren decir que no hay más. Una página puede venir con menos delimit, hasta vacía, y seguir conhas_more: true(por ejemplo, cuando un filtro descarta mucho o la página llegó a su tamaño máximo): seguí el cursor igual.- El orden es siempre
(updated_at, id)ascendente, en todos los listados. Un objeto que cambia mientras paginás vuelve a aparecer más adelante, con suupdated_atnuevo: no se pierde. En/paymentses el de su venta:(updated_at de la venta, venta, lugar del pago). branch_id(una o varias sucursales separadas por coma) acota el listado; sin él, todas las de la cuenta.GET /branchesyGET /payment-methodsno se paginan: vienen enteros.
Sincronización incremental: updated_since
updated_since trae lo modificado desde un instante (inclusivo: updated_at ≥ updated_since), con su huso: 2026-10-27T09:55:00-03:00 o …Z. Una fecha sin huso es un 400. Incluye:
- lo nuevo;
- lo que se editó, también después de cerrado (una venta corregida, un medio de pago cambiado, una devolución parcial);
- lo que se anuló (la venta sale con status CANCELED o REFUNDED);
- lo que se borró, como lápida (ver abajo).
La ventana de asentamiento (60 segundos)
Los listados no sirven lo modificado en los últimos 60 segundos. Una escritura se estampa con una hora y se ve un instante después; si el listado la sirviera enseguida, una que se confirma tarde podría quedar detrás de tu marca y perderse. Por eso lo recién cambiado aparece en la corrida siguiente. Un GET /sales/{id} o GET /products/{id}, en cambio, muestra el objeto como está ahora.
Solapá 5 minutos y deduplicá
En cada corrida pedí updated_since = la marca de la corrida anterior menos 5 minutos. Vas a recibir de nuevo algunos objetos que ya tenías: deduplicá por id + updated_at (si el que tenés es igual o más nuevo, ignorá el que llega). El solapamiento cubre relojes corridos y escrituras lentas; la deduplicación hace que no cueste nada.
updated_at como instantes, no como texto: cada sucursal escribe la fecha con su propio huso (-03:00, -05:00…), y como texto 10:00-03:00 parece anterior a 09:00-05:00 aunque sea después.Lápidas: lo que se borró
Con updated_since, los listados de ventas, productos y categorías mezclan, en el mismo orden, los objetos borrados (sin updated_since no salen). Una lápida tiene deleted: true y su updated_at es el momento del borrado. Al verla, borrá ese id de tu base (si no lo tenías, ignorala). Un combo borrado sale con object: "product", igual que en el listado.
{
"id": "6720c7d8c9e2b4d7a1f3eb21",
"object": "sale",
"deleted": true,
"branch_id": "65f1c0a2b7e4d91a3c5e7f10",
"deleted_at": "2026-10-27T11:40:05.208-03:00",
"updated_at": "2026-10-27T11:40:05.208-03:00"
}Los pagos no tienen lápida: cuando se borra una venta, sale su lápida en /sales y con ella se van sus pagos. Los clientes y las cajas no se borran.
La receta completa
- Guardá en tu base una marca por recurso: el mayor
updated_atque viste. La primera vez, una fecha vieja (por ejemplo, el día que empezaste a usar Kicket): la misma función sirve para la carga inicial y para las siguientes. - Pedí el listado con
updated_since= marca − 5 minutos ylimit=500, y seguínext_cursorhasta que seanull. - Por cada objeto: si es una lápida, borralo; si no, guardalo sólo si su
updated_ates más nuevo que el que tenés. - Al terminar, guardá la marca nueva. Si la corrida se corta, no pasa nada: la próxima arranca desde la marca anterior.
- Si a mitad de la corrida te llega un 400
invalid_cursorsin que hayas cambiado nada, reiniciá la corrida desde la marca (sin cursor). - En cada corrida compará
GET /branchescon las sucursales que ya conocés: una sucursal que entra a la cadena trae su historia con suupdated_atviejo, así que para una nueva hacé una carga inicial conbranch_id=<nueva>y una fecha vieja antes de seguir con la marca común. - Repetí cada pocos minutos. Si usás webhooks, actuá con el
datadel evento (o con unGETpor id): la sincronización es la red de seguridad. Lo recién cambiado entra en la corrida siguiente, por la ventana de asentamiento.
import { llamar } from './kicket.mjs'
const SOLAPAMIENTO_MS = 5 * 60 * 1000
/**
* Trae lo que cambió en un recurso (sales, products, categories, customers, cash-sessions, refunds) y lo aplica.
* `marca`: el mayor updated_at que viste la vez anterior (la primera vez, una fecha vieja).
* Devuelve la marca nueva: guardala en tu base para la próxima corrida.
*/
export async function sincronizar(recurso, marca, aplicar) {
const desde = new Date(Date.parse(marca) - SOLAPAMIENTO_MS).toISOString()
let cursor = null
let nueva = marca
do {
const params = new URLSearchParams({ updated_since: desde, limit: '500' })
if (cursor) params.set('cursor', cursor)
const pagina = await llamar(`/${recurso}?${params}`)
for (const obj of pagina.data) {
await aplicar(obj)
// Se comparan INSTANTES, no textos: cada sucursal escribe la fecha con su propio huso.
if (Date.parse(obj.updated_at) > Date.parse(nueva)) nueva = obj.updated_at
}
cursor = pagina.next_cursor // una página puede venir corta (hasta vacía) y seguir: mandá el cursor igual
} while (cursor)
return nueva
}
// Cómo se aplica una venta en TU base (db es tu capa de datos).
async function aplicarVenta(obj) {
if (obj.deleted) return db.ventas.borrar(obj.id) // lápida: se borró en Kicket
const actual = await db.ventas.buscar(obj.id)
// Deduplicar por id + updated_at: el solapamiento trae de nuevo lo que ya tenías.
if (actual && Date.parse(actual.updated_at) >= Date.parse(obj.updated_at)) return
// La venta entera: sus renglones y sus pagos se REEMPLAZAN (un pago puede cambiar de id al corregirse).
await db.ventas.guardar(obj)
}
const marca = (await db.marcas.leer('sales')) ?? '2020-01-01T00:00:00Z'
await db.marcas.guardar('sales', await sincronizar('sales', marca, aplicarVenta))Qué recurso sincronizar
| Recurso | Con lápidas | Notas |
|---|---|---|
/sales | Sí | Trae los renglones y los pagos adentro: con esto alcanza para ventas y cobros. |
/products | Sí | Productos y combos. El stock no mueve updated_at. |
/categories | Sí | |
/payments | No | Para sincronizar cobros, mejor /sales. Por venta: reemplazá el conjunto entero de esa venta. Una página no parte los pagos de una venta (salvo una con más pagos que limit). Una ronda abierta o una venta que se quedó sin pagos no sale: de eso te enterás por /sales. |
/refunds | No | La nota de crédito emitida después no mueve updated_at: para verla, volvé a pedir /refunds?sale_id=. |
/customers | No | Datos personales (customers:read). last_purchase_at cambia sin mover updated_at: sacala de /sales. |
/cash-sessions | No | Sólo cajas cerradas. Una que se reabre deja de salir hasta que se vuelve a cerrar. |