Sincronización incremental con updated_after
Usted quiere una copia de los clientes, las ventas o las OS en su sistema, y quiere mantenerla al día sin descargar todo de nuevo cada hora. Para eso existe el filtro updated_after: trae solo lo que cambió desde un instante, en el orden en que cambió.
Los ejemplos usan clientes, pero lo mismo vale para toda lista cuyo objeto tenga updated_at.
Cómo funciona el modo sincronización
Sección titulada «Cómo funciona el modo sincronización»Toda lista acepta updated_after. Con él, cambian tres cosas:
- El recorte. Solo viene el registro con
updated_atposterior al instante que usted envió. El límite es exclusivo: el registro conupdated_atexactamente igual al valor enviado no viene. - El orden. La lista pasa a venir por
updated_atascendente, de lo que cambió hace más tiempo a lo que cambió ahora. Sinupdated_after, el orden escreated_atdescendente. - El desempate. Dos registros con el mismo
updated_atsalen siempre en el mismo orden, por elid. Es lo que evita que la paginación salte o repita registros entre una página y otra.
created_after y created_before también son exclusivos, y se pueden combinar con updated_after.
El cursor guarda el modo de ordenación. Por eso, envíe los mismos filtros en todas las páginas de la misma ronda, agregando solo cursor. Un cursor del orden por updated_at usado en una llamada sin updated_after responde 422: cambiar de modo a mitad de camino es empezar de nuevo. Vea Paginación.
La receta
Sección titulada «La receta»- En la primera ronda no hay punto de partida. Envíe un instante muy antiguo, como
1970-01-01T00:00:00Z: eso trae todo, ya en el orden de sincronización. - Lea página por página, siguiendo el
next_cursor, hasta quehas_morevengafalse. - Grabe cada registro de su lado por el
id, como actualización: si existe, lo sobrescribe; si no existe, lo crea. - Después de cada página, guarde el mayor
updated_atque vio. Ese es el punto de partida de la próxima ronda. - En la próxima ronda, empiece un poco antes de ese punto, con una ventana de superposición.
Por qué una ventana de superposición
Sección titulada «Por qué una ventana de superposición»Enviar exactamente el mayor updated_at visto funciona casi siempre. Los huecos están en los bordes:
- el registro grabado en el mismo instante que el último que usted leyó, pero que todavía no estaba confirmado en la base cuando pasó su lectura, queda afuera, porque el límite es exclusivo;
- una escritura larga puede recibir un
updated_atun poco anterior al momento en que queda visible para la lectura.
Volver unos minutos resuelve los dos casos. Recomendamos 5 minutos: el costo es releer algunos registros en cada ronda, y la escritura por id del paso 3 ya vuelve inofensiva la relectura. Si quiere evitar el trabajo repetido, compare el updated_at que llegó con el que tiene e ignórelo cuando sea igual.
La ventana también lo protege cuando su proceso se cae a mitad de una ronda: usted reanuda desde el último punto guardado, menos la ventana, y nada queda atrás.
El código
Sección titulada «El código»# Una página de la ronda. El punto de partida es el mayor updated_at de la# ronda anterior, menos la ventana de superposición.curl -G "https://api.fatureihoje.com/public/v1/clients" \ -H "Authorization: Bearer $FH_API_KEY" \ --data-urlencode "updated_after=2026-09-24T12:55:00Z" \ --data-urlencode "limit=100"
# La página siguiente: los MISMOS filtros, más el next_cursor que vino.curl -G "https://api.fatureihoje.com/public/v1/clients" \ -H "Authorization: Bearer $FH_API_KEY" \ --data-urlencode "updated_after=2026-09-24T12:55:00Z" \ --data-urlencode "limit=100" \ --data-urlencode "cursor=VALOR_DEL_NEXT_CURSOR"import { existsSync, readFileSync, writeFileSync } from 'node:fs';
const API = 'https://api.fatureihoje.com/public/v1';const STATE_FILE = 'sync-clients.json';const OVERLAP_MS = 5 * 60 * 1000; // ventana de superposiciónconst headers = { Authorization: `Bearer ${process.env.FH_API_KEY}` };
// Punto de partida: el mayor updated_at de la ronda anterior, menos la ventana.const state = existsSync(STATE_FILE) ? JSON.parse(readFileSync(STATE_FILE, 'utf8')) : null;const since = state ? new Date(Date.parse(state.newest) - OVERLAP_MS).toISOString() : '1970-01-01T00:00:00Z';let newest = state ? state.newest : since;
function save(client) { // Grábelo en su sistema por el id: crea o sobrescribe. console.log('grabar', client.id, client.updated_at);}
let cursor = null;while (true) { // Los mismos filtros en todas las páginas; solo cambia el cursor. const params = new URLSearchParams({ updated_after: since, limit: '100' }); if (cursor) params.set('cursor', cursor);
const res = await fetch(`${API}/clients?${params}`, { headers }); if (res.status === 429) { const wait = Number(res.headers.get('retry-after') ?? '1'); await new Promise((resolve) => setTimeout(resolve, wait * 1000)); continue; } const page = await res.json(); if (!res.ok) throw new Error(`${res.status} ${page.error.code}: ${page.error.message}`);
for (const client of page.data) { save(client); if (Date.parse(client.updated_at) > Date.parse(newest)) newest = client.updated_at; } // Progreso guardado en cada página: si se cae, reanuda desde aquí. writeFileSync(STATE_FILE, JSON.stringify({ newest }));
if (!page.has_more) break; cursor = page.next_cursor;}<?php
const API = 'https://api.fatureihoje.com/public/v1';const STATE_FILE = 'sync-clients.json';const OVERLAP_SECONDS = 5 * 60; // ventana de superposición
// Punto de partida: el mayor updated_at de la ronda anterior, menos la ventana.$state = file_exists(STATE_FILE) ? json_decode(file_get_contents(STATE_FILE), true) : null;$since = $state ? gmdate('Y-m-d\TH:i:s\Z', strtotime($state['newest']) - OVERLAP_SECONDS) : '1970-01-01T00:00:00Z';$newest = $state ? $state['newest'] : $since;
function save(array $client): void{ // Grábelo en su sistema por el id: crea o sobrescribe. echo 'grabar ', $client['id'], ' ', $client['updated_at'], PHP_EOL;}
$cursor = null;while (true) { // Los mismos filtros en todas las páginas; solo cambia el cursor. $params = ['updated_after' => $since, 'limit' => 100]; if ($cursor !== null) { $params['cursor'] = $cursor; }
$retryAfter = 1; $ch = curl_init(API . '/clients?' . http_build_query($params)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('FH_API_KEY')], CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) { if (stripos($line, 'retry-after:') === 0) { $retryAfter = (int) trim(substr($line, 12)); } return strlen($line); }, ]); $page = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status === 429) { sleep(max(1, $retryAfter)); continue; } if ($status !== 200) { throw new RuntimeException($status . ' ' . $page['error']['code'] . ': ' . $page['error']['message']); }
foreach ($page['data'] as $client) { save($client); if (strtotime($client['updated_at']) > strtotime($newest)) { $newest = $client['updated_at']; } } // Progreso guardado en cada página: si se cae, reanuda desde aquí. file_put_contents(STATE_FILE, json_encode(['newest' => $newest]));
if (!$page['has_more']) { break; } $cursor = $page['next_cursor'];}import jsonimport osimport timeimport urllib.errorimport urllib.parseimport urllib.requestfrom datetime import datetime, timedelta
API = "https://api.fatureihoje.com/public/v1"STATE_FILE = "sync-clients.json"OVERLAP = timedelta(minutes=5) # ventana de superposición
def parse(value): return datetime.fromisoformat(value.replace("Z", "+00:00"))
# Punto de partida: el mayor updated_at de la ronda anterior, menos la ventana.state = Noneif os.path.exists(STATE_FILE): with open(STATE_FILE) as f: state = json.load(f)if state: since = (parse(state["newest"]) - OVERLAP).strftime("%Y-%m-%dT%H:%M:%SZ") newest = state["newest"]else: since = "1970-01-01T00:00:00Z" newest = since
def save(client): # Grábelo en su sistema por el id: crea o sobrescribe. print("grabar", client["id"], client["updated_at"])
cursor = Nonewhile True: # Los mismos filtros en todas las páginas; solo cambia el cursor. params = {"updated_after": since, "limit": 100} if cursor: params["cursor"] = cursor request = urllib.request.Request( f"{API}/clients?{urllib.parse.urlencode(params)}", headers={"Authorization": f"Bearer {os.environ['FH_API_KEY']}"}, ) try: with urllib.request.urlopen(request) as response: page = json.load(response) except urllib.error.HTTPError as failure: if failure.code == 429: time.sleep(int(failure.headers.get("Retry-After", "1"))) continue error = json.load(failure)["error"] raise SystemExit(f"{failure.code} {error['code']}: {error['message']}")
for client in page["data"]: save(client) if parse(client["updated_at"]) > parse(newest): newest = client["updated_at"] # Progreso guardado en cada página: si se cae, reanuda desde aquí. with open(STATE_FILE, "w") as f: json.dump({"newest": newest}, f)
if not page["has_more"]: break cursor = page["next_cursor"]El bucle respeta el 429: espera el Retry-After y repite la misma página. Una primera ronda sobre una base grande consume buena parte del presupuesto de llamadas de la empresa, que es uno solo para todas sus claves; use limit=100 y ejecútela fuera del horario pico. Vea Límites de uso.
Lo que la sincronización no ve
Sección titulada «Lo que la sincronización no ve»Eliminaciones. Un registro eliminado desaparece de la lista, y por eso nunca aparece en una ronda. Lo mismo vale para una OS eliminada, que se archiva y pasa a responder 404. Para saber qué salió:
- suscríbase a los eventos
.deleteddel área (client.deleted,service_order.deletedy los demás del Catálogo de eventos); - o, de vez en cuando, compare la lista completa de ids con su copia.
Una eliminación también puede tocar otros registros: cuando se elimina un cliente, sus tareas y citas pierden el vínculo. Al recibir un .deleted, vuelva a leer lo que en su sistema apuntaba al registro eliminado.
Listas sin updated_at. Tres listas tienen objetos que no cambian:
GET /public/v1/stock_movementsyGET /public/v1/events: el movimiento de stock y el evento no cambian después de grabados. En ellas,updated_afterrecorta por el instante de creación, en orden ascendente, y la receta de arriba funciona igual usandocreated_atcomo punto de partida.GET /public/v1/team: el objeto no trae fecha. La lista es chica; léala completa cuando la necesite.
Combínela con webhooks
Sección titulada «Combínela con webhooks»La sincronización y el webhook no compiten. Cada uno cubre el punto débil del otro:
| Webhook | Sincronización | |
|---|---|---|
| Cuándo llega | En el momento | En la próxima ronda |
| Si su servidor se cae | Reintentos durante unos 3 días, después se pierde | Reanuda desde el último punto guardado |
| Endpoint desactivado | Los eventos del período no llegan | No depende de un endpoint |
| Cambios sin evento | No avisa | Los trae, si el updated_at cambió |
El diseño que funciona: el webhook avisa, la sincronización garantiza. Use el evento para actuar en el momento (leer el objeto y actualizar su lado) y ejecute la sincronización de vez en cuando, una vez por hora o por día, como red de seguridad. Las dos graban por id, así que recibir el mismo cambio por los dos caminos no hace daño.
Vea Buenas prácticas de webhooks y Entregas y reintentos.
Próximo paso
Sección titulada «Próximo paso»- Paginación: el cursor y el formato de la lista.
- Integrar con n8n, Make y Zapier: la misma idea en una herramienta de automatización.