Delta sync

Most partner integrations keep a local mirror of locco resources (employees, vehicles, cost centers, departments, travel entries, payouts, webhook subscriptions). After the initial backfill, the question is the same on every endpoint: how do I poll for what changed since my last sync without re-reading every row?

The answer is the modifiedSince query parameter. It is supported on every paginated list endpoint and is the primary supported way to do incremental sync against the partner API in v1.

At a glance

  • Every paginated list endpoint accepts ?modifiedSince=<ISO 8601 UTC timestamp>.
  • Pages are returned ordered (updatedAt, id) ASC when modifiedSince is set without an explicit sort parameter. This is the order partners need to walk the change timeline forward without re-reading already-processed rows.
  • Page size caps at 100 server-side. Values above are silently clamped, and the response’s pageSize reflects the clamped value (not the raw request) so Math.ceil(total / pageSize) arithmetic stays correct.
  • The cursor is the updatedAt of the most recent row returned by the previous successful poll. Persist it. Pass it on the next poll. Repeat.

The endpoints

The same modifiedSince contract is supported on:

ResourceEndpoint
EmployeesGET /api/v1/employees
VehiclesGET /api/v1/vehicles
Cost centersGET /api/v1/cost-centers
DepartmentsGET /api/v1/departments
Travel entriesGET /api/v1/travel-entries
PayoutsGET /api/v1/payouts
Webhook subscriptionsGET /api/v1/webhooks

Webhook deliveries (GET /api/v1/webhooks/{id}/deliveries) intentionally does NOT support modifiedSince. Deliveries are append-only and effectively immutable once persisted, so partners that want to track delivery state poll by id or by status filter instead.

What modifiedSince returns

Given a poll like:

http
GET /api/v1/employees?modifiedSince=2026-04-30T12:00:00Z
Authorization: Bearer locco_live_...
X-Company-Id: 9c4e0d70-9d59-4a4a-a7d9-1e5ff6f1e3ec

The server returns every row whose updatedAt is at or after the supplied timestamp. updatedAt is computed server-side as the row’s editedAt value, falling back to createdAt for rows that have never been edited, so the timestamp is always populated.

A row that was created at 2026-04-30T11:55:00Z and never edited is excluded (its updatedAt of 11:55:00Z is before the cursor of 12:00:00Z). A row that was created last year but edited today is included.

Sort guarantee

When modifiedSince is set without an explicit sort parameter, the server orders the page by (updatedAt ASC, id ASC). This is load-bearing for delta-sync correctness:

  • Without an id tiebreaker, two rows with identical updatedAt would appear in non-deterministic order across requests, and partners walking pagination would silently skip or duplicate rows.
  • ASC (oldest change first) means the partner’s cursor at the end of a page is the highest updatedAt they’ve seen, which is the natural starting point for the next poll.

If you pass an explicit sort=...&sortDir=desc, the server still appends an id tiebreaker so pagination remains stable, but the page order will not be the order you want for delta-sync. For sync use cases, leave sort unset.

Pagination clamp

Every list endpoint clamps pageSize to 100 server-side. The response’s pageSize field reflects the EFFECTIVE clamped value, not whatever you asked for, so:

http
GET /api/v1/employees?pageSize=10000

returns up to 100 items AND a response body that says "pageSize": 100. The response also carries a totalPages field computed at the EFFECTIVE clamped page size, so the simplest loop terminator is page >= totalPages. Avoid recomputing pages from total / pageSize against your raw request value: you will compute too few pages and silently miss data.

page is similarly clamped to a minimum of 1; non-positive page values are normalised. The response’s page field reflects the actual page returned.

The polling loop

The contract is straightforward: read your stored cursor, poll with it, walk every page, save the highest updatedAt from the run as the next cursor.

python
import time
import requests
API = "https://api.locco.hr"
HEADERS = {
"Authorization": "Bearer locco_live_<your-key>",
"X-Company-Id": "<your-company-id>",
}
def poll_employees(cursor: str | None) -> tuple[list[dict], str]:
"""
Walk every page of employees changed since `cursor`. Returns the rows
plus the new cursor (the highest updatedAt seen during this run).
"""
rows = []
page = 1
new_cursor = cursor
while True:
params = {"page": page, "pageSize": 100}
if cursor is not None:
params["modifiedSince"] = cursor
r = requests.get(f"{API}/api/v1/employees", headers=HEADERS, params=params)
r.raise_for_status()
body = r.json()
rows.extend(body["items"])
# Advance the cursor to the highest updatedAt on this page.
for row in body["items"]:
updated_at = row.get("updatedAt") or row.get("createdAt")
if updated_at and (new_cursor is None or updated_at > new_cursor):
new_cursor = updated_at
# Use the response's totalPages, NOT a value computed from the request,
# so the loop terminates correctly even if the server clamped pageSize.
if page >= body["totalPages"]:
break
page += 1
return rows, new_cursor
# First run: cursor=None pulls everything (full backfill).
rows, cursor = poll_employees(cursor=None)
save_to_local_db(rows)
save_cursor(cursor)
# Steady state: poll every N minutes with the saved cursor.
while True:
time.sleep(300) # 5 minutes
cursor = load_cursor()
rows, new_cursor = poll_employees(cursor=cursor)
save_to_local_db(rows)
save_cursor(new_cursor)

A few subtleties worth pointing out:

  • Persist the cursor only after the entire poll succeeds, including writing the rows to your local store. Otherwise a crash mid-run loses the rows you fetched but didn’t persist, and the next poll won’t re-fetch them.
  • The cursor is exclusive on the server side, inclusive at your boundary. A row with updatedAt == cursor will appear in the next poll. Idempotent upserts on your side absorb the re-read. If you want strict exclusivity, advance your stored cursor to cursor + 1ms after a successful run.
  • Concurrent edits are eventually consistent. A row edited mid-poll might appear on a later page (because its updatedAt advanced past the page boundary). Your upsert logic will overwrite the earlier read with the newer one. No manual conflict resolution needed.

Initial backfill

On the very first run, pass modifiedSince=null (or omit it). The server returns every row in the company. With a 100-row page size, a 5000-employee company is 50 pages. Pace the polling at the rate-limit headroom you have (see Rate limits) and persist the cursor at the end.

For very large initial backfills, partners sometimes prefer to seed with modifiedSince=2000-01-01T00:00:00Z to use the same code path as the steady-state poll. Functionally identical to omitting the parameter.

Combining with webhooks

Webhooks (see Webhooks) push event notifications when a domain event fires. They are the recommended way to react to changes near-real-time. But they are best-effort: a delivery can fail (subscriber down, network blip) and after the retry policy exhausts, the event is gone.

Delta sync is the durable counterpart. A common pattern:

  1. Webhooks for latency. React to events within seconds of them firing.
  2. Hourly delta sync as a safety net. Catch anything that webhooks dropped (failed deliveries, subscription disabled briefly, partner downtime).

The two surfaces share the same data model. Webhook payloads carry resource ids, and partners pull the full resource via GET /api/v1/{resource}/{id} for the current state. The pull endpoint is the single source of truth in both flows.