Incremental sync with updated_after
You want a copy of the clients, the sales or the service orders in your system, and you want to keep it current without downloading everything again every hour. That is what the updated_after filter is for: it returns only what changed since a given instant, in the order it changed.
The examples use clients, but the same applies to every list whose object has updated_at.
How sync mode works
Section titled “How sync mode works”Every list accepts updated_after. With it, three things change:
- The cut. Only records with
updated_atafter the instant you sent come back. The bound is exclusive: a record whoseupdated_atequals the value you sent does not come. - The order. The list is sorted by
updated_atascending, from what changed longest ago to what changed just now. Withoutupdated_after, the order iscreated_atdescending. - The tie-break. Two records with the same
updated_atalways come in the same order, byid. That is what keeps pagination from skipping or repeating records between pages.
created_after and created_before are exclusive too, and can be combined with updated_after.
The cursor remembers the sort mode. So send the same filters on every page of the same run, only adding cursor. A cursor from the updated_at order used in a call without updated_after returns 422: switching modes midway means starting over. See Pagination.
The recipe
Section titled “The recipe”- On the first run there is no starting point. Send a very old instant, such as
1970-01-01T00:00:00Z: that returns everything, already in sync order. - Read page by page, following
next_cursor, untilhas_moreisfalse. - Write each record on your side by
id, as an upsert: if it exists, overwrite; if not, create. - After each page, store the largest
updated_atyou saw. That is the starting point of the next run. - On the next run, start a little before that point, with an overlap window.
Why an overlap window
Section titled “Why an overlap window”Sending exactly the largest updated_at you saw works almost always. The gaps are at the edges:
- a record written at the same instant as the last one you read, but not yet committed when your read went by, is left out, because the bound is exclusive;
- a long write can get an
updated_ata little earlier than the moment it becomes visible to reads.
Going back a few minutes covers both cases. We recommend 5 minutes: the cost is rereading a few records each run, and the write by id in step 3 already makes rereading harmless. If you want to skip the repeated work, compare the incoming updated_at with the one you have and ignore it when they are equal.
The window also protects you when your process dies midway through a run: you restart from the last stored point, minus the window, and nothing is left behind.
The code
Section titled “The code”# One page of the run. The starting point is the largest updated_at of the# previous run, minus the overlap window.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"
# The next page: the SAME filters, plus the next_cursor that came back.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=NEXT_CURSOR_VALUE"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; // overlap windowconst headers = { Authorization: `Bearer ${process.env.FH_API_KEY}` };
// Starting point: the largest updated_at of the previous run, minus the window.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) { // Write it to your system by id: create or overwrite. console.log('save', client.id, client.updated_at);}
let cursor = null;while (true) { // The same filters on every page; only the cursor changes. 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; } // Progress stored after every page: if it dies, it restarts from here. 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; // overlap window
// Starting point: the largest updated_at of the previous run, minus the window.$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{ // Write it to your system by id: create or overwrite. echo 'save ', $client['id'], ' ', $client['updated_at'], PHP_EOL;}
$cursor = null;while (true) { // The same filters on every page; only the cursor changes. $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']; } } // Progress stored after every page: if it dies, it restarts from here. 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) # overlap window
def parse(value): return datetime.fromisoformat(value.replace("Z", "+00:00"))
# Starting point: the largest updated_at of the previous run, minus the window.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): # Write it to your system by id: create or overwrite. print("save", client["id"], client["updated_at"])
cursor = Nonewhile True: # The same filters on every page; only the cursor changes. 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"] # Progress stored after every page: if it dies, it restarts from here. with open(STATE_FILE, "w") as f: json.dump({"newest": newest}, f)
if not page["has_more"]: break cursor = page["next_cursor"]The loop respects 429: it waits for Retry-After and retries the same page. A first run over a large base uses a good part of the company’s request budget, which is a single one for all of its keys; use limit=100 and run it outside peak hours. See Rate limits.
What sync does not see
Section titled “What sync does not see”Deletions. A deleted record disappears from the list, so it never shows up in a run. The same goes for a deleted service order, which is archived and starts returning 404. To learn what went away:
- subscribe to the area’s
.deletedevents (client.deleted,service_order.deletedand the others in the Event catalog); - or, from time to time, compare the full list of ids with your copy.
A deletion can also touch other records: when a client is deleted, their tasks and appointments lose the link. When you get a .deleted, reread whatever in your system pointed to the deleted record.
Lists without updated_at. Three lists have objects that do not change:
GET /public/v1/stock_movementsandGET /public/v1/events: stock movements and events do not change once written. There,updated_aftercuts by the creation instant, in ascending order, and the recipe above works the same usingcreated_atas the starting point.GET /public/v1/team: the object carries no date. The list is small; read all of it when you need it.
Combine with webhooks
Section titled “Combine with webhooks”Sync and webhooks do not compete. Each one covers the other’s weak spot:
| Webhook | Sync | |
|---|---|---|
| When it arrives | Right away | On the next run |
| If your server is down | Retries for about 3 days, then it is lost | Restarts from the last stored point |
| Endpoint turned off | Events from that period do not arrive | Does not depend on an endpoint |
| Changes without an event | No notice | Brings them, if updated_at changed |
The design that works: the webhook notifies, the sync guarantees. Use the event to act right away (read the object and update your side) and run the sync from time to time, once an hour or once a day, as a safety net. Both write by id, so getting the same change through both paths does no harm.
See Webhook best practices and Deliveries and retries.
Next step
Section titled “Next step”- Pagination: the cursor and the list format.
- Integrate with n8n, Make and Zapier: the same idea in an automation tool.