--- url: https://docs.tenkeybridge.com/guide/quickstart.md --- # Quickstart TenkeyBridge exposes QuickBooks Desktop (and Enterprise) through the same REST shape as the QuickBooks Online Accounting API. If you already have a working QBO integration, you don't rewrite it — you point it at TenkeyBridge. ::: info Early access TenkeyBridge is in early access. The hosted gateway at **api.tenkeybridge.com is live**. There is no self-serve signup yet — credentials are issued manually during onboarding (see [Authentication](/guide/authentication)) until the developer portal ships. ::: ## The only required change Same OAuth 2.0 flow, same paths, same JSON entities. Swap the base URL: ```diff - const BASE = "https://quickbooks.api.intuit.com"; + const BASE = "https://api.tenkeybridge.com"; ``` Everything else stays: ```js // Unchanged from your existing QBO code const res = await fetch( `${BASE}/v3/company/${realmId}/invoice/${id}`, { headers: { Authorization: `Bearer ${accessToken}` } } ); const invoice = await res.json(); ``` That's it for the happy path. ## What to expect Reads come back essentially unchanged — the compatibility matrix marks almost every entity's read side **Full**. Writes are where Desktop's quirks surface: line-item shapes, tax handling, and a handful of fields need a quick, documented change before they behave identically to QBO. Updates work the way you'd expect from QBO: `POST` the entity with `Id` and `SyncToken`, optionally with `?operation=update` on the URL (both are accepted, and a body with `Id` is always routed as an update — it never falls through to create). One semantic difference to know: Desktop has no partial-update mode, so every update behaves like QBO's sparse update — fields you omit from the body are left alone, never cleared, even on a full-body update with no `sparse` flag set. When you hit a case Desktop genuinely can't do, you get a QBO-style `Fault` back with a stable, versioned error code instead of a silent difference — see [Error codes](/reference/error-codes) for the full list and what to do about each one. Before you integrate a new entity, check the [entity matrix](/compatibility/) for its field-by-field support and the exact fix for anything marked partial. Cross-cutting rules for IDs, `SyncToken`, sparse updates, and query support live in [IDs, SyncToken & sparse updates](/guide/concepts). ## Where to go next 1. [Authentication](/guide/authentication) — OAuth 2.0 code flow (same as QBO). 2. [Node.js client](/guide/npm-client) — `@tenkeybridge/client`, typed helpers + token management for new code. 3. [Edge agent install](/guide/agent-install) — Windows agent next to QuickBooks. 4. [Entity matrix](/compatibility/) — full live / planned / gap coverage. 5. [Gateway ops](/guide/gateway-ops) — deploy, migrate, and seed (operators). --- --- url: https://docs.tenkeybridge.com/guide/authentication.md --- # Authentication TenkeyBridge clones the QuickBooks Online OAuth 2.0 authorization-code flow. If your app already connects to QBO, you keep your OAuth code and swap two URLs. | | QuickBooks Online | TenkeyBridge | |---|---|---| | Authorize URL | `https://appcenter.intuit.com/connect/oauth2` | `https://api.tenkeybridge.com/oauth2/v1/authorize` | | Token URL | `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer` | `https://api.tenkeybridge.com/oauth2/v1/tokens` | ## Getting credentials Credentials are self-serve through the [Admin API](/guide/admin-api): * **Register an OAuth client** — `POST /admin/v1/clients` with your app's name and redirect URIs. Returns a `client_id` and a `client_secret`, shown once. * **Provision a realm per customer** — `POST /admin/v1/realms`, one per QuickBooks company you connect. Returns a `realmId` — you already hold this by the time you send anyone to the authorize URL, because you're the one who created it. Both live under one organization; see [Organizations and roles](/guide/admin-api#organizations-and-roles) for who on your team can do what. ::: tip Early access The self-serve **portal UI** in front of this API (#91 P4) isn't live yet — for now, access to `/admin/v1` itself is arranged by email (). Once you have that access, provisioning clients and realms works exactly as described above and won't change when the portal ships. ::: ## 1. Send the user to the authorize URL ```text https://api.tenkeybridge.com/oauth2/v1/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https://yourapp.example.com/callback &state=RANDOM_OPAQUE_STRING &scope=com.intuit.quickbooks.accounting &realm_id=THE_REALM_ID ``` * `response_type` must be `code` (anything else is rejected with `unsupported_response_type`, HTTP 400). * `redirect_uri` must exactly match one of your registered redirect URIs. * `scope` is accepted for QBO compatibility and defaults to `com.intuit.quickbooks.accounting`. * `realm_id` is **required**: the `realmId` of the QuickBooks company being connected. You already hold it — you're the one who created the realm through the [Admin API](/guide/admin-api) — so this is the one parameter Intuit's authorize URL doesn't have. The consent page names your app and the company (`realm_id` resolves server-side to a company name) and has one button. On success the browser is redirected to your `redirect_uri` with `code`, your `state`, and `realmId` in the query string — the same shape Intuit sends. The server enforces that `realm_id` belongs to the **same organization** that owns `client_id`, on both the `GET` that renders this page and the `POST` it submits to — independently, so a value that only made it through a browser round trip is never trusted on its own. With that ownership check server-side, this page is honest about what it is: it preserves the QBO consent-screen *shape* so your existing OAuth code keeps working unmodified, but the click itself isn't the access control — the same-org check is. A `realm_id` that doesn't exist, or belongs to a different organization than the client, answers with the identical error either way (see below), so the page can't be used to probe which realm ids exist. ## 2. Exchange the code for tokens `POST /oauth2/v1/tokens` with HTTP **Basic** auth (`client_id:client_secret`) and a form body: ```bash curl -s https://api.tenkeybridge.com/oauth2/v1/tokens \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri=https://yourapp.example.com/callback ``` `redirect_uri` must match the one the code was issued against. A mismatch returns `invalid_grant` **and consumes the code** — restart from step 1. Response: ```json { "token_type": "bearer", "access_token": "…", "expires_in": 3600, "refresh_token": "…", "x_refresh_token_expires_in": 8640000, "realmId": "1234567890" } ``` Access tokens last **1 hour**; refresh tokens last **100 days**. ## 3. Refresh Same endpoint, same Basic auth: ```bash curl -s https://api.tenkeybridge.com/oauth2/v1/tokens \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=refresh_token \ -d refresh_token="$REFRESH_TOKEN" ``` Refresh tokens **rotate**: each refresh returns a new pair. Persist the new `access_token` *and* `refresh_token` together before using either. Rotation is **soft**, matching Intuit: the previous refresh token stays usable for about 5 minutes after rotation. This exists for apps that refresh concurrently (a scheduled sync job racing a user-triggered one, say) without a refresh mutex — the race's "loser" isn't punished. Presenting the previous token within that window returns a **fresh, independently valid pair** (not the same one the winner got); both the winner's and the loser's pairs keep working. Presenting it again beyond the window returns `invalid_grant`. Revoking any token in the chain (see below) kills refresh for the whole connection immediately, window or not. ## 4. Call the API ```bash curl -s "https://api.tenkeybridge.com/v3/company/$REALM_ID/query" \ --get --data-urlencode "query=select * from Customer maxresults 5" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Accept: application/json" ``` Same paths and JSON as QBO — see the [Quickstart](/guide/quickstart) and the [compatibility contract](/compatibility/). ## Revoking `POST /oauth2/v1/revoke` with Basic auth and `token=`. Idempotent — always returns 200 (RFC 7009). Revoking any token for a connection kills refresh for that whole connection right away: a still-in-window previous refresh token (see rotation, above) stops working too. A disconnect means disconnected. ## Errors | Symptom | Meaning | |---|---| | 400 `unsupported_response_type` on authorize | `response_type` wasn't `code` | | 400 "invalid client\_id or redirect\_uri" | Unknown client, or `redirect_uri` not registered | | 400 "realm\_id is required" on authorize | `realm_id` missing from the authorize URL or consent form | | 400 "invalid realm\_id" on authorize | The realm doesn't exist, or belongs to a different organization than the client — same error either way | | 401 `invalid_client` on `/tokens` | Basic auth header wrong (client ID/secret) | | 400 `invalid_grant` on `/tokens` | Code expired/consumed, `redirect_uri` mismatch, or a refresh token reused past its ~5-minute grace window (or after revocation) | API-level faults use QBO's `Fault` shape — see [Error codes](/reference/error-codes). --- --- url: https://docs.tenkeybridge.com/guide/npm-client.md --- # Node.js client If you already have a QuickBooks Online integration, you don't need a client library — [swap the base URL](/guide/quickstart) and your existing stack keeps working. For new Node.js code, `@tenkeybridge/client` is the supported client: a thin, zero-dependency, typed wrapper over the REST surface with OAuth token management built in. ```sh npm install @tenkeybridge/client ``` Requires Node 18.17+. Ships ESM and CJS, no runtime dependencies. ## Connect and read ```ts import { TenkeyBridgeClient } from "@tenkeybridge/client"; const client = new TenkeyBridgeClient({ realmId: process.env.TKB_REALM_ID!, auth: { clientId: process.env.TKB_CLIENT_ID!, clientSecret: process.env.TKB_CLIENT_SECRET!, refreshToken: process.env.TKB_REFRESH_TOKEN!, onTokensRefreshed: async (tokens) => saveTokens(tokens), }, }); const { entities } = await client.customer.query({ maxResults: 10 }); const invoice = await client.invoice.get("2FBE8-1071508936"); ``` The `auth` block accepts a static `{ accessToken }`, the client-credentials + refresh-token shape above, or a `TokenManager` you construct yourself. With a refresh token, the client refreshes ahead of expiry, single-flights concurrent refreshes, and calls `onTokensRefreshed` whenever the pair rotates — persist it there. [Rotation is soft](/guide/authentication#refresh-token-rotation): the previous refresh token keeps working for a short grace window, and the manager uses that to recover from concurrent-refresh races automatically. ## Writes Same JSON as QuickBooks Online. Updates are always [sparse](/guide/concepts) — send `Id` plus the fields you're changing: ```ts const created = await client.invoice.create({ CustomerRef: { value: "80000001-1234" }, Line: [{ DetailType: "SalesItemLineDetail", Amount: 100, SalesItemLineDetail: { ItemRef: { value: "42" }, Qty: 2 }, }], }); await client.invoice.update({ Id: created.Id!, DocNumber: "INV-1042" }); await client.invoice.delete({ Id: created.Id! }); ``` ## Typed against the matrix The per-entity helpers and TypeScript types are **generated from the same [compatibility catalog](/compatibility/) that gates the gateway**, so the client can't promise more than the API delivers: * Only the 31 live entities get accessors (`client.customer`, `client.salesReceipt`, …). * Read-only entities (Item, Employee, Preferences, TaxRate, …) expose `get`/`query` but no `create`/`update`/`delete` — it's a compile error, not a runtime 400. * Entity types carry only the fields the matrix marks supported; anything Desktop can't honor is absent, so TypeScript flags it before the API has to. Field-level notes — the exact fix for anything marked *partial* — live in the [entity matrix](/compatibility/). ## Queries and pagination ```ts const page = await client.query("Invoice", { where: "TxnDate >= '2026-01-01'", maxResults: 100, startPosition: 1, }); // Auto-pagination for await (const c of client.customer.queryAll()) { console.log(c.DisplayName); } // Raw QBO query strings work too await client.rawQuery("select * from Customer maxresults 5"); ``` The query language is the documented QBO subset — see [IDs, SyncToken & sparse updates](/guide/concepts) for what's accepted. ## Errors API failures throw `TenkeyBridgeApiError` carrying the QBO `Fault` plus TenkeyBridge's `tkb` hint block — stable [error code](/reference/error-codes), causes, fixes, and a docs link: ```ts try { await client.invoice.create(inv); } catch (err) { if (err instanceof TenkeyBridgeApiError) { console.error(err.code, err.tkb?.fixes, err.tkb?.docsUrl); } } ``` OAuth failures (expired refresh token, bad credentials) throw `TenkeyBridgeOAuthError` with the OAuth error code (`invalid_grant`, …). ## Where to go next 1. [Authentication](/guide/authentication) — getting credentials and the OAuth flow. 2. [Entity matrix](/compatibility/) — what's live, field by field. 3. [Error codes](/reference/error-codes) — every stable code and its fix. --- --- url: https://docs.tenkeybridge.com/guide/concepts.md --- # 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](/compatibility/#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 sales forms On **Invoice, Estimate, SalesReceipt, and CreditMemo**, an update that carries `Line` replaces the **entire line table** — the same contract as QBO, where the `Line` array on an update is always the complete new set. Two Desktop-flavored consequences to know: * Every line in the update is re-created on Desktop, so **line `Id`s change on every line-level update**. Don't cache sales-form line Ids across writes, and expect links that point at a specific line (e.g. a partially-invoiced estimate's line links) to be severed by a line-level update. * Any existing line you omit from `Line` is **deleted**. To leave the line table completely untouched, omit `Line` from the update body entirely — header-only updates never touch lines. An empty `Line: []` is rejected (`2020`) rather than deleting every line on a form that requires at least one. `CustomerMemo` and `TxnTaxDetail.TxnTaxCodeRef` are also updatable on these four entities. Line-level updates on other transaction entities (Bill, JournalEntry, …) 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. ## Query support {#query-support} TenkeyBridge compiles a deliberate subset of QBO's SQL-like query language: * `SELECT * FROM Entity` (column lists and aggregates are rejected) * Single-condition `WHERE` on indexed fields (`Id`, date ranges on `TxnDate` / `MetaData.LastUpdatedTime`, `DocNumber`, `Active` where applicable) * Customer dedup lookups: `PrimaryEmailAddr`, `CompanyName`, `DisplayName`, `GivenName`, `FamilyName` (alone), or `GivenName` + `FamilyName` together * `STARTPOSITION` / `MAXRESULTS` pagination within documented caps Anything outside that subset returns `UNSUPPORTED_QUERY` rather than a silent mis-parse. Per-entity exceptions (e.g. CompanyInfo rejects all WHERE/pagination) are listed under each entity's **Query support** section on the [compatibility page](/compatibility/). ## 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](/reference/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`. --- --- url: https://docs.tenkeybridge.com/guide/change-data-capture.md --- # Change Data Capture CDC is QBO's incremental-sync endpoint: one request, multiple entities, and you get back everything that changed — including deletions — since a timestamp you supply. It's the efficient alternative to re-walking `/query` for every entity on every poll. ``` GET /v3/company/:realmId/cdc?entities=Invoice,Customer&changedSince=2026-07-01T00:00:00Z ``` Same Bearer auth as every other endpoint. ## Example ```bash curl -s "https://api.tenkeybridge.com/v3/company/$REALM_ID/cdc" \ --get --data-urlencode "entities=Invoice,Customer" \ --data-urlencode "changedSince=2026-07-01T00:00:00Z" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Accept: application/json" ``` The response wraps one slot per requested entity, **in request order**, inside a single `CDCResponse` envelope: ```json { "CDCResponse": [ { "QueryResponse": [ { "Invoice": [ { "Id": "1F00-1", "SyncToken": "0", "DocNumber": "INV-1", "...": "full invoice payload" }, { "Id": "1F00-DEL-1", "status": "Deleted", "domain": "QBO", "MetaData": { "LastUpdatedTime": "2026-07-20T09:15:00-07:00" } } ], "startPosition": 1, "maxResults": 2 }, { "Fault": { "Error": [ { "Message": "More than 1000 Customer objects changed since 2026-07-01T00:00:00Z.", "Detail": "More than 1000 Customer objects changed since 2026-07-01T00:00:00Z. See https://docs.tenkeybridge.com/reference/error-codes.html#cdc_overflow", "code": "CDC_OVERFLOW" } ], "type": "ValidationFault" }, "tkb": { "code": "CDC_OVERFLOW", "causes": ["More than 1,000 objects changed for this entity in the requested window. A CDC slot never silently truncates, so the entity returns this fault instead of a partial array."], "fixes": [ "Shorten the changedSince window and poll more frequently.", "Or walk this entity with /query using a MetaData.LastUpdatedTime filter plus STARTPOSITION/MAXRESULTS pagination — that path has no object cap." ], "docsUrl": "https://docs.tenkeybridge.com/reference/error-codes.html#cdc_overflow" } } ] } ], "time": "2026-07-29T18:04:11.203Z" } ``` Here `Invoice` changed: one updated record, then one deleted stub. `Customer` happened to cross the per-entity cap for this window, so its slot is a `Fault` instead of data — the `Invoice` slot is unaffected. That's the whole model: **each entity gets its own outcome**, and one bad entity never takes down the rest of the poll. Deleted stubs are minimal on purpose — `status`, `domain`, `Id`, and `MetaData.LastUpdatedTime` (when the record was deleted) — matching QBO's shape. They're always sorted oldest-first and always come after the changed rows in the same array. An entity with nothing to report still gets a slot — an empty array, not an omission: ```json { "Item": [], "startPosition": 1, "maxResults": 0 } ``` ## Rules | Rule | Behavior | |---|---| | Lookback window | `changedSince` can be at most 30 days back — QBO's own limit. Older → whole-request `400` [`CDC_INVALID_CHANGED_SINCE`](/reference/error-codes.html#cdc_invalid_changed_since). | | `changedSince` format | Full ISO 8601 (`2026-07-01T00:00:00Z`) or a bare date (`2026-07-01`). Missing or unparseable → the same `400` above. | | Per-entity cap | 1,000 changed objects. Crossing it never truncates silently — that entity's slot becomes a [`CDC_OVERFLOW`](/reference/error-codes.html#cdc_overflow) fault instead. Shorten the window, or walk that entity with `/query` and a `MetaData.LastUpdatedTime` filter. | | Entity list | Missing or empty `entities` → whole-request `400` [`CDC_INVALID_ENTITIES`](/reference/error-codes.html#cdc_invalid_entities). | | Dedup | Entity names are deduped case-insensitively — `entities=customer,Customer` yields one `Customer` slot. | | Slot order | One slot per deduped entity, in the order you listed them (first occurrence wins on a dedup collision). | | Coverage | Every requested entity gets a slot, even with zero changes — empty array, `maxResults: 0`. | | Fault isolation | A per-entity fault (unknown entity, gap/planned entity, overflow, or a Desktop execution error) replaces only that entity's slot. The poll keeps going for everything else. | | Transport failure, before any data | The agent is offline or times out before a single round trip has succeeded → the whole request fails: `503` [`AGENT_OFFLINE`](/reference/error-codes.html#agent_offline) or `504` [`AGENT_TIMEOUT`](/reference/error-codes.html#agent_timeout). No `CDCResponse` envelope at all. | | Transport failure, mid-poll | The agent goes offline or times out after at least one round trip has already succeeded → the request still returns `200`. Entities already fetched keep their data; the entity that was in flight and every entity still waiting get an `AGENT_OFFLINE`/`AGENT_TIMEOUT` fault slot, with no further sends attempted. | Unsupported or unrecognized entity names don't fail the whole request either — they come back as their own `Fault` slot (`UNSUPPORTED_BY_DESKTOP` for gap/planned entities, `NOT_FOUND` for names TenkeyBridge doesn't recognize at all), right alongside the entities that did return data. ## Honest boundaries CDC is a read over whatever Desktop can actually tell you changed — which is narrower than QBO's model in a few specific ways: * **Deactivation is not deletion.** Desktop only hard-deletes list records that were never used on a transaction. Setting `Active: false` on a Customer, Vendor, Item, etc. is a *change*, not a delete — it comes back as a normal changed record in the entity's array, never as a `Deleted` stub. If your sync logic treats deactivation as a delete signal, read `Active` on the changed record instead of waiting for a stub that will never arrive. * **Three deletions Desktop can't report at all**, each for a different reason: * **TaxAgency** — stored as vendors on Desktop, so a deleted tax agency is indistinguishable from a deleted vendor. Undetectable. * **ExchangeRate** — rides the Currency list rather than having its own delete surface. * **Transfer** — `'Transfer'` isn't a valid `TxnDelType` in Desktop's deleted-transaction query, so transfer deletions can't be queried for. * **Term deletion detection covers standard terms only.** Desktop's deleted-list query reports standard payment terms; date-driven terms are outside that coverage. * **A deleted sales-tax item can show up under two entities.** If you request both `Item` and `TaxRate` in the same poll, a deleted sales-tax item's stub can appear in both slots — the same dual-surface behavior as the read path for that entity, not a bug in CDC. * **CompanyInfo and Preferences always over-deliver rather than window-filter.** Desktop doesn't version the company profile, so these two singleton entities return their current row whenever they're in `entities`, whether or not it changed since `changedSince`. Over-delivery beats a silent miss. ## Latency Each live entity in your `entities` list costs up to two sequential round trips to the agent — one for changed records, one for deleted stubs — and each round trip can take up to the agent's 60-second timeout. Worst case for N entities is **N × 2 × 60s**, all sequential; there's no per-endpoint deadline in v1. Poll with the entity list you actually need to sync, not every entity you might ever touch. ## See also * [Error codes](/reference/error-codes.html#cdc_invalid_entities) — `CDC_INVALID_ENTITIES`, `CDC_INVALID_CHANGED_SINCE`, `CDC_OVERFLOW` in full, with causes and fixes. * [Entity matrix — CDC](/compatibility/#cdc) — field-by-field support and the same boundaries in the compatibility contract's format. --- --- url: https://docs.tenkeybridge.com/guide/batch.md --- # Batch operations One `POST /v3/company/{realmId}/batch` call runs up to **30 operations** — creates, updates, deletes, and queries — and returns one response slot per item, keyed by your `bId`. ``` POST /v3/company/:realmId/batch ``` Same Bearer auth as every other endpoint. ## Request ```json { "BatchItemRequest": [ { "bId": "bid1", "operation": "create", "Customer": { "DisplayName": "Acme Corp" } }, { "bId": "bid2", "operation": "update", "Invoice": { "Id": "1F00-1", "SyncToken": "0", "CustomerMemo": { "value": "Thanks for your business!" } } }, { "bId": "bid3", "operation": "delete", "Invoice": { "Id": "1F00-2", "SyncToken": "0" } }, { "bId": "bid4", "Query": "SELECT * FROM SalesReceipt WHERE TotalAmt > '300.00'" } ] } ``` * Every item needs a unique `bId` — an opaque string echoed back on its slot. * An entity item is exactly one entity payload key plus `operation: create | update | delete`. * A query item is a `Query` string — the same QBO-SQL `/query` accepts, including STARTPOSITION/MAXRESULTS pagination. ## Response ```json { "BatchItemResponse": [ { "bId": "bid1", "Customer": { "Id": "80000001-1736100000", "SyncToken": "0", "DisplayName": "Acme Corp", "domain": "QBO", "sparse": false } }, { "bId": "bid2", "Fault": { "Error": [ { "Message": "Stale Object Error", "Detail": "QuickBooks Desktop: The provided edit sequence is out-of-date. See https://docs.tenkeybridge.com/reference/error-codes.html#5010", "code": "5010" } ], "type": "ValidationFault" }, "tkb": { "code": "5010", "causes": ["A user or another integration modified the record in QuickBooks after you read it."], "fixes": ["GET the record again, take the fresh SyncToken, and re-apply your change."], "docsUrl": "https://docs.tenkeybridge.com/reference/error-codes.html#5010" } }, { "bId": "bid3", "Invoice": { "Id": "1F00-2", "status": "Deleted", "domain": "QBO" } }, { "bId": "bid4", "QueryResponse": { "SalesReceipt": [ { "Id": "1F00-3", "SyncToken": "0", "TotalAmt": 425.0, "...": "full SalesReceipt payload" } ], "startPosition": 1, "maxResults": 1 } } ], "time": "2026-07-30T18:04:11.203Z" } ``` Slots come back in request order. A success slot carries exactly what the single-shot endpoint would have returned; a fault slot carries the same fault anatomy as everywhere else, `tkb` help block included. ## Batch is not a transaction Items execute **sequentially, independently**. If item 3 faults, items 1–2 have already happened and stay happened; items 4+ still run. QuickBooks Online's batch behaves the same way. Design idempotent retries per item, not per envelope. ## Structural errors fail the whole request If the envelope itself can't be understood — missing/duplicate `bId`, an item that isn't exactly one payload, an unknown `operation`, a `create` carrying an `Id` — the whole request returns `400 BATCH_INVALID_REQUEST` and **nothing executes**. Semantic problems (unsupported entity, Desktop validation errors) fault only their own item. ## Limits and honesty * **30 items max** (`BATCH_TOO_MANY_ITEMS`) — QBO's own cap, cloned. * **`optionsData` (e.g. `void`) is not supported** (`BATCH_UNSUPPORTED_OPTION`) — that item faults; the rest run. * **Latency**: items run one at a time through your QuickBooks Desktop machine; a 30-item batch of updates is up to 60 Desktop round trips. There is no per-request deadline — budget client timeouts accordingly. * **Agent offline mid-batch**: completed items keep their results; the remaining items return `AGENT_OFFLINE`/`AGENT_TIMEOUT` fault slots. If the agent was offline from the start, the whole request is a 503. ## See also * [Error codes](/reference/error-codes.html#batch_invalid_request) — `BATCH_INVALID_REQUEST`, `BATCH_TOO_MANY_ITEMS`, `BATCH_UNSUPPORTED_OPTION` in full, with causes and fixes. * [Change Data Capture](/guide/change-data-capture) — the other multi-item platform endpoint, with the same per-item fault-isolation model. --- --- url: https://docs.tenkeybridge.com/guide/reports.md --- # Reports `GET /v3/company/{realmId}/reports/{reportName}` serves five QuickBooks Online–compatible reports straight out of QuickBooks Desktop's own report engine. One call is one qbXML round trip; the response is QBO's `Header` / `Columns` / `Rows` shape, so existing QBO report code keeps working. Report names match **case-insensitively**, and the response echoes QBO's exact casing back in `Header.ReportName`. ## Supported reports | Report | qbXML request | Desktop report type | | --- | --- | --- | | `ProfitAndLoss` | `GeneralSummaryReportQueryRq` | `ProfitAndLossStandard` | | `BalanceSheet` | `GeneralSummaryReportQueryRq` | `BalanceSheetStandard` | | `TrialBalance` | `GeneralSummaryReportQueryRq` | `TrialBalance` | | `AgedReceivables` | `AgingReportQueryRq` | `ARAgingSummary` | | `AgedPayables` | `AgingReportQueryRq` | `APAgingSummary` | Any other QBO report name — `CashFlow`, the detail variants, the aging-detail variants — returns [`REPORT_UNKNOWN`](/reference/error-codes#report_unknown) with the five supported names in the fault detail. ## Parameters | Parameter | Reports | Notes | | --- | --- | --- | | `start_date`, `end_date` | ProfitAndLoss, BalanceSheet, TrialBalance | ISO `YYYY-MM-DD`. Must be sent **together**. | | `report_date` | AgedReceivables, AgedPayables | Single as-of date — aging reports are not a range. | | `date_macro` | all five | QBO date macros (`This Fiscal Year-to-date`, `Last Month`, …). Fiscal-relative on both sides, so they map 1:1. | | `accounting_method` | ProfitAndLoss, BalanceSheet, TrialBalance | `Cash` or `Accrual` → Desktop's `ReportBasis`. | | `summarize_column_by` | all five | Only `Total` is accepted in v1. | | `minorversion` | all five | Accepted and ignored, as everywhere else in TenkeyBridge. | **If you send no dates at all you get fiscal year-to-date** — the same window QuickBooks Online returns for a dateless call. This is deliberate: Desktop's own bare default is *month-to-date*, so TenkeyBridge always sends an explicit period rather than let the two platforms silently disagree about what "no dates" means. An undated aging report defaults to today, again matching QBO. ## Example ```bash curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.tenkeybridge.com/v3/company/$REALM/reports/ProfitAndLoss?start_date=2026-01-01&end_date=2026-12-31" ``` ```json { "Header": { "Time": "2026-07-30T12:00:00-07:00", "ReportName": "ProfitAndLoss", "ReportBasis": "Accrual", "StartPeriod": "2026-01-01", "EndPeriod": "2026-12-31", "SummarizeColumnsBy": "Total", "Currency": "USD", "Option": [{ "Name": "NoReportData", "Value": "false" }] }, "Columns": { "Column": [ { "ColTitle": "", "ColType": "Account" }, { "ColTitle": "Jan - Dec 26", "ColType": "Money" } ] }, "Rows": { "Row": [ { "Header": { "ColData": [{ "value": "Income" }, { "value": "" }] }, "Rows": { "Row": [ { "Header": { "ColData": [{ "value": "40100 · Construction Income" }, { "value": "" }] }, "Rows": { "Row": [ { "ColData": [{ "value": "40110 · Design Income" }, { "value": "36669.25" }], "type": "Data" } ] }, "Summary": { "ColData": [{ "value": "Total 40100 · Construction Income" }, { "value": "418731.65" }] }, "type": "Section" } ] }, "Summary": { "ColData": [{ "value": "Total Income" }, { "value": "425136.05" }] }, "type": "Section", "group": "Income" }, { "Summary": { "ColData": [{ "value": "Net Income" }, { "value": "127673.01" }] }, "type": "Section", "group": "NetIncome" } ] } } ``` (Trimmed — the real Rock Castle response is 8 root rows.) ## Reading the row tree qbXML hands back a **flat** stream of rows; QBO wants a tree. TenkeyBridge rebuilds it: * A **`Section`** has a `Header` (the section's name), nested `Rows`, and a `Summary` (its subtotal line). * A **summary-only `Section`** — `Summary` with no `Header` and no `Rows` — is a computed line like `Gross Profit`, `Net Income`, or a report's grand `TOTAL`. QBO emits these the same way. * A **data row** carries `ColData` only. Rows at the top level have no `type`; nested rows carry `"type": "Data"`. That asymmetry is QBO's, and it is reproduced verbatim. * Cells are always padded to the full column count, so `ColData[n]` always lines up with `Columns.Column[n]`. A cell Desktop had no value for is `{"value": ""}`. ## Honest boundaries * **Values are Desktop's, verbatim.** No rounding, no recomputation, no client-side arithmetic. If Desktop says `127673.01`, the API says `127673.01`. * **Column titles are Desktop's, verbatim.** Desktop's aging buckets read `Current`, `1 - 30`, `31 - 60`, `61 - 90`, `> 90`, `TOTAL` where QBO writes `91 and over` and `Total`; a period column reads `Jan - Dec 26` where QBO writes `Total`. Aging buckets *must* stay Desktop's — a company file can be configured with entirely different buckets, so printing QBO's labels over them would be a lie — and the same rule is applied to every column for consistency. QBO's `MetaData.ColKey` has no Desktop equivalent and is not invented. * **Aging buckets are company-file configuration, not request parameters.** `aging_period`, `num_periods`, and `aging_method` all fault with [`REPORT_UNSUPPORTED_OPTION`](/reference/error-codes#report_unsupported_option). Change them in QuickBooks under **Edit > Preferences > Reports & Graphs**; whatever the file is configured with comes back as the report's columns. * **`accounting_method` is rejected on the aging reports.** qbXML's `AgingReportQueryRq` has no `ReportBasis` element, so there is no honest way to produce a cash-basis aging report. * **Key on section header text, not on `group`.** `group` is emitted only where the section name confidently maps to a QBO group (`Income`, `COGS`, `Expenses`, `TotalAssets`, `GrandTotal`, …). Desktop-specific sections — anything named after a numbered account, for instance — carry no `group` at all, exactly as QBO omits it for sections it does not recognise. * **`Header.Option` carries no `AccountingStandard`.** QBO reports GAAP; Desktop never states an accounting standard, so it is omitted rather than assumed. * **Column and filter customisation is v2.** `columns`, `customer`, `vendor`, `item`, `class`, `department`, `qzurl`, and `adjusted_gain_loss` all fault loudly. Fetch the standard report and narrow it client-side. * **Report calls are not batchable.** A `Reports` item inside a [batch](/guide/batch) envelope faults per-item pointing back at this endpoint — reports are a platform surface, not an entity, so the batch envelope has nothing to execute. ## See also * [Compatibility matrix — Reports](https://docs.tenkeybridge.com/compatibility/#reports) * [`REPORT_UNKNOWN`](/reference/error-codes#report_unknown) · [`REPORT_UNSUPPORTED_OPTION`](/reference/error-codes#report_unsupported_option) · [`REPORT_INVALID_DATE`](/reference/error-codes#report_invalid_date) * [Batch operations](/guide/batch) — and why report calls stay out of the envelope --- --- url: https://docs.tenkeybridge.com/guide/ai-quickstart.md --- # Build with an AI agent Pointing a coding agent — Claude Code, Codex, Cursor, or anything that can fetch a URL — at TenkeyBridge is a first-class way to integrate. The entire documentation site is published in agent-readable form: * [`/llms.txt`](https://docs.tenkeybridge.com/llms.txt) — index with one-line summaries * [`/llms-full.txt`](https://docs.tenkeybridge.com/llms-full.txt) — every page in one file (small enough for any modern context window) ## Starting prompt Get [credentials](/guide/authentication) first, fill in the placeholders, then paste this into your agent from inside your app's repository: ```text You are integrating this application with TenkeyBridge, a QuickBooks Online-compatible REST API for QuickBooks Desktop & Enterprise. The API base URL is https://api.tenkeybridge.com and it is live. Before writing any code, fetch https://docs.tenkeybridge.com/llms-full.txt and read it — it is the entire documentation site: quickstart, authentication (OAuth2), the entity-by-entity compatibility contract, and error codes. Credentials for this integration: - client_id: - client_secret: - registered redirect URI: - QuickBooks company realm ID: (pass as `realm_id` on the authorize URL) Working rules: 1. If this app already integrates with QuickBooks Online, the core change is the base URL and the OAuth authorize/token URLs — reuse the existing QBO code paths. 2. Before integrating each entity, check its section of the compatibility contract and adapt to anything marked partial. 3. When an API call returns a Fault, look up its error code in the error-codes reference before retrying or changing the request. 4. If the documentation is unclear, wrong, or missing something you need, STOP and report it to the developer. Do not guess or work around documentation gaps. ``` ## Tool notes * **Claude Code** — paste the prompt into a session, or commit it to `CLAUDE.md` so every session starts with it. * **Codex** — put it in `AGENTS.md` at the repo root. * **Cursor** — add it as a project rule (`.cursor/rules/`). ## MCP An MCP server (docs search + credential/portal actions) is planned alongside the developer portal. Until then, `llms-full.txt` over HTTPS is the supported surface — no install step required. --- --- url: https://docs.tenkeybridge.com/guide/agent-install.md --- # Edge agent — install & setup ::: info Production path The edge agent is the production path for Desktop company files. Credentials and the agent token are issued during onboarding (see [Authentication](/guide/authentication)); the signed installer is delivered with your onboarding package until self-serve downloads ship. ::: The TenkeyBridge edge agent is a small Windows program that sits next to your QuickBooks Desktop or Enterprise install. It makes one outbound, encrypted WebSocket connection to the TenkeyBridge gateway and executes the requests your QBO-style API calls translate into. Nothing on your machine listens for inbound connections. ## Requirements * Windows 10/11 or Windows Server, 64-bit * QuickBooks Desktop or Enterprise 2023 R16 or later (Enterprise 24 recommended) * A Windows user session that can stay logged in (see [Unattended operation](#unattended-operation)) * No admin rights needed — the installer is per-user. The agent is self-contained — no .NET install, no QuickBooks SDK install. The COM interface it uses ships with QuickBooks itself. ## Install (recommended) 1. Download `TenkeyBridge-Agent--win-x64.msi` from your onboarding package. 2. Double-click it. It installs for **your Windows user only** — no admin prompt, no UAC. It goes to `%LOCALAPPDATA%\Programs\TenkeyBridge\`, adds that folder to your `PATH`, and adds a **TenkeyBridge Agent Setup** entry to the Start menu. 3. Open a new terminal and run: ``` tenkeybridge-agent setup ``` It asks for your **agent token**, the **company file** path (optional), and whether to **start the agent when you sign in**, then offers to run the QuickBooks authorization. That's it. The Start menu also has a **TenkeyBridge Agent Setup** entry that opens the same command as a convenience — but it's a console program, so the window it opens closes as soon as setup finishes, taking any error or summary text with it. Prefer running it from a terminal so you can actually read the output. ::: tip Re-run any time `setup` is safe to re-run; it shows current values as defaults and keeps anything else already in `appsettings.json`. `tenkeybridge-agent setup --show` prints the current configuration with the token masked. ::: ### `setup` options (scripted / silent) All of `setup`'s prompts have a matching flag, so it can run unattended end to end: | Flag | What it does | |---|---| | `--token ` | Agent token from your TenkeyBridge onboarding | | `--company-file ` | Full path to the `.qbw` file. On a re-run, **omitting** this flag keeps whatever is already configured; pass `--company-file=""` (empty) to clear it back to using whatever QuickBooks has open | | `--gateway-url ` | Gateway WebSocket URL (default `wss://api.tenkeybridge.com/agent`) — only asked for interactively with `--advanced` | | `--advanced` | Also ask for the gateway URL interactively | | `--startup on\|off` | Create or remove the sign-in Startup shortcut (default: ask) | | `--grant` | Run the QuickBooks authorization (same as `tenkeybridge-agent grant`) | | `--yes`, `-y` | Accept defaults, ask nothing (Startup shortcut ends up **on** unless `--startup off` is also given; the grant only runs if `--grant` is also given) | | `--show` | Print the current configuration (token masked) and exit | `setup` exits `0` on success. It exits `2` when a value you passed is invalid (`--company-file` must exist and end in `.qbw`; `--gateway-url` must be `ws://` or `wss://`), when a required value is missing and there's no terminal to ask on (for example a scripted run with no `--token` and no existing `appsettings.json`), or when the existing `appsettings.json` isn't valid JSON. `setup --show` exits `1` if the agent hasn't been configured yet. Fully unattended example: ``` tenkeybridge-agent setup --token tkba_exampleToken123 --company-file "C:\Company\ExampleCo.qbw" --startup on --yes ``` ### What the installer does / where things live | Path | Owned by | Notes | |---|---|---| | `%LOCALAPPDATA%\Programs\TenkeyBridge\tenkeybridge-agent.exe` | Installer | The agent binary | | `%LOCALAPPDATA%\Programs\TenkeyBridge\appsettings.example.json` | Installer | Reference config; not read by the agent | | `%LOCALAPPDATA%\Programs\TenkeyBridge\appsettings.json` | `setup` | Created/updated by `setup`; **install, upgrade, and uninstall never touch it** | | `%LOCALAPPDATA%\Programs\TenkeyBridge\agent.log` | the agent | `setup` only writes this path into `appsettings.json`; the file itself is created the first time the agent (or `grant`) actually runs. **Never touched by install, upgrade, or uninstall** | | `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\TenkeyBridge Agent.lnk` | `setup` | The sign-in Startup shortcut, created when you answer Yes (or pass `--startup on`) | | Start menu → **TenkeyBridge Agent Setup** | Installer | Runs `tenkeybridge-agent.exe setup` | | `PATH` (`HKCU\Environment`) | Installer | So `tenkeybridge-agent` works from any terminal | Uninstalling (Settings → Apps → TenkeyBridge Agent) removes the program files, the Start-menu shortcut, the sign-in Startup shortcut (if present), and the `PATH` entry only. Your `appsettings.json` and `agent.log` are left in place, so reinstalling picks your configuration back up without re-running `setup`. ### Verifying the download Windows shows the publisher name when you run the MSI. **Empire Innovations, LLC** is the genuine signer. Until the trusted certificate ships, builds are signed with a development certificate instead — Windows will show an "unknown publisher" warning (and SmartScreen may flag it) even though the file is legitimate. You'll receive a build signed with the trusted certificate through your onboarding package once that lands; there's nothing to do differently in the meantime beyond expecting the warning. A `SHA256SUMS` file ships alongside the installer and the exe. To confirm your download matches, run in PowerShell: ```powershell Get-FileHash .\TenkeyBridge-Agent--win-x64.msi -Algorithm SHA256 ``` and compare the hash against the matching line in `SHA256SUMS`. ### Silent / scripted install ``` msiexec /i TenkeyBridge-Agent--win-x64.msi /qn ``` then run `setup` with flags, as in the example above. To upgrade, install the newer MSI over the old one — the per-user `MajorUpgrade` rule handles removing the previous version. `winget validate`-ready manifests live in the repo (`apps/agent/installer/winget/`); a winget community listing (so `winget install Empire.TenkeyBridgeAgent` works without a direct download link) is planned but not live yet. The checked-in manifests carry a placeholder `InstallerUrl` and an all-zero `InstallerSha256` — they can't be submitted to the community repo until a real, public MSI download exists to point at. ## Configuration reference | Key | Required | Default | What it does | |---|---|---|---| | `GatewayUrl` | Yes | — | WebSocket URL of the TenkeyBridge gateway | | `AgentToken` | Yes | — | Token that authenticates this agent to the gateway | | `AgentId` | No | machine name | Identifier for this agent, sent to the gateway in the hello frame | | `CompanyFile` | No | empty | Full path to the `.qbw` company file; empty uses whatever file QuickBooks has open — set it explicitly for unattended use | | `AppName` | No | `TenkeyBridge Agent` | Application name passed to QuickBooks (`OpenConnection2`); this is the name QuickBooks shows in the integrated-application grant | | `ShutdownDrainSeconds` | No | `10` | How long Ctrl-C waits for an in-flight request to finish before exiting | | `LogFile` | No | empty | Path to a log file written directly with autoflush (in addition to the console). Durable for unattended installs; empty = console only. `setup` sets this to `agent.log` beside the exe unless you've already set it | Every key can also be set as an environment variable prefixed with `TENKEYBRIDGE_` (e.g. `TENKEYBRIDGE_AGENTTOKEN`); environment variables override `appsettings.json`. `setup` requires a token and always writes the one you give it into `appsettings.json` — there's no flag to skip that. If you'd rather the token not sit in the config file, hand-write `appsettings.json` from the [Configuration reference](#configuration-reference) without an `AgentToken` key (or run `setup` once and then delete the key afterwards), and set `TENKEYBRIDGE_AGENTTOKEN` in the environment of the session that runs the agent — environment variables always win. ## Authorize the agent in QuickBooks (one time) 1. Open QuickBooks **as Admin**, with your company file open. 2. Run `tenkeybridge-agent.exe grant` from the agent folder — or answer Yes when `setup` asks "Authorize in QuickBooks now?", or pass `setup --grant` to do it non-interactively as part of a scripted setup. 3. QuickBooks shows its integrated-application dialog. Choose **"Yes, always; allow access even if QuickBooks is not running"** and select the user the agent should log in as. 4. The command prints QuickBooks' host information and exits 0. Done. This grant is stored per company file by QuickBooks (Edit → Preferences → Integrated Applications), where you can revoke it at any time. QuickBooks identifies an application partly by its code-signing certificate, so moving from an unsigned build to a signed one (or between signing certificates) makes it prompt once more. Grant it again the same way. ::: warning Run QuickBooks and the agent at the same Windows privilege level QuickBooks' COM interface only lets the agent attach when both processes run at the **same Windows integrity level**. If QuickBooks is running **elevated** (as Administrator) but the agent is not, the connection silently fails — QuickBooks tries to launch a *second* instance and you get `QB_CONNECTION_ERROR — Could not start QuickBooks`, which looks identical to other connection problems. The simple rule: **do not run QuickBooks as Administrator.** Launch it normally (non-elevated) and run the agent normally too — they then match automatically. This is also Intuit's own recommendation. In the unattended setup below, the agent opens its own QuickBooks session, so the levels always match. ::: ## Unattended operation QuickBooks' automation interface cannot run from a Windows service. The supported pattern is: 1. A dedicated Windows user set to **log on automatically** at boot. 2. Run `tenkeybridge-agent setup --startup on` (or just answer Yes when `setup` asks) so the agent starts at sign-in. This replaces the old approach of hand-dropping a shortcut into `shell:startup` — `setup` creates that shortcut for you. 3. QuickBooks stays **closed**; the agent opens its own session with the company file using the grant above. The machine can be locked; the session must stay logged in. Running on a **terminal server / hosted QuickBooks** with several users and multi-user company files? See [Hosted & multi-user QuickBooks](/guide/agent-hosted). ::: tip Durable logs for unattended installs `setup` already sets `LogFile` to `agent.log` beside the exe, so the agent writes every log line straight to that file with autoflush — independent of stdout, so it survives a redirected console and is not lost if the process is force-killed. Tail it live with `Get-Content -Wait `. Prefer this over redirecting stdout to a file. ::: ## Manual install (hosted / RDS admins) Some setups — hosted QuickBooks, RDS terminal servers, one Windows session per company file — call for installing without the per-user MSI's PATH/Start-menu integration, or for installing several independent copies on one machine. For those, use the single-file exe `tenkeybridge-agent--win-x64.exe`, also included in your onboarding package: 1. Put it in a folder you own, e.g. `C:\TenkeyBridge\`. 2. Run `tenkeybridge-agent.exe setup` from that folder — it writes `appsettings.json` next to the exe, exactly like the MSI path does. 3. Alternatively, skip `setup` and hand-write `appsettings.json` yourself from the [Configuration reference](#configuration-reference) above. The QuickBooks authorization step is the same either way — see [Authorize the agent in QuickBooks](#authorize-the-agent-in-quickbooks-one-time) above. For the full pattern of one Windows session per company file, scheduled-task autostart, and integrity-level notes specific to shared servers, see [Hosted & multi-user QuickBooks](/guide/agent-hosted). ## Troubleshooting | Error code | Meaning | Fix | |---|---|---| | `COM_UNAVAILABLE` | QuickBooks Desktop isn't installed on this machine | Install QuickBooks on the machine the agent runs on | | `QB_CONNECTION_ERROR` | QuickBooks refused the connection or session | Re-run the grant; check the company file path; make sure QuickBooks isn't open with a *different* company file | | `QB_REQUEST_ERROR` | A request failed twice against a fresh session | Check QuickBooks is healthy; the agent log has the underlying message | | `setup` says company file not found | The path doesn't exist, or doesn't end in `.qbw` | Check the path in QuickBooks: File → Open Previous Company shows the file it last had open; the file must end in `.qbw` | | The agent process disappears the moment a request reaches QuickBooks (Event Viewer logs `0xc0000409` / `BEX64` against `tenkeybridge-agent.exe`) | The exe's code-signing certificate does not chain to a root this machine trusts — QuickBooks validates the caller's chain before opening a session | Run `Get-AuthenticodeSignature .\tenkeybridge-agent.exe`; if `Status` isn't `Valid`, reinstall from the official signed download. Only self-built or internally re-signed agents hit this | The agent reconnects to the gateway automatically with backoff — a dropped internet connection heals on its own. --- --- url: https://docs.tenkeybridge.com/guide/agent-hosted.md --- # Hosted & multi-user QuickBooks (RDS / terminal server) This page covers running the edge agent where QuickBooks is **hosted** — a Windows Server with Remote Desktop Services (terminal server), an application hosting provider, or any box where several people are logged in at once and the company file is open in **multi-user mode**. Read [Edge agent install](/guide/agent-install) first; everything there still applies (hosted admins usually use the [manual install](/guide/agent-install#manual-install-hosted-rds-admins) — one exe per user session). This page only adds what changes when the machine is shared. ::: tip Validated configuration Windows Server 2022 with the RDS Session Host role, QuickBooks Enterprise 24 as the database server *and* client, three company files hosted in multi-user mode, two users logged in over RDP with different files open, the agent running as a **standard (non-admin) user** — verified end-to-end (reads, creates, and sparse updates) on 2026-08-15. Requires an agent build from that date or later. ::: ## The one rule: the agent lives in a user's session QuickBooks' automation interface (COM) is **per Windows session**. An agent can only talk to a QuickBooks instance running in the *same* logged-in session as itself — it cannot see, and is not affected by, QuickBooks windows other users have open in their own sessions. That gives you a simple model: | You want | Do this | |---|---| | The API to reach **one** company file | Log one user in (say `qbagent`), open — or let the agent open — that file in that session, run one agent there | | The API to reach **several** company files on the same server | One Windows session **per company file**, each with its own agent, its own agent token, and its own realm | | Other staff to keep using QuickBooks interactively | Nothing special — their sessions are independent of the agent's | Everything the agent needs is in that session; it does not require the QuickBooks database server manager to be running *in* its session (that service runs in session 0 and serves everyone). ## Multi-user mode Company files hosted in multi-user mode work as-is. The agent opens its session in "don't care" mode, so it joins whichever mode the file is currently in — you do **not** need to switch the file to single-user for the agent, and users can keep working in it while the API reads and writes. ::: warning Older agent builds Agent builds before 2026-08-15 requested a *single-user* session and failed against a multi-user file with `QB_CONNECTION_ERROR — The QuickBooks company data file is currently open in a mode other than the one specified by your application`. Update the agent if you see that message on a hosted server. ::: ## Setting it up on a shared server For each company file you want on the API: 1. **Create (or pick) a Windows user** for that file, e.g. `qbagent-exampleco`. A standard user is fine — the agent needs no admin rights. Add it to *Remote Desktop Users* if you'll set it up over RDP. 2. **Log that user in** (RDP is fine) and put the single-file `tenkeybridge-agent--win-x64.exe` in a folder the user owns, e.g. `C:\Users\qbagent-exampleco\TenkeyBridge\`. 3. **Configure** — set `CompanyFile` to that user's file so the agent attaches to the right one even before anyone opens it. The fastest way is one command in that session: ``` tenkeybridge-agent setup --token tkba_exampleToken123 --company-file "C:\QBCompanyFiles\ExampleCo.qbw" --startup on --yes ``` That's equivalent to writing `appsettings.json` by hand: ```json { "GatewayUrl": "wss://api.tenkeybridge.com/agent", "CompanyFile": "C:\\QBCompanyFiles\\ExampleCo.qbw", "LogFile": "C:\\Users\\qbagent-exampleco\\TenkeyBridge\\agent.log" } ``` `setup` always writes the token you give it into `appsettings.json`. If you'd rather it not sit in the file at all — a shared server means other users may be able to read your folder — hand-write `appsettings.json` without an `AgentToken` key and set `TENKEYBRIDGE_AGENTTOKEN` in the environment instead; the environment variable overrides the file either way. 4. **Grant access once**, in that session, with the file open in QuickBooks: `tenkeybridge-agent.exe grant` → *"Yes, always; allow access even if QuickBooks is not running"*. 5. **Start the agent at logon** — either the per-user Startup folder (Win+R → `shell:startup` → shortcut to the exe), or a logon-triggered scheduled task, which survives the user closing the console window: ```powershell # run in that user's session (non-elevated); prompts for the user's password schtasks /Create /TN tkb-agent /TR "C:\Users\qbagent-exampleco\TenkeyBridge\run-agent.cmd" ` /SC ONLOGON /RU qbagent-exampleco /IT /RL LIMITED ``` `/IT` (interactive) and `/RL LIMITED` (not elevated) are what make the task land in the user's desktop session at the same privilege level as QuickBooks — both are required for COM to attach. 6. Keep that user **logged in** (disconnected is fine — do not *sign out*). Set the RDS session limits so idle/disconnected sessions for the agent user are never ended. Repeat per company file with a different user, token, and realm. ## Integrity levels on a terminal server The same-privilege rule from the install guide holds: QuickBooks and the agent in a given session must both be elevated or both not. On RDS this is easy — users are normally standard users, nothing runs elevated, and the levels match by default. Only the QuickBooks instance **in the agent's own session** matters; an administrator running QuickBooks elevated in *another* session does not affect your agent. ::: warning Older agent builds Before 2026-08-15 the agent's integrity check looked at every `QBW.exe` on the machine and could report `QB_INTEGRITY_MISMATCH — QuickBooks (QBW.exe, PID …) is running elevated` because of *another user's* QuickBooks. Update the agent if you see that on a shared server. ::: ## Application hosting providers If your QuickBooks is hosted by a provider (you RDP into their server), the same model applies and you usually don't need the provider's help: * No installer, no admin rights, no SDK — unzip and run in your own session. * Company files typically live on a UNC or mapped path the provider gives you (e.g. `Q:\ExampleCo.qbw`); use that as `CompanyFile`. * If scheduled tasks are locked down, the Startup-folder shortcut works. * Your session must stay logged in; ask the provider about idle-logoff policies for a dedicated automation user. ## Troubleshooting on shared servers | Symptom | Likely cause | Fix | |---|---|---| | `QB_CONNECTION_ERROR … open in a mode other than the one specified` | Agent build older than 2026-08-15 against a multi-user file | Update the agent | | `QB_INTEGRITY_MISMATCH` naming a PID that isn't your QuickBooks | Agent build older than 2026-08-15 | Update the agent | | Agent connects to the gateway but every request is `QB_CONNECTION_ERROR` | Agent is not in the same session as the QuickBooks it should use, or was started by a service / bare SSH shell (session 0) | Start it from the user's interactive session (Startup folder or `schtasks … /IT /RL LIMITED`) | | Requests hit the wrong company file | `CompanyFile` empty and the user has a different file open | Set `CompanyFile` explicitly | | Agent stops after the user's RDP window is closed | The user *signed out* instead of disconnecting, or an RDS policy ended the session | Disconnect only; exempt the agent user from session-end policies | The gateway's [error reference](/reference/error-codes) has the full list of codes. --- --- url: https://docs.tenkeybridge.com/guide/admin-api.md --- # Admin API `/admin/v1` is TenkeyBridge's **provisioning control plane** — the API you automate against to create realms, register OAuth clients, issue agent tokens, and manage API keys, without emailing anyone. It's versioned independently of the QBO-compatible surface and public: integrators are expected to script against it. It is deliberately **not** the QBO surface. Errors here look nothing like a QBO `Fault` and nothing like the RFC 6749 `error_description` shape `/oauth2/v1` uses — see [Errors](#errors) below. Confusing the two shapes would misrepresent what a caller is talking to. ::: tip Where this fits There's also a self-serve **[portal UI](/guide/portal)** — a browser in front of exactly these endpoints, for anyone who'd rather click through sign-up, org/realm/client setup, and API-key minting than script it. ::: ## Authentication Two credentials are accepted, resolved in this order: 1. **An API key**, in the `x-api-key` header. Minted by `POST /admin/v1/api-keys` (session only — see [Things that are shown once](#things-that-are-shown-once)), prefixed `tkb_`, scoped to exactly one organization for its whole life. 2. **A session cookie** — what the [portal](/guide/portal) sends, issued by signing in at `/admin/v1/auth`. During early access, the **entire** `/admin/v1` surface also sits behind HTTP Basic auth (`ADMIN_GATE_USER`/`ADMIN_GATE_PASSWORD`) as a stopgap until the portal ships its own account-signup flow. A valid `x-api-key` satisfies this Basic gate on its own — no `-u` flag needed — **except** in front of `/admin/v1/auth/*` (sign-in, sign-up, OAuth callbacks, magic links), which stays Basic-gated unconditionally. An API key must never be a way into the sign-up surface this gate exists to hide. An API key **cannot mint another API key** — `POST /admin/v1/api-keys` and `DELETE /admin/v1/api-keys/:id` require a session. A leaked key that could re-mint itself would outlive any attempt to revoke it. API keys are also rate-limited: **120 requests/minute**, per key. Exceeding it returns `429` with `{ "error": "rate_limited", "message": "..." }`. ## Organizations and roles Every realm, OAuth client, agent token, and API key belongs to an **organization**, never to an individual user. A user reaches an org through membership, at one of three roles: | Role | Can do | | --- | --- | | `owner` | Everything | | `admin` | Create/revoke realms, clients, and agent tokens; invite members; issue/revoke API keys | | `member` | Read-only: list realms, clients, members, API keys | **An API key's permissions are its creator's *current* role**, re-resolved from the org's member table on every request — not baked into the key at issue time. Demote or remove the creator from the org and every key they made is de-fanged immediately, with no separate revocation step. `GET /admin/v1/orgs` lists the organizations the caller belongs to, with their role in each: ```bash curl -s https://api.tenkeybridge.com/admin/v1/orgs \ -H "x-api-key: $TKB_API_KEY" ``` ```json { "orgs": [ { "id": "org_abc123", "name": "ExampleCo", "slug": "exampleco", "role": "owner" } ] } ``` An API-key principal only ever sees the one org its key is scoped to, even if the key's creator belongs to others. `GET /admin/v1/orgs/:orgId/members` (any member) lists the org's members: ```bash curl -s https://api.tenkeybridge.com/admin/v1/orgs/org_abc123/members \ -H "x-api-key: $TKB_API_KEY" ``` ```json { "members": [ { "id": "mem_1", "userId": "usr_1", "email": "owner@exampleco.example", "name": "Pat Owner", "role": "owner", "createdAt": "2026-08-01T00:00:00.000Z" } ] } ``` ## Endpoints Every endpoint below requires org membership at the listed role (`orgId` is either a body field on create, a query parameter on list, or resolved server-side from the path resource on everything else). ### Realms **`POST /admin/v1/realms`** (`admin`) — create a realm (tenant) under an org. ```bash curl -s -X POST https://api.tenkeybridge.com/admin/v1/realms \ -H "x-api-key: $TKB_API_KEY" -H "content-type: application/json" \ -d '{"orgId": "org_abc123", "name": "Acme Plumbing"}' ``` ```json { "realm": { "id": "9130351234567890", "name": "Acme Plumbing", "orgId": "org_abc123", "createdAt": "2026-08-10T00:00:00.000Z" } } ``` `realm.id` is a QBO-shaped realm id (`[1-9][0-9]{15}`) — the same `realmId` you'll pass to `/oauth2/v1/authorize` and use in every `/v3/company/{realmId}/...` call. **`GET /admin/v1/realms?orgId=...`** (`member`) — list an org's realms. **`GET /admin/v1/realms/:realmId`** (`member`) — realm detail, including agent connectivity and its agent tokens (never the token secrets themselves): ```bash curl -s https://api.tenkeybridge.com/admin/v1/realms/9130351234567890 \ -H "x-api-key: $TKB_API_KEY" ``` ```json { "realm": { "id": "9130351234567890", "name": "Acme Plumbing", "orgId": "org_abc123", "createdAt": "2026-08-10T00:00:00.000Z" }, "agent": { "online": true }, "agentTokens": [ { "id": "tok_1", "label": "front office", "createdAt": "2026-08-10T00:00:00.000Z", "lastUsedAt": null, "revoked": false, "revokedAt": null } ] } ``` **`POST /admin/v1/realms/:realmId/agent-tokens`** (`admin`, optional `{ "label": "..." }` body) — issue an agent token for that realm, for the edge agent's `appsettings.json`. ```bash curl -s -X POST https://api.tenkeybridge.com/admin/v1/realms/9130351234567890/agent-tokens \ -H "x-api-key: $TKB_API_KEY" -H "content-type: application/json" \ -d '{"label": "front office"}' ``` ```json { "id": "tok_1", "label": "front office", "token": "tkba_..." } ``` **`DELETE /admin/v1/agent-tokens/:tokenId`** (`admin`) — revoke an agent token. `204` on success. This is a top-level route, not nested under a realm — you don't need to know a token's realm to revoke it by id. **Realms cannot be deleted in v1** — `DELETE /admin/v1/realms/:realmId` 404s. A QuickBooks company is real-world state (invoices, customers, years of history) that outlives any particular integration; there is no safe "delete a company" operation to expose here, and nothing else in the product needs one yet. ### OAuth clients **`POST /admin/v1/clients`** (`admin`) — register an OAuth client. ```bash curl -s -X POST https://api.tenkeybridge.com/admin/v1/clients \ -H "x-api-key: $TKB_API_KEY" -H "content-type: application/json" \ -d '{"orgId": "org_abc123", "name": "ExampleCo", "redirectUris": ["https://app.exampleco.example/cb"]}' ``` ```json { "client": { "id": "tkbc_...", "name": "ExampleCo", "redirectUris": ["https://app.exampleco.example/cb"], "orgId": "org_abc123", "createdAt": "2026-08-10T00:00:00.000Z" }, "clientSecret": "tkbs_..." } ``` `redirectUris` is a non-empty array (max 10) of absolute URLs, each validated strictly: **https required**, except for loopback hosts (`localhost`, `127.0.0.1`, `[::1]`), which may use plain http for local development; no userinfo; no fragment; no `*` wildcard; and the value must already be in normalized form (the exact string `new URL(...)` would produce — no default port, no trailing whitespace, no embedded tab/newline) since the authorize-time matcher compares byte-for-byte. A rejected URI returns `400 invalid_request` naming what's wrong. **`GET /admin/v1/clients?orgId=...`** (`member`) — list an org's clients. Never includes `clientSecret`. **`PATCH /admin/v1/clients/:clientId`** (`admin`, `{ "redirectUris": [...] }`) — replace a client's redirect URIs (same validation as create). This is a full replacement, not a merge. **`DELETE /admin/v1/clients/:clientId`** (`admin`) — delete an OAuth client. `204` on success. **This deletes the client's authorization codes and access/refresh tokens in the same transaction** — every company connected through that client is disconnected immediately. There's no cascade-free "just deregister the app" option: a token whose issuing client no longer exists is not a credential TenkeyBridge is willing to keep honoring. ### API keys **`POST /admin/v1/api-keys`** (`admin`, session only — an API key cannot call this). ```bash curl -s -X POST https://api.tenkeybridge.com/admin/v1/api-keys \ -H "cookie: $SESSION_COOKIE" -H "content-type: application/json" \ -d '{"orgId": "org_abc123", "name": "provisioning"}' ``` ```json { "id": "key_1", "name": "provisioning", "orgId": "org_abc123", "key": "tkb_...", "createdAt": "2026-08-10T00:00:00.000Z" } ``` `name` is at most 32 characters. `orgId` is baked into the key's metadata for its whole life — a key is never re-scoped to a different org. **`GET /admin/v1/api-keys?orgId=...`** (`member`) — list an org's keys. Never includes the key value; a key made by someone who has since left the org is still listed (but no longer authorizes anything — see above) until it's explicitly revoked. **`DELETE /admin/v1/api-keys/:id`** (`admin`, session only) — revoke a key immediately. `204` on success. ## Errors Every error on this surface is the same two-field shape: ```json { "error": "unauthenticated", "message": "..." } ``` | `error` | HTTP status | Meaning | | --- | --- | --- | | `unauthenticated` | 401 | No session and no valid `x-api-key`. | | `forbidden` | 403 | Authenticated, but your role in this org is too low for the action. | | `invalid_request` | 400 | Malformed body, missing required field, or a redirect URI that fails validation. | | `not_found` | 404 | No such resource — **or** a resource that belongs to an organization you're not a member of. | | `rate_limited` | 429 | An API key crossed 120 requests/minute. | | `internal_error` | 500 | Unexpected server error. | **A resource belonging to another organization and a resource that doesn't exist return the byte-identical `not_found`.** This is deliberate: a distinguishable 403 ("yes, that realm exists, you just can't see it") would let a caller enumerate which realm ids, client ids, org ids, and agent-token ids exist by brute-forcing responses. `not_found` is the one answer that confirms nothing. ## Things that are shown once Three kinds of secret are returned exactly once, at creation, and never again: * **OAuth client secrets** (`clientSecret` on `POST /clients`) * **Agent tokens** (`token` on `POST /realms/:realmId/agent-tokens`) * **API keys** (`key` on `POST /api-keys`) Every list/detail endpoint that returns the parent resource omits the secret entirely — there is no endpoint, anywhere on this surface, that can hand one back to you a second time. Store it when it's issued, or issue a new one. --- --- url: https://docs.tenkeybridge.com/guide/portal.md --- # Developer portal The portal is a browser UI over [the Admin API](/guide/admin-api) — every action below is a click on the exact same `/admin/v1` endpoints you could otherwise script against. If you'd rather automate provisioning than click through it, the Admin API doc is the complete reference; this guide walks the same ground by hand. ::: tip Where this fits The portal is at `https://api.tenkeybridge.com/portal` (exactly `/portal`, no trailing slash). During early access the entire portal — and the `/admin/v1` surface it calls — sits behind an HTTP Basic prompt (your browser will ask for it once per session) as a stopgap until sign-up is public. Ask for the early-access credentials if you don't have them yet. ::: ## Sign in Go to `/portal/sign-in`. At launch the way in is a **magic link**: enter your email, click **Send magic link**, and open the link from the email that arrives — it signs you in and returns you to the page you started from. There's no password to set. Google and GitHub buttons render on this page once those providers are configured on the gateway; they aren't at launch, so magic link is the path to use for now. ## Create an organization Every realm, OAuth client, agent token, and API key in TenkeyBridge belongs to an **organization**, not to you personally. The first time you sign in, `/portal` shows a first-run form instead of an org list: give it a name (say, "ExampleCo") and it derives a URL-safe slug from it automatically (`exampleco`) — edit the slug field directly if you want something else. Submitting takes you back to the dashboard, now showing your new org as a card. Signing up this way makes you the org's `owner`. Roles matter throughout the rest of this guide — see [Organizations and roles](/guide/admin-api#organizations-and-roles) for what `owner`, `admin`, and `member` can each do. Click an org's card to enter it. Every page from here on is scoped to that one organization. ## Create a realm and connect the agent Inside an org, open **Realms** and click **Create realm** (visible to `admin` and `owner`; a `member` can view the list but not create). Give it a name — for a real company this is usually the QuickBooks company's name, e.g. "ExampleCo Plumbing" — and it appears in the table with a generated realm id. Click through to a realm's detail page and you'll see: * The realm id, with a copy button — this is the `realmId` you'll use in `/oauth2/v1/authorize` and every `/v3/company/{realmId}/...` call. * An **Agent online** / **Agent offline** badge, live from the gateway's connection to that realm's edge agent. * A table of agent tokens issued so far, with an issue-token form below it. Click **Issue token**, optionally labeling it (e.g. "front office"), and submit. The token is shown to you **exactly once**, in a modal, alongside a ready-to-paste config snippet: ```json { "GatewayUrl": "wss://api.tenkeybridge.com/agent", "AgentToken": "tkba_xxxxxxxx…", "CompanyFile": "" } ``` Copy the token (or the whole snippet) before dismissing the modal — there is no way to see it again from the portal; issue a new one if you lose it. Paste it into the agent's `appsettings.json` as `AgentToken`, fill in `CompanyFile` with the path to your `.qbw` file (or leave it empty to use whatever company file QuickBooks has open), and follow the rest of the install from [Edge agent — install & setup](/guide/agent-install). Once the agent connects, the badge on this page flips to **Agent online**. Revoking a token is a button on its row (`admin`/`owner` only) — the agent using it stops authenticating on its next connection attempt. ## Register an OAuth client Open **OAuth Clients** and click **Register client** (`admin`/`owner`). Give it a name and one or more redirect URIs — the same validation the Admin API enforces applies here (`https://...`, or plain `http://localhost` for local dev; see [the client rules in the Admin API doc](/guide/admin-api#oauth-clients)) — and submit. The client secret is shown **once**, in the same kind of modal as the agent token: copy it into your app's OAuth configuration before dismissing. The client list itself never shows the secret again — only the client id and its redirect URIs, which you can edit later (the edit form replaces the whole URI list, it doesn't merge into it) or delete outright. Deleting a client immediately disconnects every company that authorized through it. ## Mint an API key Open **API Keys** and click **Mint API key** (`admin`/`owner`), giving it a short name (32 characters max). The key — prefixed `tkb_` — is shown once, the same way. Send it as the `x-api-key` header on `/admin/v1` calls instead of a session cookie; it's scoped to this one organization for its whole life. A key mints with the permissions of *whoever created it, at whatever role they currently hold* — not a fixed role baked in at mint time. It also can't be used to mint or revoke other keys, even by an `owner`'s key; that always requires a signed-in session. ## Invite a teammate Open **Members** and, if you're `admin`/`owner`, fill in **Invite member**: an email address and a role of `admin` or `member` (the portal never offers `owner` — that's reserved for whoever created the org). Submitting sends an invitation email and adds a row to **Pending invitations**, with a **Re-invite** button if it needs resending. The invite email links to `/portal/accept-invitation/`. Opening it while signed in accepts the invitation and lands back on the dashboard with the new org visible; if you're not signed in yet, the portal sends you through sign-in first and brings you right back. From the members table you can change anyone's role between `admin` and `member`, or remove them — except the org's `owner`, whose row has no controls to demote or remove them. ## Secrets are shown once Three kinds of secret only ever appear once, right when you create them, exactly as the Admin API describes in [Things that are shown once](/guide/admin-api#things-that-are-shown-once): * **OAuth client secrets**, when you register a client * **Agent tokens**, when you issue one from a realm's detail page * **API keys**, when you mint one Every list and detail view that shows the parent resource — the client table, the agent-token table, the API-key table — omits the secret value entirely. The portal's one-time modal (monospace text, a Copy button, and an explicit "I've stored it" button you have to click to dismiss it) is the only place any of these three ever render. If you lose one, there's no recovery: issue a new one and revoke the old. ## What's not here yet The portal covers organizations, realms, agent tokens, OAuth clients, API keys, and membership — the same ground as the Admin API. It does not yet have a usage dashboard, an in-portal agent installer download, or billing; none of that exists today. --- --- url: https://docs.tenkeybridge.com/compatibility.md --- # The compatibility contract We deliver the most QuickBooks Online compatibility we can, and we're honest about the rest. Reads are almost entirely compatible; writes are where QuickBooks Desktop's quirks show up. Every partial or unsupported case below comes with the exact fix. This page is the **full entity matrix** — every QBO Accounting API entity we track, whether live, planned, or a documented gap. Jump to an entity from the tables below, or read [IDs, SyncToken & sparse updates](/guide/concepts) for the cross-cutting rules. ## Entity matrix Every QBO Accounting API entity we track, with an honest verdict and a one-line Desktop reality check. Use this page to answer "does TenkeyBridge cover what I use?" in under a minute. Legend — **Full**: works unchanged · **Partial**: common cases work, see the entity section · **Full (read-only)**: reads work; no write path · **Planned**: real Desktop analog, not yet shipped · **Stretch**: lower-priority planned · **Never**: no honest Desktop equivalent · **Future**: platform ops after the entity matrix. ### Sales | Entity | Verdict | Headline | |---|---|---| | [CreditMemo](#creditmemo) | Partial | Invoice twin — Balance and RemainingCredit both map from Desktop CreditRemaining | | [Customer](#customer) | Full | Sub-customer maps to Desktop Customer:Job | | [Estimate](#estimate) | Partial | Same shape as Invoice; no expiration/acceptance tracking | | [Invoice](#invoice) | Partial | Line model, tax, discount, linked txns, and custom fields | | [Item](#item) | Partial | One Item becomes several Desktop item types | | [Payment](#payment) | Partial | Applying a payment to specific invoices | | [RefundReceipt](#refundreceipt) | Partial | Card refunds of applied credits only (ARRefundCreditCard) — not general item-line refunds | | [SalesReceipt](#salesreceipt) | Partial | Same as Invoice + deposit account | ### Purchasing | Entity | Verdict | Headline | |---|---|---| | [Bill](#bill) | Full | Account vs. item expense lines map cleanly | | [BillPayment](#billpayment) | Partial | Fans out to BillPaymentCheck / BillPaymentCreditCard by PayType | | [Purchase](#purchase) | Partial | Fans out to Check / CreditCardCharge / CreditCardCredit by PaymentType | | [PurchaseOrder](#purchaseorder) | Partial | Non-posting; item lines only — never touches the GL on either side | | [Vendor](#vendor) | Full | 1099 / tax-ID field naming only | | [VendorCredit](#vendorcredit) | Partial | Bill mirror image — expense and item lines | ### Banking | Entity | Verdict | Headline | |---|---|---| | [Account](#account) | Full | AccountType / AccountSubType enum mapping | | [Deposit](#deposit) | Partial | Undeposited Funds → bank via LinkedTxn or manual DepositLineDetail | | [JournalEntry](#journalentry) | Partial | Debit/Credit lines split across Desktop JournalDebitLine / JournalCreditLine | | [Transfer](#transfer) | Full | Bank-to-bank transfer between two accounts | ### Lists & config | Entity | Verdict | Headline | |---|---|---| | [Class](#class) | Full | Hierarchical Class list; SubClass/FullyQualifiedName from Desktop Sublevel/FullName | | [CompanyCurrency](#companycurrency) | Full (read-only) | Desktop CurrencyQuery list; empty when multicurrency is off (statusCode 3250, confirmed live, and 3170 both normalized to \[]) | | [CustomerType](#customertype) | Full (read-only) | Read/query only — QBO CustomerType is read-only via the API; Desktop hierarchy is not exposed | | [Employee](#employee) | Full (read-only) | Read/query only — no Desktop write path this release; payroll aggregates out of scope | | [ExchangeRate](#exchangerate) | Partial | Projected from CurrencyRet.ExchangeRate + AsOfDate; one row per currency that carries a rate; empty when multicurrency is off (statusCode 3250, confirmed live, and 3170 both normalized to \[]) | | [PaymentMethod](#paymentmethod) | Partial | Create only — Desktop has no PaymentMethodMod; Type coarsens Desktop's fine enum to CREDIT\_CARD | NON\_CREDIT\_CARD | | [TaxAgency](#taxagency) | Full (read-only) | Read-only projection — Vendors referenced by any ItemSalesTax.TaxVendorRef (no native Desktop TaxAgency list) | | [TaxCode](#taxcode) | Full | Desktop SalesTaxCode list (TAX/NON); Name max 3 chars (fail-loud, never truncated); a plain taxable/non-taxable flag pair, not a rate container | | [TaxRate](#taxrate) | Full (read-only) | Read-only (parity with QBO) — Desktop ItemSalesTaxQuery; each single rate item is a TaxRate; sales-tax groups are not projected as TaxRates | | [Term](#term) | Partial | Merged StandardTerms + DateDrivenTerms; Type STANDARD | DATE\_DRIVEN dispatches which Desktop list; create-only | | [TimeActivity](#timeactivity) | Partial | Employee time only; Duration compiled from Hours/Minutes or a StartTime/EndTime span | ### Company | Entity | Verdict | Headline | |---|---|---| | [CompanyInfo](#companyinfo) | Full (read-only) | Read-only; Id equals the realm ID, no Desktop write path | | [Preferences](#preferences) | Partial | Read-only singleton (Id=1); honest subset of Desktop PreferencesQuery groups | ### Planned (Desktop analog exists) | Entity | Verdict | Headline | |---|---|---| | [Budget](https://github.com/tenkeybridge/tenkey-bridge/issues/101) | Planned | qbXML has no Budget records, but budget figures are readable through BudgetSummaryReportQuery — a read-only Budget synthesized from that report is planned; budget writes stay in the Desktop UI forever. | ### Never — no Desktop equivalent | Entity | Verdict | Headline | |---|---|---| | [Attachable](#attachable) | Never | QuickBooks Desktop's qbXML API has no attachments interface — the word 'Attachment' does not occur anywhere in the qbxmlops130 message set. | | [Department](#department) | Never | QBO Departments (locations) have no Desktop equivalent — the only 'Department'/'Location' strings in qbXML are free-text fields on Employee, FixedAsset, and Lead, and Desktop's one real dimensional list (Class) is already exposed faithfully as Class. | | [JournalCode](#journalcode) | Never | France-only QBO regulatory feature; not applicable to Desktop — 'JournalCode' occurs nowhere in the qbxmlops130 message set. | | [RecurringTransaction](#recurringtransaction) | Never | Desktop's memorized transactions are not exposed by qbXML — there is no message to list, read, create, or execute them. | | [ReimburseCharge](#reimbursecharge) | Never | QBO's billable-charge object has no Desktop record behind it — in qbXML, billability is a status flag (BillableStatus) on the source expense/item/time lines, never a standalone charge you can query. | | [TaxClassification](#taxclassification) | Never | Cloud automated-sales-tax concept with no Desktop counterpart — Desktop's tax model is explicit tax items and codes, all of which TenkeyBridge exposes. | | [TaxService](#taxservice) | Never | Cloud AST onboarding operation with no Desktop counterpart — 'TaxService' occurs nowhere in the qbxmlops130 message set. | ### Platform | Entity | Verdict | Headline | |---|---|---| | [Batch](#batch) | Full | Batch envelope — up to 30 create/update/delete/query operations in one request, executed sequentially with per-item faults. | | [CDC](#cdc) | Full (read-only) | Change Data Capture — one poll returns changed + deleted records across entities. | | [Reports](#reports) | Partial | Five QBO-compatible reports translated live from Desktop's report engine. | ## Entity detail ## CreditMemo **Verdict:** Partial · **Read:** Full · **Write:** Partial — Invoice twin — Balance and RemainingCredit both map from Desktop CreditRemaining | QBO field | Support | Notes | |---|---|---| | CustomerMemo | Full | Maps to Desktop CustomerMsgRef by FullName; the message must already exist as a Desktop customer message. Updatable via sparse update (#55). | | Line | Partial | Same DetailType support as Invoice: SalesItemLineDetail, GroupLineDetail, and DescriptionOnly map cleanly; DiscountLineDetail requires a Desktop discount item; other DetailTypes return UNSUPPORTED\_BY\_DESKTOP. Group lines read back as GroupLineDetail without nested line expansion; Desktop discount lines read back as regular item lines referencing the discount item. Updates carrying Line replace the entire line table: every line is re-created with a new line Id and lines omitted from the update are deleted by Desktop (v1 full replacement, #55). | | TxnTaxDetail | Partial | Set TxnTaxCodeRef explicitly; computed TotalTax alone is rejected. Updatable via sparse update (#55). | | LinkedTxn | Partial | Read-only; reflects applied payments/credits, ignored on write — same as QBO. | | TotalAmt | Full | Read-only, mapped from Desktop TotalAmount. | | Balance | Full | Read-only remaining unapplied credit; mapped from Desktop CreditRemaining. | | RemainingCredit | Full | Read-only — total credit still available to apply. A distinct QBO field, not an alias of Balance; both map from Desktop's single CreditRemaining since Desktop has only the one figure. | | ApplyTaxAfterDiscount | None | `UNSUPPORTED_BY_DESKTOP` — Desktop credit memos have no ApplyTaxAfterDiscount toggle. Control tax via per-line TaxCodeRef / TxnTaxDetail.TxnTaxCodeRef instead. | Everything else listed as full works unchanged. * CustomerRef * TxnDate * DocNumber * BillAddr * ShipAddr * PONumber * SalesTermRef * DueDate * PrivateNote ### Query support Standard `SELECT * FROM CreditMemo` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **TxnTaxDetail.TotalTax supplied without TxnTaxCodeRef** — `UNSUPPORTED_BY_DESKTOP`: Desktop cannot honor a computed tax total. Set TxnTaxDetail.TxnTaxCodeRef or per-line TaxCodeRef explicitly. See COMPATIBILITY.md. * **Line.DiscountLineDetail without an ItemRef (bare-percentage or amount-off discounts)** — `MISSING_REQUIRED_DESKTOP_ITEM`: Desktop discounts are items. Create a discount item in the company file and reference it as DiscountLineDetail.ItemRef. See COMPATIBILITY.md. * **Nested/grouped line types other than SalesItemLineDetail, DiscountLineDetail, GroupLineDetail, and DescriptionOnly** — `UNSUPPORTED_BY_DESKTOP`: These line types do not map to QuickBooks Desktop sales transactions. Remove them or flatten to a supported DetailType. See COMPATIBILITY.md. * **ApplyTaxAfterDiscount supplied** — `UNSUPPORTED_BY_DESKTOP`: Desktop credit memos have no ApplyTaxAfterDiscount toggle. Control tax via per-line TaxCodeRef / TxnTaxDetail.TxnTaxCodeRef instead. ## Customer **Verdict:** Full · **Read:** Full · **Write:** Full — Sub-customer maps to Desktop Customer:Job | QBO field | Support | Notes | |---|---|---| | ParentRef | Full | Becomes a Desktop Job under the parent; FullName is colon-delimited. | | SalesTermRef | Full | Desktop TermsRef. | | DefaultTaxCodeRef | Full | Desktop SalesTaxCodeRef. | | AcctNum | Full | Desktop AccountNumber. | | Balance | Full | Read-only, as in QBO. | | BalanceWithJobs | Full | Read-only; Desktop TotalBalance. | | FullyQualifiedName | Full | Read-only, derived by Desktop. | | Job | Full | Read-only, derived by Desktop Sublevel. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. Accepted and ignored on write. | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | PreferredPaymentMethodRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | PreferredDeliveryMethod | None | `UNSUPPORTED_BY_DESKTOP` — Desktop has no delivery-method preference. Remove the field from your payload. | | Taxable | None | `UNSUPPORTED_BY_DESKTOP` — Desktop customers have no taxable flag; set tax codes on transactions instead. | | PrintOnCheckName | None | `UNSUPPORTED_BY_DESKTOP` — Desktop CustomerRet has no PrintAs element (unlike Vendor/Employee). | | Mobile | None | `UNSUPPORTED_BY_DESKTOP` — Desktop CustomerRet has no separate mobile-phone element beyond Phone/AltPhone/Fax. | | WebAddr | None | `UNSUPPORTED_BY_DESKTOP` — Desktop CustomerRet has no website element. | Everything else listed as full works unchanged. * DisplayName * CompanyName * Title * GivenName * MiddleName * FamilyName * PrimaryEmailAddr * PrimaryPhone * AlternatePhone * Fax * BillAddr * ShipAddr * Notes * Active * CustomerTypeRef ### Query support Standard `SELECT * FROM Customer` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## Estimate **Verdict:** Partial · **Read:** Full · **Write:** Partial — Same shape as Invoice; no expiration/acceptance tracking | QBO field | Support | Notes | |---|---|---| | CustomerMemo | Full | Maps to Desktop CustomerMsgRef by FullName; the message must already exist as a Desktop customer message. Updatable via sparse update (#55). | | Line | Partial | Same DetailType support as Invoice: SalesItemLineDetail, GroupLineDetail, and DescriptionOnly map cleanly; line-level ClassRef and ServiceDate are populated on read; DiscountLineDetail requires a Desktop discount item; other DetailTypes return UNSUPPORTED\_BY\_DESKTOP. Group lines read back as GroupLineDetail without nested line expansion; Desktop discount lines read back as regular item lines referencing the discount item. Updates carrying Line replace the entire line table: every line is re-created with a new line Id and lines omitted from the update are deleted by Desktop (v1 full replacement, #55). | | TxnTaxDetail | Partial | Set TxnTaxCodeRef explicitly on write; computed TotalTax alone is rejected. On read: TotalTax, TxnTaxCodeRef, and TaxLine\[{Amount, TaxLineDetail{TaxRateRef, TaxPercent}}]. NetAmountTaxable never returned. Updatable via sparse update (#55). | | LinkedTxn | Full | Populated on read from LinkedTxnRet (IncludeLinkedTxns automatic) — progress-invoicing links when present. Links to Desktop-only transaction types (SalesOrder, ItemReceipt, …) are omitted rather than guessed at. Accepted and ignored on write. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. Accepted and ignored on write. | | TotalAmt | Full | Read-only, computed by Desktop as Subtotal + SalesTaxTotal (not independently verified live — see issue #38 report). | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExpirationDate | None | `UNSUPPORTED_BY_DESKTOP` — Desktop estimates have no expiration date. Track it in your own system if needed. | | AcceptedBy | None | `UNSUPPORTED_BY_DESKTOP` — Desktop estimates have no accepted-by field. Track acceptance in your own system. | | AcceptedDate | None | `UNSUPPORTED_BY_DESKTOP` — Desktop estimates have no accepted-date field. Track acceptance in your own system. | | TxnStatus | None | `UNSUPPORTED_BY_DESKTOP` — Desktop estimates have no status field matching QBO's Pending/Accepted/Closed/Rejected enum. Track status in your own system. | Everything else listed as full works unchanged. * CustomerRef * TxnDate * DocNumber * BillAddr * ShipAddr * PONumber * SalesTermRef * DueDate * PrivateNote ### Query support Standard `SELECT * FROM Estimate` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **TxnTaxDetail.TotalTax supplied without TxnTaxCodeRef** — `UNSUPPORTED_BY_DESKTOP`: Desktop cannot honor a computed tax total. Set TxnTaxDetail.TxnTaxCodeRef or per-line TaxCodeRef explicitly. See COMPATIBILITY.md. * **Line.DiscountLineDetail without an ItemRef (bare-percentage or amount-off discounts)** — `MISSING_REQUIRED_DESKTOP_ITEM`: Desktop discounts are items. Create a discount item in the company file and reference it as DiscountLineDetail.ItemRef. See COMPATIBILITY.md. * **Nested/grouped line types other than SalesItemLineDetail, DiscountLineDetail, GroupLineDetail, and DescriptionOnly** — `UNSUPPORTED_BY_DESKTOP`: These line types do not map to QuickBooks Desktop sales transactions. Remove them or flatten to a supported DetailType. See COMPATIBILITY.md. ## Invoice **Verdict:** Partial · **Read:** Full · **Write:** Partial — Line model, tax, discount, linked txns, and custom fields | QBO field | Support | Notes | |---|---|---| | CustomerMemo | Full | Maps to Desktop CustomerMsgRef by FullName; the message must already exist as a Desktop customer message. Updatable via sparse update (#55). | | Line | Partial | SalesItemLineDetail, GroupLineDetail, and DescriptionOnly map cleanly; line-level ClassRef and ServiceDate are populated on read; DiscountLineDetail requires a Desktop discount item referenced via DiscountLineDetail.ItemRef; other DetailTypes (e.g. SubTotalLineDetail) return UNSUPPORTED\_BY\_DESKTOP. Group lines read back as GroupLineDetail without nested line expansion; Desktop discount lines read back as regular item lines referencing the discount item. Updates carrying Line replace the entire line table: every line is re-created with a new line Id and lines omitted from the update are deleted by Desktop (v1 full replacement, #55). | | TxnTaxDetail | Partial | Set TxnTaxCodeRef explicitly on write; computed TotalTax alone is rejected. On read: TotalTax, TxnTaxCodeRef (from ItemSalesTaxRef), and TaxLine\[{Amount, TaxLineDetail{TaxRateRef, TaxPercent}}] from SalesTaxTotal/ItemSalesTaxRef/SalesTaxPercentage. NetAmountTaxable is never returned (Desktop has no taxable basis). Updatable via sparse update (#55). | | LinkedTxn | Full | Populated on read from LinkedTxnRet when IncludeLinkedTxns is requested (automatic). Desktop ReceivePayment → QBO Payment; links to Desktop-only transaction types (SalesOrder, ItemReceipt, …) are omitted rather than guessed at. Accepted and ignored on write — same as QBO treats it as read-only. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. Accepted and ignored on write. | | TotalAmt | Full | Read-only, computed by Desktop as Subtotal + SalesTaxTotal. | | Balance | Full | Read-only, computed by Desktop as BalanceRemaining. | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | Everything else listed as full works unchanged. * CustomerRef * TxnDate * DocNumber * BillAddr * ShipAddr * ShipDate * ShipMethodRef * PONumber * SalesTermRef * DueDate * PrivateNote ### Query support Standard `SELECT * FROM Invoice` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **TxnTaxDetail.TotalTax supplied without TxnTaxCodeRef** — `UNSUPPORTED_BY_DESKTOP`: Desktop cannot honor a computed tax total. Set TxnTaxDetail.TxnTaxCodeRef or per-line TaxCodeRef explicitly. See COMPATIBILITY.md. * **Line.DiscountLineDetail without an ItemRef (bare-percentage or amount-off discounts)** — `MISSING_REQUIRED_DESKTOP_ITEM`: Desktop discounts are items. Create a discount item in the company file and reference it as DiscountLineDetail.ItemRef. See COMPATIBILITY.md. * **Nested/grouped line types other than SalesItemLineDetail, DiscountLineDetail, GroupLineDetail, and DescriptionOnly (e.g. SubTotalLineDetail)** — `UNSUPPORTED_BY_DESKTOP`: These line types do not map to QuickBooks Desktop sales transactions. Remove them or flatten to a supported DetailType. See COMPATIBILITY.md. ## Item **Verdict:** Partial · **Read:** Partial · **Write:** None — One Item becomes several Desktop item types | QBO field | Support | Notes | |---|---|---| | FullyQualifiedName | Full | Read-only in this release. | | Active | Full | Read-only in this release. | | Type | Partial | Service, NonInventory, Inventory, Group map; other Desktop types are omitted from query results. | | Description | Full | Read-only in this release. | | UnitPrice | Full | Read-only in this release. | | Sku | Partial | QBO Sku ⇄ Desktop ManufacturerPartNumber — Desktop has no first-class SKU field; barcode (Advanced Inventory) is intentionally not used. Read-only in this release. | | QtyOnHand | Full | Read-only in this release. | | IncomeAccountRef | Full | Read-only in this release. | | ExpenseAccountRef | Partial | Inventory: COGSAccountRef. Two-sided Service/NonInventory (SalesAndPurchase): ExpenseAccountRef. One-sided Service/NonInventory have no expense account. | | AssetAccountRef | Full | Read-only in this release; Inventory only. | | PurchaseDesc | Full | Inventory and two-sided Service/NonInventory (SalesAndPurchase). | | PurchaseCost | Full | Inventory and two-sided Service/NonInventory (SalesAndPurchase). | | PrefVendorRef | Full | Inventory and two-sided Service/NonInventory. | | ParentRef | Full | Desktop item hierarchy. | | SubItem | Full | Derived from Desktop Sublevel. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. | Everything else listed as full works unchanged. * Name ### Query support Standard `SELECT * FROM Item` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Item types other than Service, NonInventory, Inventory, and Group (assemblies, discounts, sales tax items, payment items, fixed assets, other charge, subtotal)** — `UNSUPPORTED_BY_DESKTOP`: Omitted from query results; a direct get by id on one of these Desktop items returns a 422 UNSUPPORTED\_BY\_DESKTOP fault. * **Item create/update** — `UNSUPPORTED_BY_DESKTOP`: Lands in a later release. ## Payment **Verdict:** Partial · **Read:** Full · **Write:** Partial — Applying a payment to specific invoices | QBO field | Support | Notes | |---|---|---| | PaymentRefNum | Full | Desktop RefNumber. Query filtering by PaymentRefNum is not supported yet — query by Id or date range. | | DepositToAccountRef | Full | Omit to use Desktop's Undeposited Funds preference, same as QBO. | | Line | Partial | LinkedTxn applications to Invoices only; no Line means an unapplied payment (IsAutoApply=false). CreditMemo/Deposit applications land later. AppliedToTxnRet.TxnDate/RefNumber/BalanceRemaining have no honest home in QBO Payment.Line.LinkedTxn ({TxnId, TxnType} only) and are not projected — re-fetch the invoice for balance/date/doc number. | | UnappliedAmt | Full | Read-only; Desktop UnusedPayment. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. Accepted and ignored on write. | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ProcessPayment | Partial | Accepted and ignored — TenkeyBridge never initiates card processing. | | TxnNumber | None | `UNSUPPORTED_BY_DESKTOP` — Desktop's internal auto-increment TxnNumber has no QBO equivalent and is not surfaced. | | UnusedCredits | None | `UNSUPPORTED_BY_DESKTOP` — Desktop UnusedCredits (unapplied credit memos available to the customer) is distinct from UnappliedAmt/UnusedPayment; no honest home on Payment — belongs on CreditMemo.Balance. | Everything else listed as full works unchanged. * CustomerRef * ARAccountRef * TxnDate * TotalAmt * PaymentMethodRef * PrivateNote ### Query support Standard `SELECT * FROM Payment` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## RefundReceipt **Verdict:** Partial · **Read:** Partial · **Write:** Partial — Card refunds of applied credits only (ARRefundCreditCard) — not general item-line refunds | QBO field | Support | Notes | |---|---|---| | DepositToAccountRef | Full | Maps to Desktop RefundFromAccountRef. | | PaymentMethodRef | Full | Must be a credit-card payment method in the company file. | | Line | Partial | LinkedTxn credit-memo applications → RefundAppliedToTxnAdd only. At least one Line is required on create — Desktop's ARRefundCreditCardAdd requires one or more RefundAppliedToTxnAdd; an empty/missing Line array fails loud rather than sending Desktop an invalid request. SalesItemLineDetail / item-line refunds return UNSUPPORTED\_BY\_DESKTOP — use CreditMemo + payment/refund pair instead (see CreditMemo). | | TotalAmt | Full | Read-only from Desktop TotalAmount. | | CurrencyRef | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | | ExchangeRate | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | Everything else listed as full works unchanged. * CustomerRef * TxnDate * DocNumber * PrivateNote ### Query support Standard `SELECT * FROM RefundReceipt` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Item-line RefundReceipt (SalesItemLineDetail etc.)** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no general item-line refund receipt. Create a CreditMemo and refund via payment/ARRefundCreditCard, or handle cash/check refunds outside this entity. * **Update / sparse update** — `UNSUPPORTED_BY_DESKTOP`: ARRefundCreditCard has no Mod path in this release. ## SalesReceipt **Verdict:** Partial · **Read:** Full · **Write:** Partial — Same as Invoice + deposit account | QBO field | Support | Notes | |---|---|---| | CustomerMemo | Full | Maps to Desktop CustomerMsgRef by FullName; the message must already exist as a Desktop customer message. Updatable via sparse update (#55). | | Line | Partial | SalesItemLineDetail, GroupLineDetail, and DescriptionOnly map cleanly; line-level ClassRef and ServiceDate are populated on read; DiscountLineDetail requires a Desktop discount item referenced via DiscountLineDetail.ItemRef; other DetailTypes (e.g. SubTotalLineDetail) return UNSUPPORTED\_BY\_DESKTOP. Group lines read back as GroupLineDetail without nested line expansion; Desktop discount lines read back as regular item lines referencing the discount item. Updates carrying Line replace the entire line table: every line is re-created with a new line Id and lines omitted from the update are deleted by Desktop (v1 full replacement, #55). | | TxnTaxDetail | Partial | Set TxnTaxCodeRef explicitly on write; computed TotalTax alone is rejected. On read: TotalTax, TxnTaxCodeRef, and TaxLine\[{Amount, TaxLineDetail{TaxRateRef, TaxPercent}}]. NetAmountTaxable never returned. Updatable via sparse update (#55). | | LinkedTxn | Partial | Desktop SalesReceiptQueryRq has no IncludeLinkedTxns and SalesReceiptRet carries no LinkedTxnRet (qbXML 13.0) — never populated on read. Accepted and ignored on write. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. Accepted and ignored on write. | | TotalAmt | Full | Read-only; Desktop computes it. | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | Everything else listed as full works unchanged. * CustomerRef * TxnDate * DocNumber * BillAddr * ShipAddr * ShipDate * ShipMethodRef * PrivateNote * DepositToAccountRef ### Query support Standard `SELECT * FROM SalesReceipt` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **TxnTaxDetail.TotalTax supplied without TxnTaxCodeRef** — `UNSUPPORTED_BY_DESKTOP`: Desktop cannot honor a computed tax total. Set TxnTaxDetail.TxnTaxCodeRef or per-line TaxCodeRef explicitly. See COMPATIBILITY.md. ## Bill **Verdict:** Full · **Read:** Full · **Write:** Full — Account vs. item expense lines map cleanly | QBO field | Support | Notes | |---|---|---| | SalesTermRef | Full | Desktop TermsRef. | | Line | Full | Account-based and item-based expense lines; other line types return UNSUPPORTED\_BY\_DESKTOP. Sparse updates cannot modify lines/memo/tax yet. Both AccountBasedExpenseLineDetail.ClassRef and ItemBasedExpenseLineDetail.ClassRef are read/written, mapping to Desktop ExpenseLineAdd/ExpenseLineRet and ItemLineAdd/ItemLineRet ClassRef respectively — on ExpenseLineAdd, ClassRef sits in OSR position (AccountRef < Amount < Memo < CustomerRef < ClassRef). | | TotalAmt | Full | Read-only, computed by Desktop as AmountDue. | | Balance | Full | Read-only, as in QBO. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. Accepted and ignored on write. | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | LinkedTxn | None | `UNSUPPORTED_BY_DESKTOP` — QBO Bill has no paid-by LinkedTxn field. Desktop may return LinkedTxnRet when IncludeLinkedTxns is set, but there is no honest QBO home — not projected. | Everything else listed as full works unchanged. * VendorRef * TxnDate * DueDate * DocNumber * PrivateNote ### Query support Standard `SELECT * FROM Bill` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## BillPayment **Verdict:** Partial · **Read:** Full · **Write:** Partial — Fans out to BillPaymentCheck / BillPaymentCreditCard by PayType | QBO field | Support | Notes | |---|---|---| | PayType | Full | Check → BillPaymentCheck\*; CreditCard → BillPaymentCreditCard\*. Updates: Check family only — qbXML has no BillPaymentCreditCardMod, so credit-card-family updates fail loud with UNSUPPORTED\_BY\_DESKTOP (#75); delete and re-create instead. | | VendorRef | Full | Maps to Desktop PayeeEntityRef. | | TotalAmt | Full | Read-only from Desktop Amount; write-ignored on create/update — neither BillPaymentCheckAdd nor BillPaymentCreditCardAdd has a top-level Amount element in the OSR, Desktop derives it from the summed AppliedToTxnAdd/PaymentAmount values instead. | | CheckPayment | Full | CheckPayment.BankAccountRef ⇄ BankAccountRef. | | CreditCardPayment | Full | CreditCardPayment.CCAccountRef ⇄ CreditCardAccountRef. | | PrintStatus | Partial | Check flavor only; ⇄ IsToBePrinted. BillPaymentCheckAdd requires IsToBePrinted OR RefNumber (Desktop OSR); when a create supplies neither PrintStatus nor DocNumber, TenkeyBridge synthesizes IsToBePrinted=false so the Add stays schema-valid. | | Line | Partial | LinkedTxn bills → AppliedToTxnAdd; additional LinkedTxn VendorCredit → SetCredit. Sparse updates cannot modify lines yet. | | CurrencyRef | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | | ExchangeRate | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | Everything else listed as full works unchanged. * TxnDate * DocNumber * PrivateNote ### Query support Standard `SELECT * FROM BillPayment` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **PayType outside Check/CreditCard** — `UNSUPPORTED_BY_DESKTOP`: Only Check and CreditCard map to Desktop bill-payment families. ## Purchase **Verdict:** Partial · **Read:** Full · **Write:** Partial — Fans out to Check / CreditCardCharge / CreditCardCredit by PaymentType | QBO field | Support | Notes | |---|---|---| | PaymentType | Partial | Check and CreditCard map to Desktop families; Cash is partial (maps to Check against the supplied bank/petty-cash AccountRef — Desktop has no separate cash purchase type). | | Credit | Full | true + PaymentType CreditCard → CreditCardCredit\*; false/omitted → CreditCardCharge\*. | | AccountRef | Full | Bank account (Check/Cash) or credit-card account (CreditCard). | | EntityRef | Full | Maps to Desktop PayeeEntityRef (Customer/Vendor/Employee). | | PrintStatus | Partial | NeedToPrint/PrintComplete ⇄ IsToBePrinted (Check family). | | Line | Full | AccountBasedExpenseLineDetail and ItemBasedExpenseLineDetail via shared Bill line machinery. Sparse updates cannot modify lines yet. | | TotalAmt | Full | Read-only from Desktop Amount. | | CurrencyRef | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | | ExchangeRate | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | Everything else listed as full works unchanged. * TxnDate * DocNumber * PrivateNote ### Query support * **List query STARTPOSITION > 1 across multi-type families** — `UNSUPPORTED_QUERY`: Deep iterator pagination uses the primary Check family only; page-1 and filtered queries merge Check + CreditCardCharge + CreditCardCredit (per-type order concatenated). ### Not supported * **PaymentType outside Check/Cash/CreditCard** — `UNSUPPORTED_BY_DESKTOP`: Only Check, Cash (→Check), and CreditCard map to Desktop purchase families. * **List query STARTPOSITION > 1 across multi-type families** — `UNSUPPORTED_QUERY`: Deep iterator pagination uses the primary Check family only; page-1 and filtered queries merge Check + CreditCardCharge + CreditCardCredit (per-type order concatenated). ## PurchaseOrder **Verdict:** Partial · **Read:** Full · **Write:** Partial — Non-posting; item lines only — never touches the GL on either side | QBO field | Support | Notes | |---|---|---| | Line | Partial | ItemBasedExpenseLineDetail only (PurchaseOrderLineAdd). AccountBasedExpenseLineDetail returns UNSUPPORTED\_BY\_DESKTOP. ReceivedQty maps from ReceivedQuantity on read (read-only). Sparse updates cannot modify lines yet. | | TotalAmt | Full | Read-only from Desktop TotalAmount. | | POStatus | Partial | Open/Closed map to Desktop IsManuallyClosed, a Mod-only element — PurchaseOrderAdd has no IsManuallyClosed at all. Create with POStatus Open (or omitted) works; POStatus Closed on create returns UNSUPPORTED\_BY\_DESKTOP (create the PO open, then close it via an update). Closing via sparse update is not wired yet — POStatus is effectively read-only through TenkeyBridge until that lands. | | APAccountRef | None | `UNSUPPORTED_BY_DESKTOP` — Purchase orders are non-posting — there is no AP account on Desktop POs. | | CurrencyRef | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | | ExchangeRate | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | Everything else listed as full works unchanged. * VendorRef * TxnDate * DocNumber * VendorAddr * ShipAddr * DueDate * ExpectedDate * ShipMethodRef * PrivateNote ### Query support Standard `SELECT * FROM PurchaseOrder` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **AccountBasedExpenseLineDetail on a PurchaseOrder line** — `UNSUPPORTED_BY_DESKTOP`: Desktop PO lines are item-only. Use ItemBasedExpenseLineDetail, or post a Bill/Purchase for account expenses. * **POStatus Closed on create** — `UNSUPPORTED_BY_DESKTOP`: Desktop's PurchaseOrderAdd has no IsManuallyClosed element — a PO can't be created already closed. Create it open, then close it via an update. ## Vendor **Verdict:** Full · **Read:** Full · **Write:** Full — 1099 / tax-ID field naming only | QBO field | Support | Notes | |---|---|---| | PrintOnCheckName | Full | Desktop PrintAs. | | TaxIdentifier | Full | Maps to Desktop VendorTaxIdent. | | Vendor1099 | Full | Maps to Desktop IsVendorEligibleFor1099. | | Balance | Full | Read-only, as in QBO. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. Accepted and ignored on write. | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | Everything else listed as full works unchanged. * DisplayName * Active * CompanyName * Title * GivenName * MiddleName * FamilyName * BillAddr * PrimaryPhone * AlternatePhone * Fax * PrimaryEmailAddr * AcctNum ### Query support Standard `SELECT * FROM Vendor` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## VendorCredit **Verdict:** Partial · **Read:** Full · **Write:** Partial — Bill mirror image — expense and item lines | QBO field | Support | Notes | |---|---|---| | Line | Full | AccountBasedExpenseLineDetail and ItemBasedExpenseLineDetail via shared Bill line machinery. Sparse updates cannot modify lines yet. | | TotalAmt | Full | Read-only from Desktop CreditAmount. | | Balance | Full | Read-only remaining credit from Desktop CreditRemaining. | | CurrencyRef | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | | ExchangeRate | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | Everything else listed as full works unchanged. * VendorRef * APAccountRef * TxnDate * DocNumber * PrivateNote ### Query support Standard `SELECT * FROM VendorCredit` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## Account **Verdict:** Full · **Read:** Full · **Write:** Full — AccountType / AccountSubType enum mapping | QBO field | Support | Notes | |---|---|---| | ParentRef | Full | Becomes a Desktop sub-account; FullName is colon-delimited. | | AccountType | Partial | Mapped per the COMPATIBILITY.md appendix; unmapped values return 422 UNMAPPED\_ACCOUNT\_TYPE. Desktop-only Non-Posting appears on reads. | | AccountSubType | Partial | Accepted and ignored — Desktop has no account subtype; reads never return one. | | FullyQualifiedName | Full | Read-only, derived by Desktop. | | SubAccount | Full | Read-only, derived from Desktop Sublevel. | | CurrentBalance | Full | Read-only, as in QBO. | | CurrentBalanceWithSubAccounts | Full | Read-only; Desktop TotalBalance. | | Classification | Partial | Accepted and ignored on writes; not returned on reads — derive it from AccountType client-side. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only for now. Accepted and ignored on write. | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | CashFlowClassification | None | `UNSUPPORTED_BY_DESKTOP` — Desktop AccountRet.CashFlowClassification has no honest QBO Account field; omitted rather than guessed at. | | SpecialAccountType | None | `UNSUPPORTED_BY_DESKTOP` — Desktop SpecialAccountType has no honest QBO Account field; omitted rather than guessed at. | Everything else listed as full works unchanged. * Name * Active * AcctNum * Description ### Query support Standard `SELECT * FROM Account` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## Deposit **Verdict:** Partial · **Read:** Full · **Write:** Partial — Undeposited Funds → bank via LinkedTxn or manual DepositLineDetail | QBO field | Support | Notes | |---|---|---| | Line | Partial | LinkedTxn (PaymentTxnID path) for undeposited payments, or DepositLineDetail{Entity, AccountRef, PaymentMethodRef, CheckNum}. Sparse updates cannot modify lines yet. | | CashBack | Full | Maps to Desktop CashBackInfo. | | TotalAmt | Full | Read-only from Desktop DepositTotal. | | CurrencyRef | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | | ExchangeRate | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | Everything else listed as full works unchanged. * DepositToAccountRef * TxnDate * PrivateNote ### Query support Standard `SELECT * FROM Deposit` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## JournalEntry **Verdict:** Partial · **Read:** Full · **Write:** Partial — Debit/Credit lines split across Desktop JournalDebitLine / JournalCreditLine | QBO field | Support | Notes | |---|---|---| | PrivateNote | Full | Maps to Desktop Memo when the company file/OSR returns a header Memo on JournalEntry. | | Adjustment | Full | Maps to Desktop IsAdjustment. | | Line | Partial | JournalEntryLineDetail only. Write splits by PostingType into JournalDebitLine / JournalCreditLine; read interleaves debit then credit lists. Debits must equal credits or create fails UNSUPPORTED\_BY\_DESKTOP before the Desktop round-trip. Sparse updates cannot modify lines yet (Desktop JE Mod replaces the entire line table when lines are provided — same out-of-scope stance as Invoice lines / issue #55). JournalEntryLineDetail.Entity: only EntityRef is populated on read — Desktop's line-level EntityRef carries no entity-kind flag, so we don't stamp a Type (Vendor/Customer/Employee); a wrong guess is worse than omitting it. Set Type yourself on write if you know it; it's accepted and simply not echoed back. | | TotalAmt | Full | Read-only aggregate when present; not independently computed. | | CurrencyRef | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | | ExchangeRate | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | Everything else listed as full works unchanged. * TxnDate * DocNumber ### Query support Standard `SELECT * FROM JournalEntry` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Unbalanced journal entry (debits ≠ credits)** — `UNSUPPORTED_BY_DESKTOP`: Desktop rejects unbalanced JEs. Balance debits and credits before create. * **Line DetailType other than JournalEntryLineDetail** — `UNSUPPORTED_BY_DESKTOP`: Only JournalEntryLineDetail maps to Desktop journal lines. ## Transfer **Verdict:** Full · **Read:** Full · **Write:** Full — Bank-to-bank transfer between two accounts | QBO field | Support | Notes | |---|---|---| | FromAccountRef | Full | Maps to Desktop TransferFromAccountRef. | | ToAccountRef | Full | Maps to Desktop TransferToAccountRef. | | CurrencyRef | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | | ExchangeRate | None | `UNSUPPORTED_BY_DESKTOP` — Multicurrency not yet supported. | Everything else listed as full works unchanged. * Amount * TxnDate * PrivateNote ### Query support Standard `SELECT * FROM Transfer` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Delete** — `UNSUPPORTED_BY_DESKTOP`: "Transfer" is not a valid qbXML TxnDelType enum value, so delete is not wired for this entity. ## Class **Verdict:** Full · **Read:** Full · **Write:** Full — Hierarchical Class list; SubClass/FullyQualifiedName from Desktop Sublevel/FullName | QBO field | Support | Notes | |---|---|---| | ParentRef | Full | Becomes a Desktop sub-class; FullName is colon-delimited (e.g. Services:West Coast). | | FullyQualifiedName | Full | Read-only, derived by Desktop from the parent chain. | | SubClass | Full | Read-only, derived from Desktop Sublevel (true when Sublevel > 0). | Everything else listed as full works unchanged. * Name * Active ### Query support Standard `SELECT * FROM Class` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## CompanyCurrency **Verdict:** Full (read-only) · **Read:** Full · **Write:** None — Desktop CurrencyQuery list; empty when multicurrency is off (statusCode 3250, confirmed live, and 3170 both normalized to \[]) | QBO field | Support | Notes | |---|---|---| | Code | Full | Desktop CurrencyCode. | Everything else listed as full works unchanged. * Name * Active ### Query support Standard `SELECT * FROM CompanyCurrency` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Create, update, or delete** — `UNSUPPORTED_BY_DESKTOP`: CompanyCurrency is read/query only — Desktop CurrencyAdd/Mod is out of scope for this stretch entity; QBO also treats company currency catalog as admin-managed. ## CustomerType **Verdict:** Full (read-only) · **Read:** Full · **Write:** None — Read/query only — QBO CustomerType is read-only via the API; Desktop hierarchy is not exposed Every field works unchanged. * Name * Active ### Query support Standard `SELECT * FROM CustomerType` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Create or update** — `UNSUPPORTED_BY_DESKTOP`: QBO's CustomerType is read-only via the API. TenkeyBridge matches that parity even though Desktop allows CustomerTypeAdd. Desktop ParentRef/Sublevel/FullName have no QBO CustomerType fields and are not surfaced. ## Employee **Verdict:** Full (read-only) · **Read:** Full · **Write:** None — Read/query only — no Desktop write path this release; payroll aggregates out of scope | QBO field | Support | Notes | |---|---|---| | PrintOnCheckName | Full | Desktop PrintAs. | | CustomField | Partial | Name/StringValue populated from Desktop custom fields (DataExt); DefinitionId not available from Desktop; read-only. | | TargetBonus | None | `UNSUPPORTED_BY_DESKTOP` — Desktop payroll aggregate; license-gated and out of scope. | | AdditionalContactRef | None | `UNSUPPORTED_BY_DESKTOP` — Desktop AdditionalContactRef has no honest QBO Employee field; omitted rather than guessed at. | Everything else listed as full works unchanged. * GivenName * MiddleName * FamilyName * Suffix * DisplayName * Active * PrimaryPhone * Mobile * PrimaryEmailAddr * PrimaryAddr * HiredDate * ReleasedDate * BirthDate * Gender ### Query support Standard `SELECT * FROM Employee` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Create or update** — `UNSUPPORTED_BY_DESKTOP`: Employee is read/query only this release; TenkeyBridge doesn't expose a Desktop write path for it. ## ExchangeRate **Verdict:** Partial · **Read:** Partial · **Write:** None — Projected from CurrencyRet.ExchangeRate + AsOfDate; one row per currency that carries a rate; empty when multicurrency is off (statusCode 3250, confirmed live, and 3170 both normalized to \[]) | QBO field | Support | Notes | |---|---|---| | SourceCurrencyCode | Full | Desktop CurrencyCode on the same CurrencyRet. | | Rate | Full | Desktop ExchangeRate. | | AsOfDate | Full | Desktop AsOfDate when present; omitted when Desktop has none. | | TargetCurrencyCode | None | `UNSUPPORTED_BY_DESKTOP` — CurrencyRet has no per-row target; home currency lives on Preferences.CurrencyPrefs.HomeCurrency — never invented here. | Everything else listed as full works unchanged. ### Query support Standard `SELECT * FROM ExchangeRate` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **QBO-style keyed lookup GET /exchangerate?sourcecurrencycode=EUR\[\&asofdate=...]** — `UNSUPPORTED_QUERY`: Real QBO keys ExchangeRate by sourcecurrencycode/asofdate query params. TenkeyBridge exposes it as a conventional Id-keyed resource (Id = Desktop currency ListID) with SELECT \* queries — filter by SourceCurrencyCode client-side. * **Create, update, or delete** — `UNSUPPORTED_BY_DESKTOP`: ExchangeRate is read/query only — projected from CurrencyQuery, not a writable Desktop list. * **Currencies without ExchangeRate on CurrencyRet** — `UNSUPPORTED_BY_DESKTOP`: Projection skips CurrencyRets that do not carry ExchangeRate (home currency rows often omit it). ## PaymentMethod **Verdict:** Partial · **Read:** Full · **Write:** Partial — Create only — Desktop has no PaymentMethodMod; Type coarsens Desktop's fine enum to CREDIT\_CARD | NON\_CREDIT\_CARD | QBO field | Support | Notes | |---|---|---| | Type | Partial | Desktop PaymentMethodType (Visa, Cash, Check, …) coarsens to QBO CREDIT\_CARD | NON\_CREDIT\_CARD on read. Create writes a representative Desktop value (OtherCreditCard / Other) — the fine enum is not preserved across a round-trip. | Everything else listed as full works unchanged. * Name * Active ### Query support Standard `SELECT * FROM PaymentMethod` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Update** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no PaymentMethodMod. Create a new PaymentMethod instead; list entities are not deleted — deactivate is also unavailable without Mod. ## TaxAgency **Verdict:** Full (read-only) · **Read:** Full · **Write:** None — Read-only projection — Vendors referenced by any ItemSalesTax.TaxVendorRef (no native Desktop TaxAgency list) | QBO field | Support | Notes | |---|---|---| | DisplayName | Full | Vendor Name. | | TaxTrackedOnPurchases | None | `UNSUPPORTED_BY_DESKTOP` — No Desktop equivalent on the Vendor projection. | | TaxTrackedOnSales | None | `UNSUPPORTED_BY_DESKTOP` — No Desktop equivalent on the Vendor projection. | Everything else listed as full works unchanged. * Active ### Query support Standard `SELECT * FROM TaxAgency` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Create or update** — `UNSUPPORTED_BY_DESKTOP`: TaxAgency is a read-only projection of tax-vendor Vendors. Manage the Vendor (and ItemSalesTax.TaxVendorRef) in Desktop. ## TaxCode **Verdict:** Full · **Read:** Full · **Write:** Full — Desktop SalesTaxCode list (TAX/NON); Name max 3 chars (fail-loud, never truncated); a plain taxable/non-taxable flag pair, not a rate container | QBO field | Support | Notes | |---|---|---| | Name | Full | Desktop limits Name to 3 characters. Longer names return 400 — never truncated. | | Description | Full | Desktop Desc. | | Taxable | Full | Desktop IsTaxable. | | SalesTaxRateList | None | `UNMAPPED_FIELD` — Desktop's US edition (QBD) does not have ItemSalesTaxRef/ItemPurchaseTaxRef on SalesTaxCode — the OSR marks both 'not in QBD' (they exist only for QBCA/QBUK/QBAU). US Desktop SalesTaxCode is just the taxable/non-taxable flag pair; rate association lives on ItemSalesTax items. Read TaxRate for rate values/agency. Never returned on read; writing it faults UNMAPPED\_FIELD. | Everything else listed as full works unchanged. * Active ### Query support Standard `SELECT * FROM TaxCode` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ## TaxRate **Verdict:** Full (read-only) · **Read:** Full · **Write:** None — Read-only (parity with QBO) — Desktop ItemSalesTaxQuery; each single rate item is a TaxRate; sales-tax groups are not projected as TaxRates | QBO field | Support | Notes | |---|---|---| | RateValue | Full | Desktop TaxRate percentage. | | Description | Full | Desktop ItemDesc. | | AgencyRef | Full | Desktop TaxVendorRef — the tax agency Vendor. | Everything else listed as full works unchanged. * Name * Active ### Query support Standard `SELECT * FROM TaxRate` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Create or update** — `UNSUPPORTED_BY_DESKTOP`: TaxRate is read-only in QBO; TenkeyBridge matches that parity. Desktop ItemSalesTax items are managed as Items in the company file. * **ItemSalesTaxGroup as TaxRate** — `UNSUPPORTED_BY_DESKTOP`: Sales-tax groups are not projected as TaxRate rows. Group members are themselves ItemSalesTax list items and appear as individual TaxRates via ItemSalesTaxQuery. ## Term **Verdict:** Partial · **Read:** Full · **Write:** Partial — Merged StandardTerms + DateDrivenTerms; Type STANDARD | DATE\_DRIVEN dispatches which Desktop list; create-only | QBO field | Support | Notes | |---|---|---| | Type | Full | STANDARD ⇄ StandardTerms\*; DATE\_DRIVEN ⇄ DateDrivenTerms\*. Fixed at create — the whole entity is create-only, so this can never change via update. | | DueDays | Full | STANDARD only — Desktop StdDueDays. | | DiscountDays | Full | STANDARD only — Desktop StdDiscountDays. | | DiscountPercent | Full | Both types — Desktop DiscountPct. | | DayOfMonthDue | Full | DATE\_DRIVEN only. | | DueNextMonthDays | Full | DATE\_DRIVEN only. | | DiscountDayOfMonth | Full | DATE\_DRIVEN only. | Everything else listed as full works unchanged. * Name * Active ### Query support Standard `SELECT * FROM Term` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Update any Term field** — `UNSUPPORTED_BY_DESKTOP`: qbXML has no StandardTermsMod or DateDrivenTermsMod — Desktop's OSR only defines Add and Query for both Terms lists. Term write support is create-only. * **Deactivate a Term** — `UNSUPPORTED_BY_DESKTOP`: Deactivation is a Mod-only operation (IsActive via StandardTermsMod/DateDrivenTermsMod), which doesn't exist in qbXML. Impossible via the SDK, not just unimplemented here. ## TimeActivity **Verdict:** Partial · **Read:** Full · **Write:** Partial — Employee time only; Duration compiled from Hours/Minutes or a StartTime/EndTime span | QBO field | Support | Notes | |---|---|---| | NameOf | Partial | Only "Employee" is accepted (or omitted); "Vendor" is rejected — see Not supported. | | EmployeeRef | Full | Required; Desktop EntityRef. Missing value returns 2020. | | ItemRef | Full | Desktop ItemServiceRef. | | Description | Full | Desktop Notes. | | BillableStatus | Partial | Billable, NotBillable, HasBeenBilled; any other value returns 2010. Desktop is stricter than QBO here: "Billable" requires BOTH CustomerRef and ItemRef on the same entry, or QuickBooks rejects the save ("Billable activities must have a customer:job and service item"). Send both, or mark the entry NotBillable. Omitted on create → we emit NotBillable explicitly (QBO's omitted-default); Desktop would otherwise infer Billable from CustomerRef alone. A sparse update that never mentions BillableStatus carries the stored value forward instead — the default only applies to genuinely-absent-on-create. | | Hours | Full | Explicit duration input; wins over StartTime/EndTime when both are present. | | Minutes | Full | Explicit duration input; wins over StartTime/EndTime when both are present. | | StartTime | Full | Alternate duration input paired with EndTime; Desktop only stores the computed Duration, not the span itself. | | EndTime | Full | Alternate duration input paired with StartTime; must be after it, or 2010. | | BreakHours | Partial | Only valid alongside StartTime/EndTime; supplying it with Hours/Minutes returns 2010. | | BreakMinutes | Partial | Only valid alongside StartTime/EndTime; supplying it with Hours/Minutes returns 2010. | | HourlyRate | Partial | Accepted and ignored — Desktop time tracking has no pay-rate override at the API level. | | CostRate | Partial | Accepted and ignored — Desktop time tracking has no pay-rate override at the API level. | | Taxable | Partial | Accepted and ignored — Desktop time tracking has no taxable flag. | | CustomField | Partial | Desktop TimeTrackingQueryRq has no OwnerID element (qbXML 13.0), so custom fields are never returned for time entries. Accepted and ignored on write. | | CurrencyRef | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | ExchangeRate | Partial | Accepted and ignored — Desktop manages this itself; multi-currency mapping lands later. | | PayrollItemRef | None | `UNSUPPORTED_BY_DESKTOP` — Desktop PayrollItemWageRef has no QBO Accounting TimeActivity equivalent (QBO Payroll is a separate product). | | TxnNumber | None | `UNSUPPORTED_BY_DESKTOP` — Desktop's internal auto-increment TxnNumber has no QBO equivalent and is not surfaced. | Everything else listed as full works unchanged. * TxnDate * CustomerRef * ClassRef ### Query support Standard `SELECT * FROM TimeActivity` with the shared WHERE / STARTPOSITION / MAXRESULTS subset documented in [IDs, SyncToken & sparse updates](/guide/concepts#query-support). Anything outside that subset returns `UNSUPPORTED_QUERY`. ### Not supported * **Vendor/contractor time (NameOf: "Vendor", or a VendorRef)** — `UNSUPPORTED_BY_DESKTOP`: Only employee time is supported this release; vendor/contractor time tracking lands in a later TenkeyBridge release. ## CompanyInfo **Verdict:** Full (read-only) · **Read:** Full · **Write:** None — Read-only; Id equals the realm ID, no Desktop write path | QBO field | Support | Notes | |---|---|---| | LegalName | Full | Desktop LegalCompanyName. | | CompanyAddr | Full | Desktop Address. | | CustomerCommunicationAddr | Full | Desktop CompanyAddressForCustomer — an independent address, not a copy of CompanyAddr; absent if Desktop has none on file. | | LegalAddr | Full | Desktop LegalAddress. | | PrimaryPhone | Full | Desktop Phone. | | FiscalYearStartMonth | Full | Desktop FirstMonthFiscalYear; both sides use English month names (live-verified "January"). | | Country | Full | Desktop Address.Country, passed through as free text — no normalization, and often blank on US company files. | | NameValue | None | `UNSUPPORTED_BY_DESKTOP` — No Desktop CompanyRet equivalent — omitted rather than guessed at; never returned. | | CompanyStartDate | None | `UNSUPPORTED_BY_DESKTOP` — No Desktop CompanyRet equivalent — omitted rather than guessed at; never returned. | | SupportedLanguages | None | `UNSUPPORTED_BY_DESKTOP` — No Desktop CompanyRet equivalent — omitted rather than guessed at; never returned. | | DefaultTimeZone | None | `UNSUPPORTED_BY_DESKTOP` — No Desktop CompanyRet equivalent — omitted rather than guessed at; never returned. | | WebAddr | None | `UNSUPPORTED_BY_DESKTOP` — CompanyRet has no URL field — omitted rather than guessed at; never returned. | Everything else listed as full works unchanged. * CompanyName * Email ### Query support * **Any WHERE clause, or STARTPOSITION/MAXRESULTS** — `UNSUPPORTED_QUERY`: CompanyQueryRq takes no filters and always returns the single open company record. Use SELECT \* FROM CompanyInfo. ### Not supported * **Create, update, or delete** — `UNSUPPORTED_BY_DESKTOP`: CompanyInfo is read/query only — there is exactly one company record, and Desktop has no API to create or modify it. * **Any WHERE clause, or STARTPOSITION/MAXRESULTS** — `UNSUPPORTED_QUERY`: CompanyQueryRq takes no filters and always returns the single open company record. Use SELECT \* FROM CompanyInfo. ## Preferences **Verdict:** Partial · **Read:** Partial · **Write:** None — Read-only singleton (Id=1); honest subset of Desktop PreferencesQuery groups | QBO field | Support | Notes | |---|---|---| | AccountingInfoPrefs | Partial | ClassTrackingPerTxn/ClassTrackingPerTxnLine ← IsUsingClassTracking (same bool projected to both); TrackDepartments always false (no Desktop Locations). | | CurrencyPrefs | Partial | MultiCurrencyEnabled ← IsMultiCurrencyOn; HomeCurrency ← HomeCurrencyRef. | | TimeTrackingPrefs | Partial | WorkWeekStartDate ← FirstDayOfWeek only. | | TaxPrefs | Partial | UsingSalesTax true when SalesTaxPreferences is present on PreferencesRet; no other TaxPrefs fields. | | SalesFormsPrefs | Partial | DefaultShipMethodRef; AllowEstimates/UsingProgressInvoicing from JobsAndEstimatesPreferences. DefaultTerms never populated — Desktop SalesAndCustomersPreferences carries no DefaultTermsRef (qbXML 13.0). IsAutoApplyingPayments has no honest QBO SalesFormsPrefs twin — omitted. | | VendorAndPurchasesPrefs | Partial | UsingInventory ← IsUsingInventory only. DaysBillsAreDue / auto-discount have no honest QBO twin. | | ReportPrefs | Partial | ReportBasis ← SummaryReportBasis when Cash or Accrual. | | ProductAndServicesPrefs | None | `UNSUPPORTED_BY_DESKTOP` — No honest PreferencesRet projection — omitted rather than guessed. | | EmailMessagesPrefs | None | `UNSUPPORTED_BY_DESKTOP` — No honest PreferencesRet projection — omitted rather than guessed. | | OtherPrefs | None | `UNSUPPORTED_BY_DESKTOP` — No honest PreferencesRet projection — omitted rather than guessed. | Everything else listed as full works unchanged. ### Query support * **Any WHERE clause, or STARTPOSITION/MAXRESULTS** — `UNSUPPORTED_QUERY`: PreferencesQueryRq takes no filters and always returns the single open company PreferencesRet. Use SELECT \* FROM Preferences. ### Not supported * **Create, update, or delete** — `UNSUPPORTED_BY_DESKTOP`: Preferences is read/query only in QBO and Desktop — there is no PreferencesAdd/Mod. * **Any WHERE clause, or STARTPOSITION/MAXRESULTS** — `UNSUPPORTED_QUERY`: PreferencesQueryRq takes no filters and always returns the single open company PreferencesRet. Use SELECT \* FROM Preferences. ## Budget **Verdict:** Planned · **Read:** None · **Write:** None — qbXML has no Budget records, but budget figures are readable through BudgetSummaryReportQuery — a read-only Budget synthesized from that report is planned; budget writes stay in the Desktop UI forever. Desktop has a real qbXML analog, but TenkeyBridge has **not shipped** this entity yet. Requests return `UNSUPPORTED_BY_DESKTOP` with a link back here until the implementation lands. Tracking: [#101](https://github.com/tenkeybridge/tenkey-bridge/issues/101) ### Query support * **query** — `UNSUPPORTED_BY_DESKTOP`: Not yet implemented. qbXML cannot enumerate budgets — BudgetSummaryReportQuery requires a FiscalYear — so the planned query path probes a bounded fiscal-year window and synthesizes deterministic Ids. See the tracking issue for the design. * *Workaround:* Until the read path ships, pull the budget-vs-actual reports from Desktop directly (or export), not via the Accounting API. ### Not supported * **read** — `UNSUPPORTED_BY_DESKTOP`: Not yet implemented. qbXML has no Budget record API (no BudgetQuery in the qbxmlops130 message set), but BudgetSummaryReportQuery returns budget amounts by account × period (optionally × class or × customer), which is the data QBO's read-only Budget carries. A synthesized read is planned — see the tracking issue. * *Workaround:* Until the read path ships, pull the budget-vs-actual reports from Desktop directly (or export), not via the Accounting API. * **query** — `UNSUPPORTED_BY_DESKTOP`: Not yet implemented. qbXML cannot enumerate budgets — BudgetSummaryReportQuery requires a FiscalYear — so the planned query path probes a bounded fiscal-year window and synthesizes deterministic Ids. See the tracking issue for the design. * *Workaround:* Until the read path ships, pull the budget-vs-actual reports from Desktop directly (or export), not via the Accounting API. * **create** — `UNSUPPORTED_BY_DESKTOP`: Permanent: qbXML has no BudgetAdd. Budget setup stays in the Desktop UI — QBO's own Budget entity is read-only too, so no QBO-compatible client loses anything here. * **update** — `UNSUPPORTED_BY_DESKTOP`: Permanent: qbXML has no BudgetMod. Budget changes stay in the Desktop UI. * **delete** — `UNSUPPORTED_BY_DESKTOP`: Permanent: qbXML has no BudgetDel. ## Attachable **Verdict:** Never · **Read:** None · **Write:** None — QuickBooks Desktop's qbXML API has no attachments interface — the word 'Attachment' does not occur anywhere in the qbxmlops130 message set. This entity has **no honest QuickBooks Desktop equivalent**. Every verb returns `UNSUPPORTED_BY_DESKTOP` — never a silent empty list. ### Query support * **query** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no attachments API over qbXML — Attachable queries always fail loud. ### Not supported * **read** — `UNSUPPORTED_BY_DESKTOP`: Re-audited 2026-07-29: zero occurrences of any attachment message (no AttachmentAdd/Query/Ref of any kind) across the full qbxmlops130 schema. Desktop's Attached Documents feature is UI-only; files attached in Desktop cannot be listed or fetched through TenkeyBridge. * *Workaround:* Store documents in your own system keyed by the TenkeyBridge entity Id — that is what QBD integrations have always had to do. * **query** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no attachments API over qbXML — Attachable queries always fail loud. * **create** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no attachments API over qbXML — attachments cannot be uploaded through TenkeyBridge. * **update** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no attachments API over qbXML — attachments cannot be modified through TenkeyBridge. * **delete** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no attachments API over qbXML — attachments cannot be deleted through TenkeyBridge. ## Department **Verdict:** Never · **Read:** None · **Write:** None — QBO Departments (locations) have no Desktop equivalent — the only 'Department'/'Location' strings in qbXML are free-text fields on Employee, FixedAsset, and Lead, and Desktop's one real dimensional list (Class) is already exposed faithfully as Class. This entity has **no honest QuickBooks Desktop equivalent**. Every verb returns `UNSUPPORTED_BY_DESKTOP` — never a silent empty list. ### Query support * **query** — `UNSUPPORTED_BY_DESKTOP`: qbXML has no Department/Location list to query — confirmed absent from the qbxmlops130 message set. * *Workaround:* Query Class instead if you remap locations client-side; that remapping is your call, TenkeyBridge never reinterprets DepartmentRef silently. ### Not supported * **read** — `UNSUPPORTED_BY_DESKTOP`: Re-audited 2026-07-29 against the qbxmlops130 message set: qbXML has no Department or Location list — no Add/Mod/Query message pair exists. The tokens only appear as plain STRTYPE fields (Employee.Department, FixedAsset.Location, Lead.Location), which are annotations, not a queryable list. * *Workaround:* If your integration treats QBO locations as just another dimension, remap DepartmentRef onto Class in your own code — Class is live in TenkeyBridge. We decided against a built-in Department→Class alias, even opt-in: ClassRef already maps to Class, so aliasing would make two QBO fields silently write one Desktop list and collide when a client sends both. * **query** — `UNSUPPORTED_BY_DESKTOP`: qbXML has no Department/Location list to query — confirmed absent from the qbxmlops130 message set. * *Workaround:* Query Class instead if you remap locations client-side; that remapping is your call, TenkeyBridge never reinterprets DepartmentRef silently. * **create** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no Department create. Use Class if you need a dimensional axis on Desktop. * **update** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no Department update. * **delete** — `UNSUPPORTED_BY_DESKTOP`: Desktop has no Department delete. ## JournalCode **Verdict:** Never · **Read:** None · **Write:** None — France-only QBO regulatory feature; not applicable to Desktop — 'JournalCode' occurs nowhere in the qbxmlops130 message set. This entity has **no honest QuickBooks Desktop equivalent**. Every verb returns `UNSUPPORTED_BY_DESKTOP` — never a silent empty list. ### Query support * **query** — `UNSUPPORTED_BY_DESKTOP`: JournalCode is a France-locale QBO regulatory concept with no Desktop counterpart. ### Not supported * **read** — `UNSUPPORTED_BY_DESKTOP`: Re-audited 2026-07-29: JournalCode is a France-locale QBO regulatory concept (mandatory journal coding for FEC compliance) with zero counterpart in qbXML — US Desktop has no such concept to map. * **query** — `UNSUPPORTED_BY_DESKTOP`: JournalCode is a France-locale QBO regulatory concept with no Desktop counterpart. * **create** — `UNSUPPORTED_BY_DESKTOP`: JournalCode is a France-locale QBO regulatory concept with no Desktop counterpart. * **update** — `UNSUPPORTED_BY_DESKTOP`: JournalCode is a France-locale QBO regulatory concept with no Desktop counterpart. * **delete** — `UNSUPPORTED_BY_DESKTOP`: JournalCode is a France-locale QBO regulatory concept with no Desktop counterpart. ## RecurringTransaction **Verdict:** Never · **Read:** None · **Write:** None — Desktop's memorized transactions are not exposed by qbXML — there is no message to list, read, create, or execute them. This entity has **no honest QuickBooks Desktop equivalent**. Every verb returns `UNSUPPORTED_BY_DESKTOP` — never a silent empty list. ### Query support * **query** — `UNSUPPORTED_BY_DESKTOP`: Memorized transactions cannot be enumerated via qbXML — zero functional API surface. ### Not supported * **read** — `UNSUPPORTED_BY_DESKTOP`: Re-audited 2026-07-29: no MemorizedTxn message of any kind exists in the qbxmlops130 message set. The only trace of the concept in the entire schema is a cosmetic 'memorizedTxn' row label in generic report output — a taxonomy tag, not an access path to a memorized transaction's contents or schedule. * *Workaround:* Manage memorized transactions in the Desktop UI. Transactions they generate ARE visible through TenkeyBridge once QuickBooks posts them — they arrive as ordinary invoices/bills/etc. * **query** — `UNSUPPORTED_BY_DESKTOP`: Memorized transactions cannot be enumerated via qbXML — zero functional API surface. * **create** — `UNSUPPORTED_BY_DESKTOP`: Memorized transactions cannot be created via qbXML; manage them in the Desktop UI. * **update** — `UNSUPPORTED_BY_DESKTOP`: Memorized transactions cannot be updated via qbXML; manage them in the Desktop UI. * **delete** — `UNSUPPORTED_BY_DESKTOP`: Memorized transactions cannot be deleted via qbXML; manage them in the Desktop UI. ## ReimburseCharge **Verdict:** Never · **Read:** None · **Write:** None — QBO's billable-charge object has no Desktop record behind it — in qbXML, billability is a status flag (BillableStatus) on the source expense/item/time lines, never a standalone charge you can query. This entity has **no honest QuickBooks Desktop equivalent**. Every verb returns `UNSUPPORTED_BY_DESKTOP` — never a silent empty list. ### Query support * **query** — `UNSUPPORTED_BY_DESKTOP`: No standalone billable-charge object exists in qbXML to query; billability is line-level state on source transactions. ### Not supported * **read** — `UNSUPPORTED_BY_DESKTOP`: Re-audited 2026-07-29: 'ReimburseCharge' occurs nowhere in the qbxmlops130 message set. Desktop models billability as a BillableStatus enum (Billable / NotBillable / HasBeenBilled) on expense and item lines of purchase transactions and on time entries — there is no separate queryable charge object to project one from. * *Workaround:* Read billable state where Desktop actually keeps it: TimeActivity exposes BillableStatus today, and billable purchase lines live on their source Bill/Purchase transactions. * **query** — `UNSUPPORTED_BY_DESKTOP`: No standalone billable-charge object exists in qbXML to query; billability is line-level state on source transactions. * **create** — `UNSUPPORTED_BY_DESKTOP`: ReimburseCharge is a QBO cloud concept with no Desktop qbXML analog; mark source lines Billable instead. * **update** — `UNSUPPORTED_BY_DESKTOP`: ReimburseCharge is a QBO cloud concept with no Desktop qbXML analog. * **delete** — `UNSUPPORTED_BY_DESKTOP`: ReimburseCharge is a QBO cloud concept with no Desktop qbXML analog. ## TaxClassification **Verdict:** Never · **Read:** None · **Write:** None — Cloud automated-sales-tax concept with no Desktop counterpart — Desktop's tax model is explicit tax items and codes, all of which TenkeyBridge exposes. This entity has **no honest QuickBooks Desktop equivalent**. Every verb returns `UNSUPPORTED_BY_DESKTOP` — never a silent empty list. ### Query support * **query** — `UNSUPPORTED_BY_DESKTOP`: TaxClassification is part of QBO's automated sales tax stack; Desktop uses explicit tax items/codes instead. ### Not supported * **read** — `UNSUPPORTED_BY_DESKTOP`: Re-audited 2026-07-29: 'TaxClassification' occurs nowhere in the qbxmlops130 message set. Desktop's tax machinery is ItemSalesTax / ItemSalesTaxGroup / SalesTaxCode — explicit records, not AST classification codes — and those ARE available through TenkeyBridge as TaxRate, TaxCode, and TaxAgency. * *Workaround:* Use TaxCode/TaxRate/TaxAgency for Desktop's real tax model instead of AST classifications. * **query** — `UNSUPPORTED_BY_DESKTOP`: TaxClassification is part of QBO's automated sales tax stack; Desktop uses explicit tax items/codes instead. * **create** — `UNSUPPORTED_BY_DESKTOP`: TaxClassification cannot be created on Desktop via qbXML. * **update** — `UNSUPPORTED_BY_DESKTOP`: TaxClassification cannot be updated on Desktop via qbXML. * **delete** — `UNSUPPORTED_BY_DESKTOP`: TaxClassification cannot be deleted on Desktop via qbXML. ## TaxService **Verdict:** Never · **Read:** None · **Write:** None — Cloud AST onboarding operation with no Desktop counterpart — 'TaxService' occurs nowhere in the qbxmlops130 message set. This entity has **no honest QuickBooks Desktop equivalent**. Every verb returns `UNSUPPORTED_BY_DESKTOP` — never a silent empty list. ### Query support * **query** — `UNSUPPORTED_BY_DESKTOP`: TaxService is a QBO automated-sales-tax onboarding op with no Desktop API. ### Not supported * **read** — `UNSUPPORTED_BY_DESKTOP`: Re-audited 2026-07-29: TaxService is QBO's automated-sales-tax onboarding endpoint; qbXML has zero AST surface. Desktop tax setup happens through ItemSalesTax/SalesTaxCode records, exposed by TenkeyBridge as TaxRate/TaxCode/TaxAgency. * *Workaround:* Create tax rates and codes through TaxRate/TaxCode instead of the AST TaxService flow. * **query** — `UNSUPPORTED_BY_DESKTOP`: TaxService is a QBO automated-sales-tax onboarding op with no Desktop API. * **create** — `UNSUPPORTED_BY_DESKTOP`: TaxService is a QBO automated-sales-tax onboarding op with no Desktop API. * **update** — `UNSUPPORTED_BY_DESKTOP`: TaxService is a QBO automated-sales-tax onboarding op with no Desktop API. * **delete** — `UNSUPPORTED_BY_DESKTOP`: TaxService is a QBO automated-sales-tax onboarding op with no Desktop API. ## Batch **Verdict:** Full · **Read:** Full · **Write:** Full — Batch envelope — up to 30 create/update/delete/query operations in one request, executed sequentially with per-item faults. This is a **gateway-level platform endpoint** — not an entity. It cannot be queried or CRUD'd through the entity routes. ```http POST /v3/company/{realmId}/batch ``` | QBO field | Support | Notes | |---|---|---| | bId | Full | Required and unique per request; echoed verbatim on each BatchItemResponse slot. | | operation (create | update | delete) | Full | Runs the identical pipeline as the single-shot entity routes — same classification, faults, and result shapes. | | Query items | Full | Full /query parity: the shared WHERE subset, STARTPOSITION/MAXRESULTS, iterator-backed deep pages. | | per-item faults | Full | A failed item returns a Fault slot (with the tkb help block); the rest of the batch still runs. | Everything else listed as full works unchanged. ### Not supported * **more than 30 items** — `BATCH_TOO_MANY_ITEMS`: QBO's own per-request cap, cloned. Split into multiple batch calls. * **optionsData (e.g. void)** — `BATCH_UNSUPPORTED_OPTION`: No wired TxnVoid path against Desktop yet — the item faults loud; the rest of the batch runs. * **transactional rollback** — `UNSUPPORTED_BY_DESKTOP`: Batch is not a transaction (QBO's isn't either): items succeed or fault independently; earlier writes are never rolled back by a later failure. * **entity CRUD or /query on Batch itself** — `UNSUPPORTED_BY_DESKTOP`: Batch is a platform endpoint, not an entity — POST /v3/company/{realmId}/batch. ## CDC **Verdict:** Full (read-only) · **Read:** Full · **Write:** None — Change Data Capture — one poll returns changed + deleted records across entities. This is a **gateway-level platform endpoint** — not an entity. It cannot be queried or CRUD'd through the entity routes. ```http GET /v3/company/{realmId}/cdc?entities=Invoice,Customer&changedSince=2026-07-01 ``` | QBO field | Support | Notes | |---|---|---| | entities | Full | Comma-separated QBO entity names. Unsupported names return per-entity Fault slots; live entities in the same request still return data. | | changedSince | Full | ISO 8601 timestamp or YYYY-MM-DD, at most 30 days back (QBO's own limit). | | deleted-record stubs | Full | status:'Deleted' stubs via Desktop's TxnDeletedQuery/ListDeletedQuery, with TimeDeleted as MetaData.LastUpdatedTime. | Everything else listed as full works unchanged. ### Not supported * **changedSince older than 30 days** — `CDC_INVALID_CHANGED_SINCE`: Hard limit, cloned from QBO. Use /query with a MetaData.LastUpdatedTime filter for older data. * **more than 1,000 objects for one entity** — `CDC_OVERFLOW`: Loud per-entity fault, never silent truncation. Page through /query instead. * **deactivated list records reported as deletions** — `UNSUPPORTED_BY_DESKTOP`: Desktop only hard-deletes unused list records. Active:false is a change, not a deletion — it surfaces as a changed record. * **deleted TaxAgency / ExchangeRate / Transfer detection** — `UNSUPPORTED_BY_DESKTOP`: Tax agencies are vendors on Desktop (deletion indistinguishable); exchange rates ride the Currency list; 'Transfer' is not a valid TxnDelType. ## Reports **Verdict:** Partial · **Read:** Partial · **Write:** None — Five QBO-compatible reports translated live from Desktop's report engine. This is a **gateway-level platform endpoint** — not an entity. It cannot be queried or CRUD'd through the entity routes. ```http GET /v3/company/{realmId}/reports/ProfitAndLoss?start_date=2026-01-01&end_date=2026-12-31 ``` | QBO field | Support | Notes | |---|---|---| | ProfitAndLoss / BalanceSheet / TrialBalance | Full | GeneralSummaryReportQueryRq (ProfitAndLossStandard / BalanceSheetStandard / TrialBalance). Values verbatim from Desktop — no recomputation. | | AgedReceivables / AgedPayables | Full | AgingReportQueryRq (ARAgingSummary / APAgingSummary). Desktop's file-configured aging buckets come back as columns, honestly. | | start\_date / end\_date / date\_macro | Full | ISO dates or QBO date macros (fiscal-relative on both sides, so macros map 1:1). Omit them and you get fiscal year-to-date, matching QBO — Desktop's own bare default is month-to-date, so TenkeyBridge always sends an explicit period. | | report\_date | Full | The aging reports are as-of a single date. Omit it and QBO's default (today) is used. | | accounting\_method | Partial | `REPORT_UNSUPPORTED_OPTION` — Cash/Accrual on the three summary reports. Aging reports reject it — qbXML has no ReportBasis there. | | Column titles | Partial | Desktop's own column titles pass through verbatim, so aging buckets read '> 90' where QBO says '91 and over', and a period column reads 'Jan - Dec 26' where QBO says 'Total'. QBO's MetaData.ColKey has no Desktop equivalent and is not invented. | Everything else listed as full works unchanged. ### Not supported * **columns / customer / vendor / item / class / department / qzurl / adjusted\_gain\_loss** — `REPORT_UNSUPPORTED_OPTION`: Column and filter customization is v2. Desktop returns its standard report layout. * **aging\_period / num\_periods / aging\_method** — `REPORT_UNSUPPORTED_OPTION`: Desktop aging buckets are company-file configuration, not request parameters. * **summarize\_column\_by other than Total** — `REPORT_UNSUPPORTED_OPTION`: qbXML SummarizeColumnsBy exists — clean v2 upgrade path. * **other QBO report names (CashFlow, detail/aging-detail variants, …)** — `REPORT_UNKNOWN`: Planned. The fault lists the five supported reports. --- --- url: https://docs.tenkeybridge.com/guide/gateway-ops.md --- # Gateway ops — deploy, migrate, seed ::: tip Live The hosted gateway at `api.tenkeybridge.com` went live 2026-07-09 (Fly.io `sjc` + Neon `aws-us-west-2`), proven end-to-end against a real QuickBooks Enterprise company file. This page documents the deploy flow that stood it up. ::: The gateway is a single Node/TypeScript process (Fly.io + Neon Postgres) that terminates the QBO-compatible REST API, brokers the agent-plane WebSocket, and runs the OAuth2 code-flow clone. This page covers standing one up: first deploy, DNS + TLS, database migrations, and seeding the first tenant. ## One-time: create the Fly app From the repo root: ```bash cd apps/gateway fly launch --no-deploy ``` This reads `apps/gateway/fly.toml` (app name `tenkeybridge-gateway`, region `sjc` — the nearest live region to the Neon database in `aws-us-west-2`; Fly deprecated `den` and `sea` in mid-2026 — `min_machines_running = 1`, `auto_stop_machines = "off"` — the agent's persistent WebSocket connection needs a machine that's always warm) and creates the Fly app without deploying yet. ## Secrets `DATABASE_URL` is **never** committed to `fly.toml` — it's set as a Fly secret so it isn't visible in the build config or `flyctl config show`: ```bash fly secrets set DATABASE_URL="postgres://:@/?sslmode=require" ``` Use the **pooled** Neon connection string (PgBouncer, port 6543) — the gateway holds one long-lived `pg.Pool` per process, and Fly may run more than one machine. ### Auth secrets (#91 P1) `BETTER_AUTH_SECRET` signs session cookies and tokens. Generate 32+ bytes and set it once — **rotating it invalidates every session**, so treat it as permanent unless it leaks. ```bash fly secrets set --app tenkeybridge-gateway \ BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \ RESEND_API_KEY="" ``` `RESEND_API_KEY` sends the magic-link email; `src/config.ts` requires both of these (and `AUTH_EMAIL_FROM`, below) — the gateway refuses to boot without them, the same as it already refuses to boot without `DATABASE_URL`. Google and GitHub sign-in are optional — set both halves of a pair or set neither: ```bash fly secrets set --app tenkeybridge-gateway \ GOOGLE_CLIENT_ID="..." GOOGLE_CLIENT_SECRET="..." \ GITHUB_CLIENT_ID="..." GITHUB_CLIENT_SECRET="..." ``` `src/config.ts`'s `socialProvider()` treats an id with no matching secret (or vice versa) as absent rather than erroring, so a half-configured pair just means the portal doesn't offer that sign-in button — the gateway still boots and the other provider (or magic-link email) still works. **Nobody is blocked on setting these up**; add them whenever the Google/GitHub OAuth apps exist. Non-secret values (`PORTAL_BASE_URL`, `AUTH_COOKIE_DOMAIN`, `AUTH_EMAIL_FROM`, `GATEWAY_BASE_URL`) live in `fly.toml` `[env]`, not as secrets — they're already there as of #91. ### Admin gate secret (#91 final review) `ADMIN_GATE_USER` / `ADMIN_GATE_PASSWORD` close the entire `/admin/v1` surface (sign-in, sign-up, org CRUD, the api-key endpoints, and the read endpoints) behind HTTP Basic auth — the human partner's call, made because that surface has no self-serve gate of its own until the P4 portal ships. Both are **optional as a pair**: unset entirely, `src/admin/adminGate.ts` skips the gate (this is what keeps local dev, CI, and the test suite green); set only one and `loadConfig()` throws at boot, since a half-configured gate is a deployment mistake, not a safely-degraded state. ```bash fly secrets set --app tenkeybridge-gateway \ ADMIN_GATE_USER="..." \ ADMIN_GATE_PASSWORD="$(openssl rand -base64 24)" ``` The password is a **Fly secret**, never a `fly.toml` `[env]` entry — that's exactly the mistake this gate exists to prevent one layer up. **Remove both once the P4 portal ships** its own account-signup/auth flow — leaving them set past that point just adds a second, forgotten front door. **A valid `x-api-key` bypasses this gate on its own** — no `-u` Basic flag needed — everywhere on `/admin/v1` **except** in front of `/admin/v1/auth/*` (sign-in, sign-up, OAuth callbacks, magic links), which stays Basic-gated unconditionally: an API key must never be a way into the sign-up surface this gate exists to hide. Once you hold an org-scoped key, every other admin call looks like: ```bash curl -s https://api.tenkeybridge.com/admin/v1/realms?orgId=$ORG_ID \ -H "x-api-key: $TKB_API_KEY" ``` See [Admin API](/guide/admin-api) for the full endpoint list and response shapes. **OAuth provider callback URLs** — register these with each provider (Better Auth is mounted at `/admin/v1/auth`, see `src/auth/auth.ts`'s `basePath`): | Provider | Authorized redirect URI | |---|---| | Google | `https://api.tenkeybridge.com/admin/v1/auth/callback/google` | | GitHub | `https://api.tenkeybridge.com/admin/v1/auth/callback/github` | `AUTH_COOKIE_DOMAIN=.tenkeybridge.com` is what lets `portal.tenkeybridge.com` read a session cookie set by `api.tenkeybridge.com`. Leave it unset locally — browsers reject a `Domain` attribute on `localhost`. **Running `db:migrate` or `seed` from a shell** (not on Fly) needs the same required vars in that shell's environment, not just `DATABASE_URL` — `loadConfig()` validates the full config eagerly before either command touches the database. For a one-off migration or seed run, a throwaway 32+ byte `BETTER_AUTH_SECRET` and any non-empty `RESEND_API_KEY` / `AUTH_EMAIL_FROM` are enough; neither command sends email or opens a session. ## Pre-deploy checklist (#91 P1) — completed 2026-08-04 The P1 cutover is done: secrets set, migration rehearsed on a Neon branch, `0002` and `seed backfill-org` run against production, gateway deployed (`v29`), and `0003` (#135, `org_id NOT NULL`) shipped behind it. Kept below as the record of what a schema-plus-data cutover on this gateway involves. 1. **Set secrets** — `BETTER_AUTH_SECRET`, `RESEND_API_KEY` (and `ADMIN_GATE_USER`/`ADMIN_GATE_PASSWORD` if the gate isn't already configured) — see §Secrets above. 2. **Rehearse the migration on a Neon branch** — §Required pre-deploy step, below. Do not skip straight to production. 3. **`db:migrate`** against production — §Run migrations against Neon. 4. **`seed backfill-org`** against production — §The P1 ownership backfill. 5. **Deploy** — `fly deploy`, below. The ordering mattered because P1's SELECTs (`verifyAdminKey`, `getOAuthClient`, `verifyClientSecret`) name `org_id`, and the gateway refuses to boot without `BETTER_AUTH_SECRET` / `RESEND_API_KEY` — deploying before migrating would have taken the live realm's OAuth surface down, or crash-looped the whole service including the agent WebSocket. **The same rule holds for any future migration that adds a column existing code reads: migrate first, deploy second.** For ordinary deploys, `fly.toml`'s `release_command = "pnpm db:migrate"` (runs after build, before the new image serves traffic) handles migrations on its own; re-running an applied migration is a no-op. It never runs a backfill or any other `seed` command — those stay by hand. ## Deploy ```bash fly deploy ``` This builds `apps/gateway/Dockerfile` from the repo root (multi-stage, `node:24-slim`, `pnpm install --prod` — the gateway runs via `tsx` directly, no compile step), runs `release_command` (`pnpm db:migrate`, `[deploy]` in `fly.toml`) against the production database, and only then rolls the new image out to traffic. ## DNS + TLS Point `api.tenkeybridge.com` at the Fly app with a CNAME in Vercel DNS: ``` api.tenkeybridge.com. CNAME tenkeybridge-gateway.fly.dev. ``` Then request the certificate on the Fly side: ```bash fly certs add api.tenkeybridge.com fly certs show api.tenkeybridge.com # poll until status is "Ready" ``` Fly's edge proxy forwards the WebSocket upgrade for `/agent` over the same `internal_port` as the REST traffic — no separate listener or extra Fly config is needed for the agent plane. ## Run migrations against Neon Migrations are drizzle-kit generated SQL, checked into `apps/gateway/drizzle/`. Apply them with the migrate CLI, pointed at the real (non-pooled, for DDL) Neon URL: ```bash DATABASE_URL="postgres://:@/?sslmode=require" \ pnpm --filter @tenkeybridge/gateway db:migrate ``` To regenerate migrations after a schema change: ```bash cd apps/gateway && pnpm db:generate ``` `schema.ts` (Drizzle table definitions) and `applySchema` in `src/store/db.ts` (hand-written idempotent DDL used by the test suite's PGlite instances) are the same schema expressed twice — keep them in agreement and re-run the gateway test suite after any change to either. ### Required pre-deploy step: rehearse on a Neon branch first `db:migrate` runs `drizzle-orm/node-postgres/migrator`, which wraps **every** pending migration in a single transaction. That path has only ever been exercised against PGlite in tests — PGlite doesn't go through the real Postgres migrator, so nothing in the test suite proves this migrator works against real Postgres. Before running `db:migrate` against production, branch Neon's production database (§Staging, below), point `DATABASE_URL` at the branch, and run the full sequence — `db:migrate` then `seed backfill-org` — there first. Confirm the branch ends with `organization`/`user`/`member` tables present, `realms.org_id`/`oauth_clients.org_id` populated for the existing rows, and `org_id` still **nullable** on both, then tear the branch down. Do not treat this as optional or skip straight to production — it is the only place the production data path gets exercised before it runs for real. The gateway test suite cannot be pointed at the branch — every test config hardcodes `pglite://memory`, which is the whole reason this rehearsal exists. Run `pnpm --filter @tenkeybridge/gateway test` as a normal regression check, but to exercise the *migrated* database, boot the gateway itself against the branch (`DATABASE_URL= pnpm --filter @tenkeybridge/gateway dev`) and drive the OAuth flow end to end: `POST /oauth2/v1/authorize` → `POST /oauth2/v1/tokens` → an authenticated `/v3/company/...` call. That covers `getOAuthClient` and `verifyClientSecret` — the two SELECTs this branch changed to name `org_id`. A `503 AGENT_OFFLINE` on the REST call is the expected pass: it means auth cleared and only the agent is absent. **Rehearsed 2026-08-03** on branch `p1-migrator-rehearsal` (a copy of production: 1 realm, 3 oauth\_clients). `0002` applied cleanly in one transaction, adding Better Auth's eight tables and a nullable `org_id`; `backfill-org` attached 1 realm + 3 clients and was a verified no-op on a second run. Credential columns (`admin_key_hash` — since dropped by `0006` — `secret_hash`, `redirect_uris`) hashed identically to production before and after, and the full OAuth flow succeeded against the migrated branch. Branch torn down. ### One-time: the P1 ownership backfill (#91) `realms` and `oauth_clients` gained an owning organization. `0002` adds Better Auth's eight tables plus a **nullable** `org_id` column on both — it does not, and as shipped on this branch *cannot*, make `org_id` `NOT NULL` in the same migration run. `drizzle-orm`'s Postgres migrator applies all pending migrations in one transaction (`node_modules/drizzle-orm/pg-core/dialect.js`), so a `SET NOT NULL` migrated in the same batch as the `ADD COLUMN` would fail on production's existing ownerless row and **roll the `ADD COLUMN` back with it** — leaving no `org_id` column at all and an unrunnable backfill. Tightening `org_id` to `NOT NULL` was therefore deferred to its own later migration and deploy, tracked in [#135]. **Both halves are now done.** This section is kept as the record of a sequence that is finished, not a runbook to re-run: the backfill ran against production on 2026-08-04 (1 realm, 3 `oauth_clients`, 0 rows left `NULL`), and `0003` — the two-statement `SET NOT NULL` — shipped after it. A fresh database gets both from `db:migrate` in order and needs no backfill at all, because every write path has required `org_id` since P1. So there were two steps here, not three, and no schema change happened between them: ```bash export NEON_URL="postgres://...neon.tech/tenkeybridge?sslmode=require" # DIRECT url, not pooled # 1. Better Auth's eight tables + a nullable org_id on realms/oauth_clients DATABASE_URL=$NEON_URL pnpm --filter @tenkeybridge/gateway db:migrate # 2. Create the owning org + owner account and attach every ownerless row. # Idempotent: safe to re-run. DATABASE_URL=$NEON_URL pnpm --filter @tenkeybridge/gateway seed backfill-org \ --org-name "ExampleCo" --org-slug exampleco \ --owner-email owner@exampleco.example --owner-name "Pat Owner" ``` The backfill (`src/store/backfill.ts`) never recreates a realm and never touches `admin_key_hash` (dropped by `0006`), `secret_hash`, or `redirect_uris` — the credentials that work before it work after it. It matches the org on slug and the user on email, and only attaches rows where `org_id` is still `NULL`, so a realm that already belongs to someone is never moved — re-running it after a partial failure, or after it has already succeeded, is a no-op on anything already attached. The owner account it creates has a verified email and no linked provider. Signing in with Google or GitHub at that same address links to it rather than forking a second user, so the first portal login lands on the org that owns the existing realm. [#135]: https://github.com/tenkeybridge/tenkey-bridge/issues/135 ## Seed the first tenant Use the seed CLI (`apps/gateway/src/seed.ts`, exposed as `pnpm gateway:seed` from the repo root — there is no root-level `pnpm seed`, that name only exists as a package script inside `apps/gateway`) against the same `DATABASE_URL`. Realms and OAuth clients are owned by an organization as of #91 P1 — `create-realm` and `create-client` both now require `--org `. If no organization exists yet, `seed backfill-org` (above) creates one and prints its `orgId`; otherwise look the id up in the `organization` table. ```bash # 1. Create a realm (tenant) under an org — prints realmId DATABASE_URL=$NEON_URL pnpm --filter @tenkeybridge/gateway seed create-realm \ --name "Acme Co" --org "$ORG_ID" # 2. Issue an agent token for that realm — the edge agent's appsettings.json needs this DATABASE_URL=$NEON_URL pnpm --filter @tenkeybridge/gateway seed issue-agent-token --realm $REALM_ID # 3. Register an OAuth client for whatever app will call the REST API, also org-owned DATABASE_URL=$NEON_URL pnpm --filter @tenkeybridge/gateway seed create-client \ --name "My App" --org "$ORG_ID" --redirect https://myapp.example.com/oauth/callback ``` Each command prints its secret (`agentToken`, `client_secret`) **once** — store it immediately, it is not retrievable later (only the hash is persisted). `create-realm` no longer takes or prints an admin key — the adminKey/consent-form credential (P1) was fully retired in #91 P2/P3. There is nothing to pass and nothing to store beyond `realmId`. **`gateway:seed` is now the internal / break-glass path only** — the self-serve [Admin API](/guide/admin-api) (`/admin/v1`, P2) does everything above (create a realm, issue an agent token, register a client) for a normal customer, gated by org membership instead of shell access to this database. Use `seed` when you need to provision something before an organization/owner account exists to call the admin API with, or when debugging directly against the database. `seed backfill-org` remains the only way to create an organization from scratch until the portal (P4) ships its own signup flow. Run the OAuth2 code-flow (authorize with `realm_id` → consent → redirect with code → token exchange) against `/oauth2/v1/authorize` and `/oauth2/v1/tokens` to get an `ACCESS_TOKEN` scoped to that realm — see [Authentication](/guide/authentication). ## Milestone proof With an edge agent connected for the realm and a valid access token, this is the end-to-end proof the gateway is live and bridging real QuickBooks data: ```bash curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ https://api.tenkeybridge.com/v3/company/$REALM_ID/customer/$CUSTOMER_ID | jq . ``` ## Logs Every HTTP request — REST or OAuth, success or failure, even an early auth-middleware rejection — produces exactly one structured pino line (`/healthz` is deliberately excluded: Fly polls it continuously and the noise would drown real traffic). This is what "check the Fly logs for that request" should actually show: ```json {"level":40,"method":"GET","path":"/v3/company/:realmId/:entity/:id","status":404,"durationMs":3,"realmId":"4562...","entity":"widget","msg":"gateway http request"} ``` ```json {"level":40,"method":"GET","path":"/v3/company/:realmId/query","status":400,"durationMs":2,"realmId":"4562...","query":"SELECT * FROM Customer MAXRESULTS -5","errorCode":"UNSUPPORTED_QUERY","msg":"gateway http request"} ``` ```json {"level":30,"method":"POST","path":"/oauth2/v1/tokens","status":200,"durationMs":41,"clientId":"tkbcl_...","grantType":"refresh_token","msg":"gateway http request"} ``` Fields: `method`, `path` (the route **template**, e.g. `:entity`/`:id`, not the raw URL — so log lines group cleanly), `status`, `durationMs`, and `realmId`/`entity` when the route carries them. A REST `query` request additionally logs the raw query text (the thing you need to diagnose an `UNSUPPORTED_QUERY` 422/400 without guessing), and any 4xx/5xx logs an `errorCode` pulled from the response body's fault/error code. OAuth requests log `grantType`/`responseType`/`clientId` when known. Level is `info` for 2xx/3xx, `warn` for 4xx, `error` for 5xx. **Hard constraint:** never a request/response body, an `Authorization` header, a token, an auth code, or a client secret — `fly logs` is safe to paste into an issue. ### Durable sink: Axiom `fly logs` is **lossy** — Fly's NATS-based log pipeline silently drops lines under normal operation (observed live, [#53]), and keeps no history. The source of truth is Axiom: when the `AXIOM_TOKEN` + `AXIOM_DATASET` secrets are set, the gateway ships every log line directly to Axiom over HTTPS (via `@axiomhq/pino`), bypassing Fly's pipeline. stdout stays wired in parallel, so `fly logs` still works for casual live-tailing — just never treat a *missing* line there as evidence the request didn't happen; query Axiom instead. [#53]: https://github.com/tenkeybridge/tenkey-bridge/issues/53 * **Where:** [app.axiom.co](https://app.axiom.co), org `empire-innovations`, dataset `tkb-gateway` (free tier: 30-day retention, 500 GB/mo — orders of magnitude above gateway volume). * **Setup:** create an ingest-only API token scoped to the dataset (Axiom → Settings → API tokens), then: ```bash fly secrets import -a tenkeybridge-gateway # paste, then Ctrl-D: # AXIOM_TOKEN=xaat-... # AXIOM_DATASET=tkb-gateway ``` * **Rotation:** revoke the token in Axiom, create a new one, re-run the import. With either var unset the gateway logs to stdout only (local dev, tests, and CI never touch Axiom). * **Querying:** the `msg` field is `gateway http request`; filter on `path`, `status`, `realmId`, or `errorCode`. Example APL: ``` ['tkb-gateway'] | where path contains "companyinfo" | sort by _time desc ``` ## Staging Staging E2E runs against a **Neon branch** database seeded with throwaway tenant data — not a second Fly app. Branch Neon's main database, point a local or preview gateway process at the branch's connection string via `DATABASE_URL`, run migrations and seed as above, and tear the branch down when done. --- --- url: https://docs.tenkeybridge.com/reference/error-codes.md --- # Error codes A developer hitting a TenkeyBridge error should never wonder what happened. Every fault carries three layers of help: 1. **The QBO-compatible `Fault`** — the exact shape the QuickBooks Online API uses, so existing QBO SDKs and error handlers keep working unchanged. Branch on `code`. 2. **A `tkb` block** — TenkeyBridge's enrichment: likely **causes**, concrete **fixes**, and a **docsUrl** deep-linking to that code's section on this page. It sits *outside* `Fault`, in a top-level field QBO clients ignore. 3. **A docs link in `Detail`** — so even if your SDK only surfaces `Message`/`Detail`, the trail to the full story is still in front of you. ## The fault envelope {#envelope} ```jsonc { "Fault": { "Error": [{ "code": "UNSUPPORTED_BY_DESKTOP", "Message": "Unsupported by QuickBooks Desktop", "Detail": "Sparse update of Line is not supported yet for Bill; line-level updates are currently supported on sales forms (Invoice, Estimate, SalesReceipt, CreditMemo) only. Re-create the transaction or update header fields only. See https://docs.tenkeybridge.com/reference/error-codes.html#unsupported_by_desktop", "element": "Bill.Line" }], "type": "ValidationFault" }, "time": "2026-07-24T00:00:00.000Z", "tkb": { "code": "UNSUPPORTED_BY_DESKTOP", "causes": [ "The field, line type, item type, or operation you sent exists in QuickBooks Online but has no equivalent in QuickBooks Desktop's qbXML API — or the whole entity is a documented Desktop gap." ], "fixes": [ "Remove or replace the unsupported value; the fault's element and Detail name exactly what triggered it.", "Check the entity's section on https://docs.tenkeybridge.com/compatibility/ for the full list of supported fields and operations." ], "docsUrl": "https://docs.tenkeybridge.com/reference/error-codes.html#unsupported_by_desktop" } } ``` * `Fault.Error` is always an array (currently always length 1); `code` is the value to branch on; `element` (when present) names the field that triggered the fault. * `Fault.type` is `ValidationFault` for anything you can fix by changing the request or the company file, `SystemFault` for TenkeyBridge-side failures (HTTP 5xx). * `tkb` is additive and versioned with the codes themselves (`ERROR_CODES_VERSION = "2026-07"`). Never *required* for correct handling — branch on `code`; read `tkb` when a human needs to know what to do next. The string codes below are **TenkeyBridge's own**, part of the public API surface — their meaning won't change under you between versions. [Numeric codes](#numeric-codes) carry the same meaning they have in the QuickBooks Online API. ## Translation codes Raised when a request can't be translated honestly to QuickBooks Desktop. These are almost always fixable by changing the request. ### UNSUPPORTED\_BY\_DESKTOP {#unsupported\_by\_desktop} **HTTP 422** · You sent a field, line type, item type, operation, or whole entity that has no QuickBooks Desktop equivalent. **Causes** * The field or operation exists in QuickBooks Online but qbXML (Desktop's API) has no way to express it — a permanent platform gap, not a missing TenkeyBridge feature. * The entity itself is a documented gap (Attachable, Budget, …) or not yet shipped in TenkeyBridge. Entity-level responses link straight to that entity's section on the [compatibility page](/compatibility/). **Fixes** * Remove or replace the value — the fault's `element` and `Detail` name exactly what triggered it. * Check the entity's section on the [compatibility page](/compatibility/) for the full list of supported fields and operations, and the closest supported alternative. * For Desktop-backed realms, branch on this code and degrade gracefully (skip the feature) rather than retrying — the same request will fail the same way every time. ```jsonc { "Fault": { "Error": [{ "code": "UNSUPPORTED_BY_DESKTOP", "Message": "Unsupported entity", "Detail": "Item create/update lands in a later TenkeyBridge release. Pull items with read/query for now. See https://docs.tenkeybridge.com/reference/error-codes.html#unsupported_by_desktop", "element": "Item" }], "type": "ValidationFault" } } ``` ### UNSUPPORTED\_QUERY {#unsupported\_query} **HTTP 400/422** · Your `query` uses a clause TenkeyBridge can't translate into a reliable Desktop query. **Causes** * Multi-condition `WHERE` (`AND`/`OR`), any `ORDER BY`, or aggregate selects (`COUNT`, …). * A filter on a field Desktop can't index, or `STARTPOSITION` paging past what Desktop can serve for that query shape. **Fixes** * Simplify to a single supported filter: `Id`, a date range on `TxnDate` or `MetaData.LastUpdatedTime`, `DocNumber`, or `Active` — then filter and sort the rest client-side. * On `Customer`, dedup lookups are also supported directly: `PrimaryEmailAddr`, `CompanyName`, `DisplayName`, `GivenName`, `FamilyName` (each alone), or `GivenName` + `FamilyName` together — see the Query support notes on the [compatibility page](/compatibility/) for case-insensitivity and performance caveats. ### UNMAPPED\_FIELD {#unmapped\_field} **HTTP 422** · The payload contains a field TenkeyBridge doesn't recognize for this entity. **Causes** * A typo in a field name, a QBO minor-version field, or a field that belongs to a different entity. Distinct from [`UNSUPPORTED_BY_DESKTOP`](#unsupported_by_desktop): *unmapped* means "not a known field at all", *unsupported* means "known, but Desktop can't do it". **Fixes** * Drop the field or fix the spelling; the entity's field table on the [compatibility page](/compatibility/) lists every accepted field. ### UNMAPPED\_ACCOUNT\_TYPE {#unmapped\_account\_type} **HTTP 422** · The `AccountType` (or `AccountSubType`) value has no QuickBooks Desktop account-type equivalent. **Fixes** * Use one of the Desktop-mappable account types listed in the Account section of the [compatibility page](/compatibility/); the fault's `Detail` names the closest supported type when there is one. ### MISSING\_REQUIRED\_DESKTOP\_ITEM {#missing\_required\_desktop\_item} **HTTP 422** · You referenced something QuickBooks Desktop models as an item in the company file, but the request doesn't point at one that exists. **Causes** * Most commonly a bare-percentage or amount-off discount with no `ItemRef` — Desktop discounts *are* discount items; there is no "anonymous discount". **Fixes** * Create the item in the Desktop company file, then reference it (e.g. `DiscountLineDetail.ItemRef`). ## Gateway codes Raised by the TenkeyBridge gateway before or after translation — routing, auth, and the connection to the agent running next to QuickBooks Desktop. ### NOT\_FOUND {#not\_found} **HTTP 404** · The URL doesn't match any TenkeyBridge route, or the entity segment isn't a known QuickBooks entity name at all (typo / unknown string). Known-but-unsupported entities return [`UNSUPPORTED_BY_DESKTOP`](#unsupported_by_desktop) instead. **Fixes** * Check the path shape (`/v3/company/{realmId}/{entity}`) and the entity spelling against the [entity matrix](/compatibility/). ### AUTHENTICATION\_FAILED {#authentication\_failed} **HTTP 401** · The `Authorization` header is missing, isn't a Bearer token, or the access token is invalid or expired. **Causes** * Access tokens live **60 minutes** — the most common cause is simply an expired token. **Fixes** * Send `Authorization: Bearer `. * When the access token expires, exchange your refresh token at `/oauth2/v1/tokens` (`grant_type=refresh_token`) for a fresh pair — and store the **new** refresh token; refresh tokens rotate on every use. ### AUTHORIZATION\_FAILED {#authorization\_failed} **HTTP 403** · The access token is valid but was issued for a different realm than the one in the URL path. **Fixes** * Use the `realmId` returned in your OAuth callback together with the tokens minted for it — realm and token travel as a pair. * If you meant to talk to a different company file, run the connect flow again for that realm. ### QUERY\_PARSER\_ERROR {#query\_parser\_error} **HTTP 400** · The `query` parameter is missing or empty. **Fixes** * Pass a QBO-SQL statement in the `query` parameter, URL-encoded: `GET /v3/company/{realmId}/query?query=SELECT%20*%20FROM%20Customer`. ### BAD\_REQUEST {#bad\_request} **HTTP 400** · The request doesn't form a valid operation. **Causes** * A non-JSON request body, an unsupported `?operation=` value, or an update/delete without the record's `Id`. **Fixes** * The `Detail` names the exact problem; correct the request shape and resend. Updates and deletes need a JSON body carrying the entity's `Id` (and `SyncToken`). ### AGENT\_OFFLINE {#agent\_offline} **HTTP 503** · No TenkeyBridge agent is connected for this realm right now. **Causes** * The Windows machine hosting QuickBooks Desktop is off or asleep, the agent isn't running, or its network path to the gateway is down. **Fixes** * Start the agent on the machine hosting QuickBooks Desktop and retry — requests succeed as soon as it reconnects. * Nothing is queued: this request was **not** executed, so it's always safe to retry. * If the realm polls unattended (e.g. overnight), treat 503 as "come back later", not as a failure. ### AGENT\_TIMEOUT {#agent\_timeout} **HTTP 504** · The agent is connected but QuickBooks Desktop didn't answer in time. **Causes** * A modal dialog open in QuickBooks on the host machine (the #1 culprit), a very large request, or the company file busy with another operation. **Fixes** * Dismiss any open dialog in QuickBooks on the host machine, then retry. * Break very large queries into pages. * ⚠️ The request may still have applied after the gateway gave up — **re-query before retrying a write** to avoid duplicates. ### AGENT\_EXECUTION\_ERROR {#agent\_execution\_error} **HTTP 502** · The agent reached the machine but the request failed at the QuickBooks/COM layer. The raw Windows-side error is kept in the agent and gateway logs (it can contain machine-local detail that doesn't belong in an API response). **Causes** * QuickBooks isn't running, or no company file is open. * A QuickBooks login or authorization dialog is blocking access. * The agent and `QBW.exe` run at different Windows integrity levels — they must match (run **both** non-elevated). **Fixes** * On the host machine: open QuickBooks with the company file, run both QuickBooks and the agent non-elevated, and check the agent log for the exact underlying error. ### INTERNAL\_ERROR {#internal\_error} **HTTP 500** · An unexpected error inside the TenkeyBridge gateway — not your request and not QuickBooks. **Fixes** * Retry once; if it persists, report it along with the response's `time` field so it can be correlated with the gateway logs. ### CDC\_INVALID\_ENTITIES {#cdc\_invalid\_entities} **HTTP 400** · The `entities` parameter is missing, empty, or not a comma-separated list of QBO entity names. **Causes** * No `entities` parameter, an empty one, or a value that isn't a comma-separated list (e.g. `entities=Invoice,Customer,Bill`). **Fixes** * Pass `entities` as a comma-separated list of entity names. Unsupported names don't fail the request — they come back as per-entity `Fault` slots — but the parameter itself must be present and non-empty. ### CDC\_INVALID\_CHANGED\_SINCE {#cdc\_invalid\_changed\_since} **HTTP 400** · `changedSince` is missing, unparseable, or outside the 30-day CDC look-back window. **Causes** * `changedSince` isn't a parseable ISO 8601 timestamp or `YYYY-MM-DD` date. * `changedSince` is older than the 30-day CDC look-back window — the same limit QuickBooks Online enforces; it also bounds worst-case Desktop query cost. **Fixes** * Pass `changedSince` as an ISO 8601 timestamp (`2026-07-01T00:00:00Z`) or bare date (`2026-07-01`) within the last 30 days. * Syncing older data? Do a full walk with `/query` and a `MetaData.LastUpdatedTime` filter instead — CDC is for incremental polling. ### CDC\_OVERFLOW {#cdc\_overflow} **HTTP 400** · More than 1,000 objects changed for one entity in the requested window. **Causes** * A CDC slot never silently truncates, so once an entity crosses 1,000 changed objects in the window it returns this fault instead of a partial array. **Fixes** * Shorten the `changedSince` window and poll more frequently. * Or walk this entity with `/query` using a `MetaData.LastUpdatedTime` filter plus `STARTPOSITION`/`MAXRESULTS` pagination — that path has no object cap. ### BATCH\_INVALID\_REQUEST {#batch\_invalid\_request} **HTTP 400** · The batch envelope or one of its items is structurally malformed. **Causes** * `BatchItemRequest` is missing, empty, or not an array; a missing or duplicate `bId`; an item that isn't exactly one `Query` or one entity payload; a missing or unknown operation; or an operation/`Id` combination that contradicts itself (create with an `Id`, update/delete without one). **Fixes** * Nothing executed — a malformed envelope never half-runs. The fault `Detail` names the offending item by `bId` or index; fix that item's shape and resend the whole batch. ### BATCH\_TOO\_MANY\_ITEMS {#batch\_too\_many\_items} **HTTP 400** · The batch carries more than 30 items. **Causes** * More than 30 items in one batch — the same per-request cap QuickBooks Online enforces. **Fixes** * Split the work into multiple batch calls of at most 30 items each. Items execute sequentially either way, so splitting costs no extra Desktop round trips. ### BATCH\_UNSUPPORTED\_OPTION {#batch\_unsupported\_option} **HTTP 200** (per-item fault slot — the batch request itself is not a 400) · The item carries an unsupported `optionsData` value. **Causes** * The item carries `optionsData` (e.g. `"void"`) — TenkeyBridge has no wired `TxnVoid` path against Desktop yet, and guessing at void semantics would be dishonest. **Fixes** * Drop `optionsData` from the item; only this item faulted — the rest of the batch still ran. Track void support on the compatibility page. ### REPORT\_UNKNOWN {#report\_unknown} **HTTP 422** · The report name in the URL is not one TenkeyBridge serves from Desktop. **Causes** * The report name is not one of the five reports TenkeyBridge serves from QuickBooks Desktop. QuickBooks Online's report catalogue is much larger; the rest — `CashFlow`, the detail variants, and the aging-detail reports — are planned, not shipped. **Fixes** * Use one of `ProfitAndLoss`, `BalanceSheet`, `TrialBalance`, `AgedReceivables`, or `AgedPayables`. Names match case-insensitively. * Check the [compatibility page](https://docs.tenkeybridge.com/compatibility/#reports) for the current report coverage before adding a new report call. ### REPORT\_UNSUPPORTED\_OPTION {#report\_unsupported\_option} **HTTP 422** · The request carries a report parameter Desktop cannot honour. **Causes** * The request carries a QuickBooks Online report parameter that Desktop's report engine cannot honour — a column/filter customisation, an aging knob that lives in company-file preferences rather than the request, or `accounting_method` on an aging report (qbXML's `AgingReportQueryRq` has no `ReportBasis` element). **Fixes** * Drop the parameter and read the standard Desktop layout; the fault's `element` names exactly which one failed. * For aging buckets, change them in QuickBooks under **Edit > Preferences > Reports & Graphs** — Desktop returns whatever the company file is configured with, as the report's columns. * For column or filter customisation, fetch the full report and narrow it client-side. ### REPORT\_INVALID\_DATE {#report\_invalid\_date} **HTTP 400** · A report date or date macro was not usable. **Causes** * A report date was not a real calendar date in `YYYY-MM-DD` form, `start_date` and `end_date` were not supplied together, `start_date` fell after `end_date`, or `date_macro` was not one of QuickBooks Online's date macros. **Fixes** * Send `start_date` and `end_date` together as `YYYY-MM-DD`, or send `date_macro` alone — not a partial pair. * For the aging reports use `report_date` (a single as-of date), not `start_date`/`end_date`. * Omit the dates entirely to get the fiscal year-to-date window, which is what the same call returns from QuickBooks Online. ## Numeric codes {#numeric-codes} Numeric codes carry the same meaning they have in the QuickBooks Online API, so existing QBO error-handling logic keeps working unchanged. They come from three places: mapped from QuickBooks Desktop status codes when Desktop itself rejects the request (`610`, `5010`, `6240`, `2500`, and `2020` from Desktop's 3070); raised by TenkeyBridge's own validation before the request ever reaches Desktop (`2010`, `2020`); and `500` for an internal TenkeyBridge failure. ### 610 — Object not found {#610} **HTTP 400** · No record with the given `Id` exists in the company file. **Causes** * The record was deleted in QuickBooks, or the `Id` belongs to a different realm. * Desktop Ids differ from QBO Ids for the same logical record — an Id carried over from a QBO integration will never match. **Fixes** * Re-query for the record to get its current Id in this realm. ### 5010 — Stale object {#5010} **HTTP 400** · Optimistic-concurrency conflict: the `SyncToken` you sent is stale (Desktop's `EditSequence`). **Causes** * A user or another integration modified the record in QuickBooks after you read it. **Fixes** * `GET` the record again, take the fresh `SyncToken`, and re-apply your change. ```jsonc { "Fault": { "Error": [{ "code": "5010", "Message": "Stale Object Error", "Detail": "QuickBooks Desktop: The provided edit sequence is out-of-date. See https://docs.tenkeybridge.com/reference/error-codes.html#5010" }], "type": "ValidationFault" } } ``` ### 6240 — Duplicate name exists {#6240} **HTTP 400** · A list record with this name already exists. **Causes** * Desktop names (`Customer`, `Vendor`, `Item`, …) must be unique per list — including **inactive** records, which is the case that usually surprises QBO-first integrations. **Fixes** * Use the existing record, pick a different name, or rename/reactivate the conflicting record in QuickBooks. ### 2500 — Invalid reference {#2500} **HTTP 400** · A `*Ref` you sent (`CustomerRef`, `ItemRef`, `AccountRef`, …) points at a record that doesn't exist in the company file. **Fixes** * Query for the referenced record first and use its current Id; create it if it doesn't exist yet. ### 2010 — Invalid field value {#2010} **HTTP 422** · A field value fails validation — wrong type, an unparseable date or number, or a value outside what Desktop accepts. **Fixes** * The `element` and `Detail` name the field; correct the value and resend. ### 2020 — Required parameter missing {#2020} **HTTP 400/422** · A field the operation requires is missing — either one QBO requires, or one QuickBooks Desktop *additionally* requires (the `Detail` says which). **Fixes** * Add the named field and resend. Desktop-only requirements are also flagged per entity on the [compatibility page](/compatibility/). ### 500 — System failure {#500} **HTTP 500, `SystemFault`** · TenkeyBridge hit an internal integrity error translating Desktop's response — for example a returned record missing its `ListID` or `EditSequence`. This is a TenkeyBridge bug, not a problem with your request. **Fixes** * Retry once; if it persists, report it with the response's `time` field. ## Desktop status codes {#desktop-status-codes} Any QuickBooks Desktop status code without a QBO mapping passes through **untouched** as the fault's `code` (message `Business Validation Error`), so no error is ever swallowed — and the raw QuickBooks message is always preserved in `Detail`, prefixed `QuickBooks Desktop:`. For the notoriously cryptic common ones, the `tkb` block explains what QuickBooks actually means: | Desktop code | What QuickBooks means | What to do | |---|---|---| | `3000` | The record id is malformed for this entity — Desktop `ListID`s/`TxnID`s have entity-specific formats, so a QBO id or guessed value won't parse. | Use an Id previously returned by TenkeyBridge for this entity. | | `3170` | The record could not be modified — commonly a value the QuickBooks UI would also refuse, or the record is held by another user or open window. | Read the raw message in `Detail`; close the record's window in QuickBooks, or try single-user mode. | | `3175` | The record is locked by another QuickBooks user or an open window. | Close the record's window; retry after the other session finishes. | | `3180` | General save error — frequent culprits are sales-tax configuration conflicts or lines referencing accounts/items that changed mid-save. | Read the raw message in `Detail`; it usually names the list or field involved. | | `3250` | The feature the request needs is not enabled in this company file (inventory, sales tax, multicurrency, …). | Enable the feature in QuickBooks preferences, or drop the fields that need it. | | `3260` | The QuickBooks user the agent session runs as lacks permission for this action. | Grant the role the permission (Company → Set Up Users and Passwords), or run the agent session as a user with sufficient rights. | | `-1` | QuickBooks returned a response TenkeyBridge could not parse a status code from. | Check the agent log on the host machine; report the fault with its `Detail`. | Mapped Desktop codes (`3100`→`6240`, `3120`→`610`, `3200`→`5010`, `3140`→`2500`, `3070`→`2020`) surface as their [numeric QBO codes](#numeric-codes) above. ## OAuth errors {#oauth-errors} The OAuth endpoints (`/oauth2/v1/*`) speak [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2), not the QBO Fault shape — errors come back as `{ "error", "error_description", "error_uri" }`. `error` is the stable value to branch on; `error_description` says what actually went wrong. | `error` | HTTP | What happened | What to do | |---|---|---|---| | `invalid_client` | 401 | Unknown `client_id` or wrong `client_secret`. | Send HTTP Basic auth with `client_id` as username, `client_secret` as password. | | `invalid_grant` (code exchange) | 400 | The authorization code is invalid, expired, **already used**, or was issued to a different client / `redirect_uri`. | Codes are single-use, and the token request's `redirect_uri` must exactly match the authorize request's. Restart the connect flow for a fresh code. | | `invalid_grant` (refresh) | 400 | The refresh token is invalid, expired, revoked, or already rotated. | Every refresh returns a **new** refresh token — always store the latest. If the chain is lost, reconnect. | | `unsupported_grant_type` | 400 | `grant_type` isn't one TenkeyBridge supports. | Use `authorization_code` (first exchange) or `refresh_token` (renewal). | | `unsupported_response_type` | 400 | `/authorize` called without `response_type=code`. | TenkeyBridge implements the authorization-code flow only. | See the [authentication guide](/guide/authentication) for the full connect flow. --- --- url: https://docs.tenkeybridge.com/public/brand/README.md --- # Docs public brand mirror Served by VitePress as **`/brand/...`**. Copied from repo-root `brand/web/`. When masters change, re-export into `brand/web/` and refresh this folder.