Appearance
IDs, SyncToken & sparse updates
Cross-cutting rules that apply to every live entity. You usually don't need to change application code for these — they're listed so you know what's happening and why behavior matches QuickBooks Online.
IDs
We pass Desktop's ListID / TxnID through as the QBO Id. As long as your code doesn't parse the format of an ID (almost nobody does), references work unchanged across create → read → update → delete.
CompanyInfo is the exception: its Id equals the realm ID (Desktop's company profile has no ListID/TxnID). See the CompanyInfo section.
SyncToken
Maps to Desktop's EditSequence. Optimistic-concurrency conflicts return the same 400 you'd get from QBO when the token is stale — re-fetch and retry.
ReferenceType
QBO's { value, name } shape maps to Desktop's ListID + FullName. Either side of a round-trip keeps both pieces when Desktop returns them.
Updates & sparse updates
Updates (?operation=update, or a POST body with Id) are classified by the body, not just the query param: a POST whose body carries an Id is always treated as an update, whether or not ?operation=update is on the URL — an Id-bearing POST never reaches create, so it can never duplicate the record.
?operation=update — or a body with "sparse": true — with no Id can't be an update, so we fail loud with a 400 instead of guessing.
Desktop has no partial update, so we read-merge-write the full record for you. Your sparse POST behaves like QBO's — with one semantic difference worth knowing: every update behaves as sparse on Desktop, whether or not your request sets "sparse": true. QBO's full update clears any writable field you omit from the body; Desktop's Mod has no "clear what's missing" mode, so omitted fields are always preserved, never cleared — even on a full-body update with no sparse flag. If your client relies on QBO's full-update semantics to blank out a field by omitting it, that won't happen here; set the field explicitly instead.
Line-level updates
On Invoice, Estimate, SalesReceipt, CreditMemo, PurchaseOrder, JournalEntry and Deposit, an update that carries Line is the complete new line table, the same contract as QBO:
- Keep a line by sending its
Id(theLine.Ida read returned). Desktop keeps the line's identity, so line Ids stay stable across updates and links that point at a specific line survive. Fields you omit on a line that carries anIdare merged from the current record. - A line with no
Idis added. - A line you leave out is deleted. To leave the lines untouched, omit
Lineentirely; header-only updates never touch lines. An emptyLine: []is rejected (2020) rather than deleting every line. - The same
Idtwice in one array is a2010fault; anIdthat is not on the record is a400.
Entity notes: a JournalEntry update is checked for balance over the merged lines and a line may flip between Debit and Credit. On a Deposit, a payment-linked line keeps its link but its amount and payer come from the payment, so edits to them are not applied; a CashBack that differs from the current record is refused (422). On a PurchaseOrder, changing Qty or UnitPrice without Amount recomputes Amount.
CustomerMemo and TxnTaxDetail.TxnTaxCodeRef are also updatable on the four sales forms. Line-level updates on other transaction entities (Bill, …) still fail loud with UNSUPPORTED_BY_DESKTOP.
Deletes
POST /{entity}?operation=delete works for transactions (Invoice, SalesReceipt, Bill, Payment, TimeActivity, Estimate) and returns QBO's {"status": "Deleted"} shape.
One honesty note: Desktop's delete takes no SyncToken, so a stale token can't be rejected the way QBO would. Name-list entities (Customer, Vendor, Account, Item) are never deleted — matching QBO, deactivate them with a sparse Active: false update.
Voids
POST /{entity}?operation=void with {"Id": "...", "SyncToken": "..."} voids a posted transaction and returns the record as Desktop reports it afterwards. Supported for Invoice, SalesReceipt, Purchase (Check, CreditCardCharge and CreditCardCredit families) and BillPayment (check and credit-card families). Under the hood: a read (finds the Desktop family and the current EditSequence), a TxnVoidRq, then a re-read.
- SyncToken is enforced. Desktop's
TxnVoidRqhas noEditSequenceinput, so TenkeyBridge compares yourSyncTokento the record's currentEditSequencebefore voiding and returns5010 Stale Object Erroron a mismatch — the same as QBO.SyncTokenis required. - Residual race window.
TxnVoidRqtakes noEditSequence, so a change made in QuickBooks between our read and the void is not detected; theSyncTokencheck protects against stale callers, not against that gap. - Unknown read-back. If the void succeeds but the follow-up read fails, you get
502 VOID_APPLIED_READBACK_FAILED— the void was applied;GETthe record and do not retry. - Payment cannot be voided. qbXML has no void for
ReceivePayment; the call returns400 UNSUPPORTED_BY_DESKTOP— delete the payment instead. Every other entity also returns400. - Batch:
operation: "update"+optionsData: "void"(Batch guide). - What Desktop returns after a void (proven on QuickBooks Enterprise 24): the same
IdandTxnDate, a newSyncToken, totals and line amounts at0, andPrivateNoteprefixedVOID:. Invoice and SalesReceipt lines also getQty: 0; a BillPayment's applied-to-billLineentries disappear and the bills it paid reopen. Voiding an already-voided record succeeds and changes nothing. - Voiding a check adds journal entries. When you void a
Purchaseof the Check family, QuickBooks posts a journal entry dated the check's own date that re-books the original check, and a reversing journal entry dated the day of the void, and rewrites the memo toVOID: <memo> GJE, RGJE created on MM/DD/YYYY(the void date). A check voided in a later month therefore still shows its expense in the original month, and the reversal lands in the month of the void. The check is also marked printed (PrintStatus: PrintComplete). Expect those two entries in the bank register and journal reports. Credit-card charge and credit voids are not yet live-tested. - Sandbox: the sandbox reproduces this post-void shape, memo included, but does not create the journal-entry pair.
Warnings
A request can succeed and still carry a caveat. Non-fatal conditions come back on the successful response in an X-TKB-Warning header (printable ASCII, at most 500 characters, several warnings joined with ; ). The body is unchanged, so clients that ignore the header keep working. You will see it in two cases:
- QuickBooks warned. Desktop performed the request but answered with a warning-severity status for part of it. The header reads
QuickBooks warning <code>: <message>, for exampleQuickBooks warning 530: The field "Name" is not supported by this implementation. (EmployeeDisplayNameno longer takes this path: Desktop derives it from the first, middle and last names, so a write naming a different one is refused with a400up front.) - TenkeyBridge ignored a field. On an Invoice, SalesReceipt, Estimate or CreditMemo create or update, a
TxnTaxDetail.TotalTaxsent without aTxnTaxCodeRefis not sent to QuickBooks, which computes the tax itself. If its figure differs from yours the header readsTxnTaxDetail.TotalTax ignored; QuickBooks Desktop computed 8.25.
Warnings appear on single-record and query responses, never on error responses, and the TotalTax warning is not emitted for /batch items. See Warnings in the error reference.
Query support
GET /v3/company/{realmId}/query?query=... accepts QBO's SQL-like language:
sql
SELECT * | COUNT(*) | Col1, Col2, ... FROM Entity
[WHERE cond AND cond ...]
[ORDERBY field [ASC|DESC] [, field ...]] -- ORDER BY also accepted
[STARTPOSITION n] [MAXRESULTS n]A cond is field = | != | < | > | <= | >= value, field IN ('a','b'), or field LIKE 'pattern' (% is the only wildcard). Keywords are case-insensitive; strings are single-quoted with \' and \\ escapes; numbers and true/false are bare (quoted numbers are accepted too, as QBO does). Like QBO, there is no OR.
sql
select * from Customer where DisplayName = 'Acme'
select * from Invoice where CustomerRef = '12' and TxnDate >= '2026-01-01'
and TxnDate <= '2026-03-31' orderby TxnDate desc maxresults 100
select count(*) from Invoice where Balance > '0'
select * from Item where Name like 'Wid%'
select Id, DisplayName from Customer where Id in ('1', '2')COUNT(*)answers{ "QueryResponse": { "totalCount": N } }(count of the filtered set;STARTPOSITION/MAXRESULTSare ignored).- Column lists return exactly the listed properties (
Idis not added implicitly; dotted names such asMetaData.LastUpdatedTimekeep their nesting). ORDERBYsorts the whole filtered set beforeSTARTPOSITION/MAXRESULTSslice it. Strings sort case-insensitively, dates and timestamps chronologically, amounts numerically; a record with no value sorts first (last when descending).- String comparisons (
=,!=,IN,LIKE,<, …) are case-insensitive and trimmed — a TenkeyBridge rule, not a verified match of QBO's own collation. Refs (CustomerRef = '12') compare the exact id. - A date-only value against a timestamp (
MetaData.LastUpdatedTime >= '2026-06-01') names the whole UTC day:>=starts at its first instant,<=runs through its last,>/<exclude it. Strict</>onTxnDatebecome inclusive bounds one day over.
What QuickBooks filters, and what TenkeyBridge filters
QuickBooks Desktop can only apply some conditions itself. TenkeyBridge pushes every condition it can into the qbXML request and applies the rest to the translated records before sorting, counting and paging. The gateway-side path fetches up to 5,000 records from QuickBooks; if that fetch comes back full it returns UNSUPPORTED_QUERY (never a possibly-incomplete answer) — narrow the query with a pushed filter.
| Entity | Applied by QuickBooks (fast) |
|---|---|
| All transactions — Invoice, SalesReceipt, Estimate, CreditMemo, Payment, RefundReceipt, Bill, VendorCredit, PurchaseOrder, BillPayment, Purchase, JournalEntry, Deposit, Transfer, TimeActivity | Id (=, IN); TxnDate and MetaData.LastUpdatedTime with =, <, <=, >, >= (both bounds together); DocNumber = / IN and one-sided LIKE ('INV%', '%24', '%2026%') — not TimeActivity, which has no reference number in Desktop; on Payment the field is PaymentRefNum (DocNumber is accepted as an alias) |
| Invoice, Estimate, SalesReceipt, CreditMemo, Payment, RefundReceipt | CustomerRef (=, IN) |
| Bill, VendorCredit, PurchaseOrder, BillPayment | VendorRef (=, IN) |
| Purchase | EntityRef — the payee (=, IN) |
| Invoice, Bill | Balance > 0 narrows to unpaid documents in QuickBooks; the exact test runs gateway-side |
| All lists — Customer, Vendor, Employee, Account, Item, Class, Term, PaymentMethod, CustomerType, TaxCode, TaxRate, CompanyCurrency, ExchangeRate | Id (=, IN); Active (=, !=, IN (true, false)); MetaData.LastUpdatedTime ranges; the entity's name (DisplayName on Customer / Vendor / Employee, Name elsewhere) with = and LIKE |
| Customer, Account, Class, Item | FullyQualifiedName = / IN ('Acme:Kitchen'). On these hierarchical lists QuickBooks matches names against the full colon path, so a name = or prefix LIKE narrows the fetch and is then checked exactly on the short name — DisplayName = 'Kitchen' finds the job Acme:Kitchen |
| TaxAgency | Active only (it is derived from several Desktop lists) |
Everything else — MetaData.CreateTime, != and IN on dates, numeric comparisons (TotalAmt, Balance, …), any other field (PrimaryEmailAddr, CompanyName, GivenName, AccountType, SalesTermRef, …), interior-wildcard LIKE — is applied by TenkeyBridge to the fetched records. Conditions combine with AND only. Id, DocNumber and FullyQualifiedName lookups are single QuickBooks requests that take no other filter, so any other condition in the same query is checked on the (tiny) result.
Timestamps and MetaData.LastUpdatedTime. QuickBooks Desktop rejects any datetime ending in Z, so TenkeyBridge sends explicit +00:00 offsets (full datetimes are honored to the second). Desktop reads a date-only bound in the machine's local time, and labels every timestamp it returns with a fixed -07:00 while its clock is local — so the MetaData times you read can be an hour off true UTC during daylight saving. A date-only LastUpdatedTime bound is therefore pushed a day wider each way and then re-checked against the returned MetaData (the UTC-day rule above), while a full datetime bound is pushed exactly and trusted as sent.
Other details. IN lists are de-duplicated and may hold at most 1,000 distinct values (4000 above that). String literals may not contain control characters (4000). Unquoted ISO dates and words (TxnDate >= 2026-01-01, DocNumber = INV-100) are still accepted as strings, as before. ORDERBY Id compares all-digit ids numerically. A name equality (DisplayName = 'Acme') narrows the Desktop fetch with a NameFilter; if that returns nothing the gateway retries once over the unfiltered list before answering empty, names containing non-ASCII characters are never pushed, and setting TKB_QUERY_NAME_PUSHDOWN=off on the gateway disables name pushdown entirely (full scan + exact check, as before v2). Name LIKE always keeps its exact gateway-side re-check.
Two-phase scan. When a WHERE or ORDERBY can't be pushed into QuickBooks, the gateway no longer pulls full records for the whole scan window. It first asks QuickBooks for just the id, EditSequence and the few fields the query reads (IncludeRetElement; no line items, linked transactions or custom fields), filters, sorts and pages those slim rows, then fetches the full records for only the returned page by id (in chunks of 250). Results are identical to a single full fetch; COUNT(*) and column lists the slim rows already cover need no second request, and an empty page stops after the first. It applies to Customer, Vendor, Account, Class, Invoice, Bill, SalesReceipt, Estimate and CreditMemo; other entities, conditions on a property the slim request can't express (for example Bill.Balance), one-shot Id/DocNumber lookups and narrow windows (a DocNumber prefix, a single customer, a TxnDate range of a week or less) keep the single full fetch. A record changed between the two requests is returned with its current values; one deleted in between is omitted. Setting TKB_QUERY_TWO_PHASE=off on the gateway restores the single full fetch.
Limits and error codes. A gateway-side fetch is capped at 5,000 records — 1,000 for transactions fetched with line items (about 30 ms and 2.5 KB each on Desktop; COUNT(*) and header-only column lists skip lines and keep 5,000, and a two-phase scan's slim rows carry no lines, so it keeps 5,000 too). Hitting the cap is UNSUPPORTED_QUERY / HTTP 400 (narrow with a TxnDate or LastUpdatedTime range); syntax and unknown-property errors are 4000 / HTTP 400.
Errors
- Invalid syntax (
OR, an unquoted date, an unterminated string, a badMAXRESULTS) is QBO'sQueryParserError: HTTP 400, code4000. - An unknown property, or a wrong operator/value for a property, is
QueryValidationError: property Foo not found in Customer: HTTP 400, code4000. QBO properties Desktop has no equivalent for returnUNSUPPORTED_QUERYnaming the property. - A valid query TenkeyBridge can't answer reliably (the 5,000-record scan cap, deep
STARTPOSITIONon a list) returnsUNSUPPORTED_QUERYnaming the clause. CompanyInfo,PreferencesandBudget(synthesized) take only a bareSELECT *(andWHERE Id = ...on Budget); per-entity exceptions are listed under each entity's Query support section on the compatibility page.
Errors
We return QBO-style Fault JSON, so existing error handling keeps working. Anything Desktop genuinely can't do — or that TenkeyBridge has not shipped yet — returns a documented code. See Error codes.
Gap entities (no Desktop equivalent) and planned entities (not yet built) both return UNSUPPORTED_BY_DESKTOP with a message that links to the entity's compat section. Genuinely unknown entity names still return NOT_FOUND.

