Sincronização incremental com updated_after
Você quer uma cópia dos clientes, das vendas ou das OS no seu sistema, e quer mantê-la em dia sem baixar tudo de novo a cada hora. É para isso que existe o filtro updated_after: ele traz só o que mudou desde um instante, na ordem em que mudou.
Os exemplos usam clientes, mas o mesmo vale para toda lista cujo objeto tem updated_at.
Como o modo sincronização funciona
Seção intitulada “Como o modo sincronização funciona”Toda lista aceita updated_after. Com ele, três coisas mudam:
- O recorte. Só vem o registro com
updated_atdepois do instante que você mandou. O limite é exclusivo: o registro comupdated_atexatamente igual ao valor mandado não vem. - A ordem. A lista passa a vir por
updated_atcrescente, do que mudou há mais tempo para o que mudou agora. Semupdated_after, a ordem écreated_atdecrescente. - O desempate. Dois registros com o mesmo
updated_atsaem sempre na mesma ordem, peloid. É o que faz a paginação não pular nem repetir registro entre uma página e outra.
created_after e created_before também são exclusivos, e podem ser combinados com updated_after.
O cursor guarda o modo de ordenação. Por isso, mande os mesmos filtros em todas as páginas da mesma rodada, só acrescentando cursor. Um cursor da ordem por updated_at usado numa chamada sem updated_after responde 422: trocar de modo no meio é começar de novo. Veja Paginação.
A receita
Seção intitulada “A receita”- Na primeira rodada, não há ponto de partida. Mande um instante bem antigo, como
1970-01-01T00:00:00Z: isso traz tudo, já na ordem de sincronização. - Leia página por página, seguindo o
next_cursor, atéhas_morevirfalse. - Grave cada registro no seu lado pelo
id, como atualização: se já existe, sobrescreve; se não existe, cria. - Depois de cada página, guarde o maior
updated_atque você viu. Esse é o ponto de partida da próxima rodada. - Na próxima rodada, comece um pouco antes desse ponto, com uma janela de sobreposição.
Por que uma janela de sobreposição
Seção intitulada “Por que uma janela de sobreposição”Mandar exatamente o maior updated_at visto funciona quase sempre. Os buracos estão nas bordas:
- o registro gravado no mesmo instante do último que você leu, mas que ainda não estava confirmado no banco quando a sua leitura passou, fica de fora, porque o limite é exclusivo;
- uma gravação longa pode receber um
updated_atum pouco anterior ao momento em que ela fica visível para leitura.
Voltar alguns minutos resolve os dois casos. Recomendamos 5 minutos: o custo é reler alguns registros a cada rodada, e a gravação pelo id do passo 3 já torna a releitura inofensiva. Se quiser pular o trabalho repetido, compare o updated_at que chegou com o que você tem e ignore quando for igual.
A janela também protege quando o seu processo cai no meio de uma rodada: você recomeça do último ponto guardado, menos a janela, e nada fica para trás.
O código
Seção intitulada “O código”# Uma página da rodada. O ponto de partida é o maior updated_at da rodada# anterior, menos a janela de sobreposição.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"
# A próxima página: os MESMOS filtros, mais o next_cursor que veio.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_DO_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; // janela de sobreposiçãoconst headers = { Authorization: `Bearer ${process.env.FH_API_KEY}` };
// Ponto de partida: o maior updated_at da rodada anterior, menos a janela.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) { // Grave no seu sistema pelo id: cria ou sobrescreve. console.log('gravar', client.id, client.updated_at);}
let cursor = null;while (true) { // Os mesmos filtros em todas as páginas; só o cursor muda. 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; } // Progresso guardado a cada página: se cair, recomeça daqui. 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; // janela de sobreposição
// Ponto de partida: o maior updated_at da rodada anterior, menos a janela.$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{ // Grave no seu sistema pelo id: cria ou sobrescreve. echo 'gravar ', $client['id'], ' ', $client['updated_at'], PHP_EOL;}
$cursor = null;while (true) { // Os mesmos filtros em todas as páginas; só o cursor muda. $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']; } } // Progresso guardado a cada página: se cair, recomeça daqui. 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) # janela de sobreposição
def parse(value): return datetime.fromisoformat(value.replace("Z", "+00:00"))
# Ponto de partida: o maior updated_at da rodada anterior, menos a janela.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): # Grave no seu sistema pelo id: cria ou sobrescreve. print("gravar", client["id"], client["updated_at"])
cursor = Nonewhile True: # Os mesmos filtros em todas as páginas; só o cursor muda. 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"] # Progresso guardado a cada página: se cair, recomeça daqui. with open(STATE_FILE, "w") as f: json.dump({"newest": newest}, f)
if not page["has_more"]: break cursor = page["next_cursor"]O laço respeita o 429: espera o Retry-After e repete a mesma página. Uma primeira rodada numa base grande gasta bastante do orçamento de chamadas da empresa, que é um só para todas as chaves dela; use limit=100 e rode fora do horário de pico. Veja Limites de uso.
O que a sincronização não vê
Seção intitulada “O que a sincronização não vê”Exclusão. Registro excluído some da lista, e por isso nunca aparece numa rodada. O mesmo vale para OS excluída, que é arquivada e passa a responder 404. Para saber o que saiu:
- inscreva-se nos eventos
.deletedda área (client.deleted,service_order.deletede os outros do Catálogo de eventos); - ou, de tempos em tempos, compare a lista inteira de ids com a sua cópia.
Exclusão também pode mexer em outros registros: quando um cliente é excluído, as tarefas e os compromissos dele perdem o vínculo. Ao receber um .deleted, releia o que no seu sistema apontava para o registro excluído.
Listas sem updated_at. Três listas não têm objeto que muda:
GET /public/v1/stock_movementseGET /public/v1/events: movimento de estoque e evento não mudam depois de gravados. Nelas,updated_afterrecorta pelo instante de criação, em ordem crescente, e a receita acima funciona igual usandocreated_atcomo ponto de partida.GET /public/v1/team: o objeto não traz data. A lista é pequena; leia inteira quando precisar.
Combine com webhooks
Seção intitulada “Combine com webhooks”Sincronização e webhook não competem. Cada um cobre o ponto fraco do outro:
| Webhook | Sincronização | |
|---|---|---|
| Quando chega | Na hora | Na próxima rodada |
| Se o seu servidor cai | Novas tentativas por cerca de 3 dias, depois perde | Recomeça do último ponto guardado |
| Endpoint desligado | Os eventos do período não chegam | Não depende de endpoint |
| Mudanças sem evento | Não avisa | Traz, se o updated_at mudou |
O desenho que funciona: o webhook avisa, a sincronização garante. Use o evento para agir na hora (ler o objeto e atualizar o seu lado) e rode a sincronização de tempos em tempos, uma vez por hora ou por dia, como rede de segurança. As duas gravam pelo id, então receber a mesma mudança pelos dois caminhos não faz mal.
Veja Boas práticas de webhooks e Entregas e novas tentativas.
Próximo passo
Seção intitulada “Próximo passo”- Paginação: o cursor e o formato da lista.
- Integrar com n8n, Make e Zapier: a mesma ideia numa ferramenta de automação.