Appearance
Node.js client
If you already have a QuickBooks Online integration, you don't need a client library — swap the base URL 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/clientRequires 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: 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 — 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! });Voiding
New in client 0.3.0. invoice, salesReceipt, purchase and billPayment also have void (POST ?operation=void). SyncToken is required; a stale one fails with 5010. It takes the same { requestId } option as create/update/delete, and resolves to the post-void record as re-read from QuickBooks:
ts
import { isVoided } from "@tenkeybridge/client";
const voided = await client.invoice.void({ Id: "42", SyncToken: "3" });
isVoided(voided); // trueOn QuickBooks Desktop a void leaves the record in place with its totals zeroed and PrivateNote set to VOID: or VOID: <original memo> (checks also get GJE, RGJE created on MM/DD/YYYY appended, the date being the void date). isVoided(txn) is true only when TotalAmt is 0 and PrivateNote starts with VOID:. It is a positive signal only: false means "not known to be voided", not "definitely live".
Typed against the matrix
The per-entity helpers and TypeScript types are generated from the same compatibility catalog that gates the gateway, so the client can't promise more than the API delivers:
- Only the 33 live entities get accessors (
client.customer,client.salesReceipt, …). - Read-only entities (Employee, Preferences, TaxRate, …) expose
get/querybut nocreate/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.
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");Values in where
where is a raw string, so user-supplied values must be escaped. qboString() returns a quoted literal with \ and ' backslash-escaped (O'Brien becomes 'O\'Brien'):
ts
import { qboString } from "@tenkeybridge/client";
const page = await client.query("Customer", {
where: `DisplayName = ${qboString(name)}`,
});The escapes need the matching gateway change (its query parser accepting \' and \\), which deploys together with this client release; an older gateway rejects \' as an unsupported query, and would take a lone \ literally. Upgrade the gateway before (or together with) using qboString on values that may contain quotes or backslashes. Plain where strings keep working unchanged.
where takes AND-joined conditions (=, !=, <, >, <=, >=, IN, LIKE; there is no OR). See Query support for the full language (ORDERBY, COUNT(*), column lists) and for which conditions QuickBooks filters itself versus which TenkeyBridge applies after fetching (capped at 5,000 records).
Errors
API failures throw TenkeyBridgeApiError carrying the QBO Fault plus TenkeyBridge's tkb hint block — stable error code, 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);
}
}Safe retries
From client 0.3.0, pass a requestId (QuickBooks Online's requestid) on create, update and delete and a retry after a timeout returns the original result instead of writing twice:
ts
import { randomUUID } from "node:crypto";
const requestId = randomUUID(); // one id per logical write, reused on every retry
const invoice = await client.invoice.create(payload, { requestId });409 REQUEST_IN_FLIGHT (wait retryAfterMs, resend) and 502 REQUEST_OUTCOME_UNKNOWN (look the record up) are the two outcomes to handle; both keep definitelyNotAppliedfalse. Keep the same requestId when you resend. See Safe retries.
Rate limits and retry guidance
The client never retries for you. On a 429 the gateway sends Retry-After, exposed as err.retryAfter (raw header) and err.retryAfterMs (parsed from delta-seconds or HTTP-date; undefined if absent or invalid).
err.definitelyNotApplied is true only when the request was certainly not executed (400, 401, 402, 403, 429, 503 AGENT_OFFLINE, 502 QB_COMPANY_FILE_UNAVAILABLE). When it is false (for example a 504 timeout, another 503, or another 502), a write may have landed: query for the record before retrying. The gateway raises AGENT_OFFLINE only before sending; a request lost mid-flight (the connection dropped or the gateway restarted) is a 504 AGENT_TIMEOUT. A true value is not advice to retry as-is; a 400 needs a corrected request. To make a write retry-safe, see above.
ts
if (err instanceof TenkeyBridgeApiError && err.status === 429) {
await sleep(err.retryAfterMs ?? 1000);
}OAuth failures (expired refresh token, bad credentials) throw TenkeyBridgeOAuthError with the OAuth error code (invalid_grant, …).
Where to go next
- Authentication — getting credentials and the OAuth flow.
- Entity matrix — what's live, field by field.
- Error codes — every stable code and its fix.

