GET /transactions has an incremental sync mode for consumers that keep their own copy of a company’s transactions. Instead of paging through the entire list on every run and diffing it yourself, you ask “what changed since I last looked?” and receive only the transactions modified since then, including the ones that were deleted.
Supplying updated_after (a Unix timestamp in integer seconds, not milliseconds) switches the endpoint into incremental mode. A request without it behaves exactly as it always has, so existing consumers see no difference.
What changes in incremental mode
Two fields on the transaction object support this mode, and they appear on every endpoint that returns a transaction (list, retrieve, and update):
Note that
updated_after filters on when a transaction was last modified, which is unrelated to start_date and end_date, which filter on the transaction’s own date. A transaction dated last January can have been modified this morning.
The watermark: use the server’s clock, never your own
Every incremental response carries a top-levelwatermark field: a server-generated Unix timestamp that you pass back as updated_after on your next sync.
Always pass back the watermark. Never compute the next updated_after from your own clock. Consumer clocks drift relative to the server’s, and drift in the fast direction silently loses records: you ask for changes since a moment that had not happened yet on the server, and anything written in the gap is skipped with no error and no visible symptom.
The watermark is pinned when a sync starts and is identical on every page of that sync. That is deliberate: it lets you drain all pages of a run and commit a single value at the end.
Treat the watermark as a value to round-trip, not a value to do arithmetic on, even though it is a timestamp. Do not add a safety margin, subtract a few seconds, or compare it against your local clock.
The sync loop
- Call
GET /transactions?updated_after=<your stored watermark>. - Process
data. Apply updates for rows withdeleted: false, remove rows withdeleted: true. - If
has_moreis true, call again withstarting_after=<next_cursor>and the sameupdated_after. Repeat. - When
has_moreis false, storewatermarkas your new checkpoint.
updated_after plus the cursor:
has_more is false, so 1771183600 becomes the stored checkpoint for the next run. Note the watermark is the same 1771183600 on both pages. The transaction objects above are abbreviated; real responses carry the full Transaction object on every row.
Changes made while a sync runs are not lost. A run is bounded at the instant its watermark records, so a transaction modified mid-run simply arrives on your next sync. In rare cases, a write that was still committing while your run read past it can land just behind the watermark. If your use case cannot tolerate even that window, occasionally restart from a checkpoint a few seconds older than the one you stored and let the harmless overlap re-apply. That is the one sanctioned exception to the no-arithmetic rule above; never adjust the watermark in your normal loop.
Bootstrapping a mirror: start at updated_after=0
To seed a new mirror, call GET /transactions?updated_after=0, not the plain list. Zero matches every transaction, so the initial load and every later sync share one mode, one ordering, and one cursor format, and the watermark the initial load returns is exactly what the next run passes back, with no gap between them.
Seeding from the plain list is the trap this design exists to prevent: the plain list returns no watermark, so you would have to invent a starting point from your own clock, reintroducing exactly the drift the watermark removes, and losing anything written between the seed call and your first incremental call.
One cost of starting at zero: the sync also returns tombstones for transactions that were deleted before your mirror existed. You have no copy of them to remove, so skip any deleted: true row you do not already hold. This is harmless.
Deletions
The classic listing has always hidden deleted transactions, and the retrieve and update endpoints do not return them either. A deleted row simply stopped appearing, and there was no way to tell “deleted” apart from “fell outside my page window” without a full re-read. Incremental mode is the exception: a row withdeleted: true is a tombstone. The transaction was genuinely deleted, and you should remove it from your copy. deleted: true is never used to signal any other kind of disappearance; see the filter caveat below for the one situation where a row vanishes without a tombstone.
Combining filters with updated_after
The list’s filters may be combined with updated_after and are applied normally. Know what you are getting, because there is a real hazard here.
Filters match a transaction’s current state. A transaction that changes such that it no longer matches your filter simply stops appearing in your sync results, with no deleted tombstone, because it was not deleted. Concretely: you sync with status=approved, a transaction you already hold moves back to pending, and your next sync just does not mention it. Your copy still says approved, and nothing told you otherwise.
The combination is safe in two situations:
- You are asking a question, not maintaining a copy. A one-shot query that keeps no state cannot drift, because there is nothing persistent to go stale.
- You are filtering on something that cannot change.
source_vendorandsource_typeare fixed at ingestion, andstart_date/end_datefilter on the transaction’s immutabledate, so those are safe.
Errors
Invalidupdated_after returns 400 invalid_request. The value must be a non-negative integer no greater than 253402300799 (9999-12-31). The upper bound catches the classic mistake: a JavaScript consumer passing Date.now() sends milliseconds, roughly 1000x too large. The same 400 is returned for a non-integer value and for a negative value.
404 resource_missing. The incremental next_cursor is an opaque token. Handing back something the server did not issue (a truncated string, a transaction id, a hand-edited value) is rejected rather than half-trusted:
404 for an unknown transaction id passed as starting_after, with a generic message and no param. Both cases are declared on the list operation.
Consuming incremental sync safely
- Treat
next_cursoras opaque in incremental mode. It is an encoded internal token. Do not parse it, construct one, or assume it is a transaction id. In the classic listing it remains a transaction id. - Handle
deleteddefensively everywhere. It may betrueonly in incremental mode today, but read it wherever you consume a transaction rather than assuming it is alwaysfalseelsewhere. - Carry
updated_afteron every page of a sync, not just the first. It is part of the query, and the cursor alone does not replace it. - Expect rows you have never seen. Any consumer that starts using
updated_afterbegins receiving transaction objects for deleted rows it previously could not see at all.