Sparse Updates vs. Full Replacement: Syncing Invoices to QuickBooks Desktop Without Clobbering Edits
- quickbooks sparse update
- synctoken conflict
- quickbooks desktop sync

Quick answers
What does sparse: true do in the QuickBooks API? It tells the update to merge the fields you sent into the existing record instead of replacing the whole object, so omitted fields keep their current values.
What happens if I omit sparse? The update is a full replacement — any field you didn't include gets cleared or reset to its default, including customer memos, custom fields, and line items.
What is a SyncToken? It's an optimistic-concurrency version stamp on a record; you must send the current one with every update, and a stale one returns an error instead of silently overwriting.
How does this work on QuickBooks Desktop? Desktop uses EditSequence, an opaque string with the same purpose, and a compatibility layer like TenkeyBridge maps it to the SyncToken field your QBO code already reads.
Can I sparse-update a single invoice line? Not really — line arrays are replace-in-full even under sparse: true, so you must send every line you want to keep.
If you already ship against the QuickBooks Online API, you know the two-line version of this: send sparse: true and a valid SyncToken, or you'll blow away fields you never meant to touch. What's less obvious is what those semantics turn into when the company file is QuickBooks Desktop or Enterprise, where the underlying model is qbXML, the version stamp is called EditSequence, and the rules about what you can modify are stricter in some places and looser in others.
This post is about the gap between "my update returned 200" and "my update did what the accountant expected."
Full replacement is the default, and it is unforgiving
The QBO update semantics are worth restating precisely, because a lot of integrations get this wrong for months without noticing.
A plain update — no sparse flag — is a full replacement. The object you POST becomes the object of record. Every writable field you omitted is set back to null or its default. That includes:
CustomerMemoandPrivateNoteCustomFieldentriesSalesTermRef,ShipMethodRef,DueDatewhen it was manually overriddenLine— the entire array, including any line a human added in the UI after your last read
A sparse update merges. Send sparse: true plus Id, SyncToken, and only the fields you actually want changed, and the rest of the record stays as it is.
POST /v3/company/{realmId}/invoice
Content-Type: application/json
{
"Id": "148",
"SyncToken": "3",
"sparse": true,
"DueDate": "2025-07-15",
"CustomerMemo": { "value": "Net 30, PO 88421" }
}
That's the whole trick, and it's why the interesting failures don't come from the update call itself — they come from the read that produced the SyncToken, and what happened in the seconds or hours between.
What EditSequence is, in Desktop terms
QuickBooks Desktop has the same problem QBO has — two writers, one record — and solves it the same way, with a different name.
Every qbXML object carries an EditSequence: an opaque string the file generates when the record is written. To modify a record with qbXML you send a Mod request containing TxnID, EditSequence, and the fields you want. If the EditSequence you sent doesn't match what's in the file, QuickBooks rejects the request — typically status code 3200, "The provided edit sequence is out-of-date." Someone changed the record after you read it.
The practical differences you should know:
EditSequenceis opaque. It is not a monotonically increasing integer likeSyncTokenusually appears to be. Don't parse it, don't compare it for ordering, don't assume "higher means newer." Store it as a string and send it back verbatim.- It changes on every write, including writes made by a user clicking Save in the QuickBooks UI, by a bank feed match, by a memorized-transaction run, or by another integration.
- It is per-object, not per-file. Modifying a customer doesn't invalidate the invoice's
EditSequence— but modifying an invoice does invalidate that invoice's.
TenkeyBridge maps EditSequence onto the SyncToken field in the JSON your client already parses, so the round-trip works without changes to your code. You read an invoice, you get a SyncToken, you send it back on update, and a stale value produces a QBO-shaped fault rather than a raw qbXML status code. If you want the longer tour of how Desktop status codes and HRESULTs get translated into faults your error handler already understands, we wrote that up in Decoding QuickBooks Desktop Errors.
Where Desktop's Mod semantics differ from QBO's sparse merge
Here's the honest part. qbXML Mod requests are not sparse in the QBO sense, and the difference shows up in a few specific places.
Line items are replace-in-full — on both platforms. This surprises people because it feels like it should be mergeable. It isn't. In QBO, if you include a Line array in a sparse update, that array replaces the existing lines wholesale; lines you omit are deleted. In qbXML, an InvoiceMod that contains any InvoiceLineMod elements replaces the entire line set, and any existing line whose TxnLineID you don't echo back is removed. Same outcome, same footgun.
So "update one line on an invoice" always means: read the invoice, modify the one line in the array you got back, send all the lines. There is no per-line patch endpoint on either platform.
Some fields are not writable on a Mod at all. Desktop is stricter than QBO about changing certain fields after a transaction exists — particularly on transactions that have been linked (an invoice with payments applied, an item receipt converted to a bill), closed by a closing date, or that touch inventory in ways the file won't let you rewrite. A sparse update that would succeed against QBO can come back as a fault against Desktop. That's a real parity gap, not a bug; the specific per-entity list lives in the compatibility matrix, and it's worth reading before you promise a customer that your invoice editor works identically on both.
Full replacement is more dangerous on Desktop. Because Desktop company files accumulate fields that your integration has never heard of — job costing classes, sales rep, custom fields defined per-file, price levels — an omit-everything full replacement has more to destroy. Our default guidance: use sparse updates unconditionally, and treat full replacement as something you do deliberately, never as a default your HTTP client falls into.
The conflict, in the order it actually happens
A SyncToken conflict is not a race condition in the classic sense. It's usually just latency measured in hours.
- Your worker reads invoice 148 at 09:00.
SyncTokenis3. - Pat Owner at ExampleCo opens the invoice in QuickBooks at 09:20, fixes a shipping address typo, saves. The file's
EditSequencechanges. - Your worker picks up a queued job at 09:41 and posts an update with
SyncToken: "3". - You get a fault.
The fault is the system working. What you do next is the design decision.
The wrong answer, which is nonetheless the most common one in the wild: catch the error, re-read the object, and blindly retry the same payload against the fresh token. That's not conflict resolution — that's clobbering with extra steps. You just overwrote Pat's address fix, because your payload still carries the address you read at 09:00.
Writing a sync loop that's actually idempotent
Four rules get you most of the way.
1. Never cache a SyncToken across a job boundary
Read immediately before you write, in the same unit of work. A token stored in your database from last night's sync is a guess. If your queue can delay a job — and it can — the read belongs inside the job, not in the thing that enqueued it.
2. Send only the fields you own
This is the discipline that makes retries safe. Decide, per entity, which fields your system is the source of truth for. For a billing integration that might be Line, DueDate, CustomerMemo, and one custom field. Everything else — addresses, terms, classes, sales rep — belongs to the user. Then build your sparse payload from only those fields, computed fresh from your own data.
If your update payload is derived entirely from your system's current state and never from the object you read, a blind retry becomes correct by construction. You'll still overwrite a user edit to a field you own — that's the deal you made — but you won't touch anything else.
3. Re-read, re-derive, retry — with a bound
def update_invoice(client, invoice_id, desired):
for attempt in range(3):
current = client.get(f"/v3/company/{realm}/invoice/{invoice_id}")
payload = build_sparse_payload(desired, current) # only owned fields
payload["Id"] = invoice_id
payload["SyncToken"] = current["SyncToken"]
payload["sparse"] = True
try:
return client.post(f"/v3/company/{realm}/invoice", json=payload)
except StaleTokenFault:
continue
raise ConflictUnresolved(invoice_id)
build_sparse_payload takes current only so it can echo back line-level identifiers (Id on QBO lines, TxnLineID on Desktop) and decide whether a change is even needed. It should not copy arbitrary fields forward.
Three attempts is plenty. If you're losing three in a row, the record is being actively edited by a human and you should back off, not fight.
4. Make "no change needed" a real branch
Compare your desired state against what you just read. If they match, return without writing. This matters more on Desktop than on QBO: every write bumps the EditSequence, shows up in the audit trail, and on a multi-user file contends for the same record another user might have open. A sync loop that rewrites 4,000 unchanged invoices every night is generating conflicts for everyone else in the file. If you're working against a hosted or multi-user deployment, the notes on RDS and hosted environments cover the contention side of this in more detail.
Handling the conflict you can't auto-resolve
Sometimes both sides changed a field you own, and there's no safe merge. Three defensible options, in rough order of how often they're right:
- Last-writer-wins with an audit record. Write your value, log the previous one, surface it in your app. Simple, and honest as long as the log is visible.
- Queue for human review. Park the record, stop syncing it, show a diff. Correct for anything money-adjacent — amounts, line totals, tax.
- Yield to QuickBooks. Treat the file as authoritative, pull the change back into your system. Right when your system is a reporting or ordering front-end rather than the book of record.
What's not defensible is silently dropping the conflict, or retrying forever. Pick a policy per entity and write it down where support can find it.
One more Desktop-specific wrinkle: Void and Delete aren't updates
sparse: true doesn't apply to voiding or deleting. On Desktop these are distinct qbXML operations with their own rules — a voided invoice keeps its number and audit history, a deleted one doesn't — and both still require a current EditSequence. If your reconciliation logic treats "set amount to zero" as a soft delete, you'll get different behavior across the two platforms. Void explicitly.
FAQ
Does TenkeyBridge generate the SyncToken, or does QuickBooks?
QuickBooks Desktop does. The SyncToken value you receive is derived from the file's EditSequence for that object, so it reflects real changes in the company file — including edits made by a user in the QuickBooks UI while your integration wasn't looking. Treat it as opaque and round-trip it unmodified.
How do I add a line to an invoice without re-sending the existing ones?
You can't. Read the invoice, append your new line to the array you got back (keeping each existing line's identifier intact), and send the complete array in a sparse update. Omitting a line deletes it.
What happens if the invoice is inside a closed accounting period?
Desktop enforces the closing date at the file level, and the update will fail rather than silently succeed. The error comes back as a fault; how specific it is depends on the entity and the operation, which is one reason we recommend mapping errors to user-visible messages rather than surfacing raw codes — there's more on that in QuickBooks Desktop errors don't have to be cryptic.
Can I detect that a user changed a record, without polling every object?
Use a change-data query with a last-updated filter rather than reading the full object set. Both QBO and the Desktop-backed API support querying by MetaData.LastUpdatedTime, which is how most incremental sync loops should be structured. Poll for changed IDs, then read-and-write only those.
Is the sparse behavior identical across every entity type?
Close, but not identical. Line-bearing transactions all share the replace-the-array behavior; name-list entities like Customer and Vendor merge more cleanly; and a handful of fields are read-only on Desktop after creation. Check the per-entity notes in the compatibility docs before you assume a field is writable. If you want to try a conflicting update against a real Desktop file without setting one up, the sandbox has one loaded.