Skip to main content

Synchronization

The data model is made for offline-first applications: a phone in a field without coverage, a machine terminal, a service that works in batches. The API therefore does not hand out "the current state" — it hands out changes, in an order a client can resume.

What the model guarantees​

RequirementHow the API provides it
Records can be created offline without collidingthe client generates the UUID; PUT /{collection}/{id} stores a record under it
A repeated request is harmlessPUT with If-None-Match: * creates only; a lost response followed by a retry cannot overwrite anything
Concurrent edits resolve without losing bothper-attribute, last write wins: PATCH carries a merge patch naming only the attributes it changes
A client can fetch only what changedevery collection lists in the order updatedAt, then id and hands out a cursor
Withdrawal and deletion reach every clientwithdrawal is a status change and arrives with the record; a deletion arrives as a DELETE entry of /audit-logs
Reported documentation stays accountablechanges to it go through the correction path and are recorded with a reason

Reading changes​

Each collection is polled independently with its own cursor.

GET /fields?limit=500                              first page
GET /fields?limit=500&cursor=<nextCursor> next page, while hasMore is true
GET /fields?cursor=<stored nextCursor> a later poll: only what changed since

Every page carries three members:

{ "items": [ … ], "nextCursor": "…", "hasMore": true }
  • nextCursor resumes after the last record of this page — and it is returned on the last page too, with hasMore: false, so that the next poll resumes exactly where this one ended. Store it per collection.
  • hasMore tells you whether further records follow now.
  • The order is updatedAt, then id, so records written in the same instant do not fall between pages. Treat the cursor as opaque.

To start from a point in time rather than from the beginning, pass updatedAfter=<ISO 8601 UTC timestamp> on the first request instead of a cursor — the two are alternatives. A cursor belongs to one collection and one set of filters. The largest limit the API accepts is the deployment's choice (the specification allows up to 1000); if a request is refused for it, use a smaller page.

An algorithm for a client​

for each collection C in dependency order:                 # farms before fields before regions …
cursor = stored cursor for C (none on first run)
loop:
page = GET /C?limit=500 [&cursor=cursor]
for each record in page.items: upsert into the local store
cursor = page.nextCursor ; store it
if not page.hasMore: break

deletions:
page = GET /audit-logs?action=DELETE [&cursor=…] # same mechanism
for each entry: remove the record entry.entityType / entry.entityId locally

How the three kinds of change arrive​

ChangeWhat the client sees
created or editedthe record, with a later updatedAt
withdrawn from usethe record itself — with a terminal status (COMPLETED, CANCELLED, SOLD …) or an endsAt in the past. It is not removed; references to it stay valid
deletedno record at all — an AuditLog entry with action: DELETE, entityType and entityId. Filter /audit-logs by action, entityType or entityId

Because withdrawal is not deletion, a synchronised store must never treat "this record disappeared from the list" as a deletion: lists do not shrink.

Writing changes​

Work through the outbox in dependency order — a reference must point at a record that exists (Farm before Field before Region, and so on).

Local changeRequest
a record created offlinePUT /{collection}/{id} with If-None-Match: *
attributes changedPATCH /{collection}/{id} with a merge patch of only those attributes
a record replaced as a wholePUT /{collection}/{id} (with If-Match if you want to fail on a newer version)
a record deletedDELETE /{collection}/{id}

How to read the answers:

AnswerMeaningThe client
200, 201, 204doneremoves the entry from the outbox
412 on a createthe record already exists — usually your own earlier requestreads it; if it is yours, the entry is done
409an invalid state transition, an immutable record, a business key or a dependency conflictshows the person; a retry changes nothing
422the record is invalid, or violates a ruleshows the person; a retry changes nothing
401the token is no longer validrefresh the token and retry
403the person may not do this on this farmstops; do not retry
5xx, no networkthe cause is outside the recordretry later

Do not guess from the status code alone — read the problem detail: its type and violations say which rule was violated.

Conflicts, in practice​

  • Two devices change different attributes of a record: both changes survive.
  • Two devices change the same attribute: the later write wins.
  • A change to reported documentation (referenced by a SUBMITTED report) needs Change-Reason; without it the server refuses.

Time​

createdAt and updatedAt are set by the server, in UTC. A client that needs the local time of a record shows it in Farm.timezone. A device whose clock is wrong therefore cannot corrupt the cursor — but it can still write a wrong value into an attribute that holds a time, so keep device clocks honest.

See it in action​