Skip to content

quickbooks-online-kit

PieceWhat it does
createQuickBooks()Wires everything from one config object
OAuthAuthorize URL, callback parsing, code exchange, refresh, revoke; signed state for CSRF protection; realm id from the callback
TokenStoreTiny get/set/delete interface. MemoryTokenStore, FileTokenStore (atomic, 0600), SqliteTokenStore (any better-sqlite3 / node:sqlite-shaped handle)
TokenManagerSingle-flight refresh, rotation persisted before use, adopts tokens another process already rotated, optional cross-process lock hook
QboClientFind-or-create customer, sparse update, items and price map (paginated), estimate, estimate → invoice (with a double-invoice guard), sales receipt, record payment, raw request() / query()
ErrorsQboError with the parsed Fault, QboReconnectRequiredError, QboNotConnectedError
AuditAuditLog interface, memory and JSONL implementations, summarizeHealth()
/nextWeb-standard Request/Response route handlers: connect, callback, status, test, disconnect, audit
/react<QboConnectionStatus /> and the presentational <QboConnectionStatusView />
/testingstartFakeQbo(): an in-process fake of Intuit’s OAuth and the REST endpoints above
Terminal window
npm install @27colab/quickbooks-online-kit
  1. Sign in at developer.intuit.com and create an app with the QuickBooks Online and Payments scope com.intuit.quickbooks.accounting.
  2. Under Keys & credentials → Development, copy the Client ID and Client secret.
  3. Add a Redirect URI that exactly matches the one you pass to the kit, such as http://localhost:3000/api/quickbooks/callback.
  4. A sandbox company is created for you under Sandbox. Intuit’s production keys require completing its app assessment, which is outside this library.

Anything that is not exactly production / prod resolves to sandbox. Stored tokens remember the environment they were issued for and are ignored if you switch, so sandbox tokens are never sent to the other environment.

This sample points the kit at the in-process fake, plays the consent screen, then creates a customer and an estimate, converts it to an invoice, records a payment, and forces a server-side token expiry to show the single refresh-and-retry.

samples/src/qbo-fake.ts
import { createQuickBooks, MemoryTokenStore, QboError } from '@27colab/quickbooks-online-kit';
import { startFakeQbo } from '@27colab/quickbooks-online-kit/testing';
const fake = await startFakeQbo();
try {
const qbo = createQuickBooks({
clientId: fake.clientId,
clientSecret: fake.clientSecret,
redirectUri: 'http://localhost:3000/api/quickbooks/callback',
stateSecret: 'any-long-random-string',
store: new MemoryTokenStore(),
endpoints: fake.endpoints, // point the kit at the in-process fake
});
// The connect flow, with the "consent screen" played by the fake.
const state = new URL(qbo.connectUrl()).searchParams.get('state')!;
const cb = new URL('http://localhost:3000/api/quickbooks/callback');
cb.searchParams.set('code', fake.issueAuthCode());
cb.searchParams.set('state', state);
cb.searchParams.set('realmId', fake.realmId);
await qbo.handleCallback(cb.toString());
const status = await qbo.status();
console.log('connected:', status.connected, status.environment);
const { client } = qbo;
const { customer } = await client.findOrCreateCustomer({ givenName: 'Ada', familyName: 'Example', email: 'ada@example.com' });
fake.db.Item!.push({ Id: '900', Name: 'Consulting', UnitPrice: 150, Type: 'Service', Active: true });
const prices = await client.buildPriceMap();
const consulting = prices['Consulting']!;
const estimate = await client.createEstimate({
customerId: customer.Id,
lines: [{ itemId: consulting.id, qty: 2, unitPrice: consulting.unitPrice }],
});
const invoice = await client.convertEstimateToInvoice(estimate.Id);
await client.convertEstimateToInvoice(estimate.Id).catch((e) => console.log('second conversion:', e instanceof QboError ? e.code : e));
await client.recordPayment({ customerId: customer.Id, invoiceId: invoice.Id, amount: invoice.TotalAmt });
console.log('invoice total', invoice.TotalAmt);
fake.expireAccessTokens(); // server-side expiry: the kit refreshes once and retries
console.log((await qbo.testConnection()).connected, 'refreshes:', fake.tokenCalls.refresh_token);
} finally {
await fake.close();
}
output
connected: true sandbox
second conversion: ALREADY_INVOICED
invoice total 300
true refreshes: 1

The second conversion fails with ALREADY_INVOICED, which is the double-invoice guard.

Needs keys. Typechecked, not executed. This also mounts the Next.js route handlers (authorize is mandatory on purpose: these endpoints can connect, disconnect and read accounting data).

samples/src/qbo-setup.ts
// Typechecked, not run: real configuration against an Intuit sandbox app.
import { createQuickBooks, FileTokenStore, JsonlAuditLog } from '@27colab/quickbooks-online-kit';
import { createRouteHandlers } from '@27colab/quickbooks-online-kit/next';
export const qbo = createQuickBooks({
clientId: process.env.QBO_CLIENT_ID!,
clientSecret: process.env.QBO_CLIENT_SECRET!,
redirectUri: `${process.env.APP_URL}/api/quickbooks/callback`,
environment: process.env.QBO_ENVIRONMENT, // 'sandbox' (default) | 'production'
stateSecret: process.env.QBO_STATE_SECRET!, // any long random string
store: new FileTokenStore('.data/qbo-tokens.json'),
audit: new JsonlAuditLog('.data/qbo-audit.jsonl'),
});
declare function isAdmin(req: Request): Promise<boolean>;
// app/api/quickbooks/[action]/route.ts
const h = createRouteHandlers({
kit: qbo,
authorize: async (req) => isAdmin(req), // REQUIRED: your own session check
redirectAfterCallback: (r) => (r.ok ? '/admin/quickbooks?connected=1' : `/admin/quickbooks?error=${encodeURIComponent(r.error)}`),
});
export const GET = h.GET;
export const POST = h.POST;
RouteMethodNotes
/connectGETRedirects to Intuit’s consent screen
/callbackGETProtected by the signed state, not by authorize
/statusGETOffline: reads the store only
/testPOSTLive CompanyInfo call (refreshes tokens if needed)
/disconnectPOSTRevokes at Intuit (best effort) and deletes local tokens
/auditGETHealth summary and recent audit entries

POSTs also reject a cross-origin Origin header, and the handlers never return tokens.

  • Customers: findOrCreateCustomer, findCustomerByEmail, findCustomerByDisplayName; findOrCreateCustomer also sparse-updates an existing customer.
  • Items: buildPriceMap() returns { 'Name': { id, unitPrice, … } }; findItemByName.
  • Documents: createEstimate, convertEstimateToInvoice (throws ALREADY_INVOICED on repeat), createSalesReceipt, recordPayment.
  • Escape hatches: request(), query() and qboQuote() for values interpolated into queries.
  • Kit methods: connectUrl(), handleCallback(), status(), testConnection(), disconnect(), health().
  • Token refresh. Intuit rotates refresh tokens, so two concurrent refreshes with the same old token would race. The kit coalesces callers in one process, writes new tokens to the store before returning them, and if Intuit answers invalid_grant while the store already holds a newer token it adopts that. For multiple instances add the lock option (a database advisory lock, Redis lock, …).
  • 401 handling. One forced refresh, one retry. 429 and GET 5xx retry with Retry-After and backoff. POSTs are never blindly retried on 5xx, and every create carries a requestid.
  • Audit log. Every client call, refresh and connection action records action, outcome, duration, status, intuit_tid and the QBO ids touched. Tokens and request bodies are never recorded; keep PII and secrets out of your own metadata.
  • Token storage. Tokens are stored as plain JSON. Use disk or volume encryption, or wrap a store with your own encrypting adapter. Never commit the token file.
  • Accounting scope only: no Payments API, webhooks, reports, attachments, CDC or batch.
  • The fake supports only what this kit uses (a minimal select * from X where field = 'v' dialect; create, sparse update and read for six entity types). Passing against it does not prove behavior against the real API.
  • Fixtures and examples are invented. The minor version is pinned to 75; check Intuit’s changelog before relying on newer fields.
  • Issues are triaged occasionally.