quickbooks-online-kit
Package @27colab/quickbooks-online-kit · Source on GitHub · MIT · Node 20+ · ESM · zero runtime dependencies (react optional peer for /react)
What is in the box
Section titled “What is in the box”| Piece | What it does |
|---|---|
createQuickBooks() | Wires everything from one config object |
| OAuth | Authorize URL, callback parsing, code exchange, refresh, revoke; signed state for CSRF protection; realm id from the callback |
TokenStore | Tiny get/set/delete interface. MemoryTokenStore, FileTokenStore (atomic, 0600), SqliteTokenStore (any better-sqlite3 / node:sqlite-shaped handle) |
TokenManager | Single-flight refresh, rotation persisted before use, adopts tokens another process already rotated, optional cross-process lock hook |
QboClient | Find-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() |
| Errors | QboError with the parsed Fault, QboReconnectRequiredError, QboNotConnectedError |
| Audit | AuditLog interface, memory and JSONL implementations, summarizeHealth() |
/next | Web-standard Request/Response route handlers: connect, callback, status, test, disconnect, audit |
/react | <QboConnectionStatus /> and the presentational <QboConnectionStatusView /> |
/testing | startFakeQbo(): an in-process fake of Intuit’s OAuth and the REST endpoints above |
Install
Section titled “Install”npm install @27colab/quickbooks-online-kitBring your own keys
Section titled “Bring your own keys”- Sign in at developer.intuit.com and create an app with the QuickBooks Online and Payments scope
com.intuit.quickbooks.accounting. - Under Keys & credentials → Development, copy the Client ID and Client secret.
- Add a Redirect URI that exactly matches the one you pass to the kit, such as
http://localhost:3000/api/quickbooks/callback. - 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.
Run it without keys
Section titled “Run it without keys”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.
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();}connected: true sandboxsecond conversion: ALREADY_INVOICEDinvoice total 300true refreshes: 1The second conversion fails with ALREADY_INVOICED, which is the double-invoice guard.
Configure for a sandbox
Section titled “Configure for a sandbox”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).
// 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.tsconst 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;| Route | Method | Notes |
|---|---|---|
/connect | GET | Redirects to Intuit’s consent screen |
/callback | GET | Protected by the signed state, not by authorize |
/status | GET | Offline: reads the store only |
/test | POST | Live CompanyInfo call (refreshes tokens if needed) |
/disconnect | POST | Revokes at Intuit (best effort) and deletes local tokens |
/audit | GET | Health summary and recent audit entries |
POSTs also reject a cross-origin Origin header, and the handlers never return tokens.
API overview
Section titled “API overview”- Customers:
findOrCreateCustomer,findCustomerByEmail,findCustomerByDisplayName;findOrCreateCustomeralso sparse-updates an existing customer. - Items:
buildPriceMap()returns{ 'Name': { id, unitPrice, … } };findItemByName. - Documents:
createEstimate,convertEstimateToInvoice(throwsALREADY_INVOICEDon repeat),createSalesReceipt,recordPayment. - Escape hatches:
request(),query()andqboQuote()for values interpolated into queries. - Kit methods:
connectUrl(),handleCallback(),status(),testConnection(),disconnect(),health().
Design notes
Section titled “Design notes”- 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_grantwhile the store already holds a newer token it adopts that. For multiple instances add thelockoption (a database advisory lock, Redis lock, …). - 401 handling. One forced refresh, one retry. 429 and GET 5xx retry with
Retry-Afterand backoff. POSTs are never blindly retried on 5xx, and every create carries arequestid. - Audit log. Every client call, refresh and connection action records action, outcome, duration, status,
intuit_tidand the QBO ids touched. Tokens and request bodies are never recorded; keep PII and secrets out of your ownmetadata. - 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.
Limits
Section titled “Limits”- 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.