On this page
Key takeaways
- Every refresh returns a new refresh token and kills the old one. Save the new tokens before you use them, or a crash in between sends a human back to the consent screen.
- Refresh in one place. Many callers, one refresh in flight, sixty seconds early, and a revoked connection told apart from a network blip.
- Make the environment explicit. Tag every stored token set with sandbox or production and refuse a mismatch; read item ids from the connected company at runtime.
- Guard the second invoice with data you already have, the link from invoice back to estimate. It stops retries and double clicks; it is not atomic.
- Keep tokens and request bodies out of the audit log, and derive the health page from the log so it survives a restart.
A QuickBooks integration looks finished when the sandbox works. Orders created estimates, estimates became invoices, and the connection page said Connected. On the day we pointed it at the real company file, I spent the whole day patching it.
The environment check guessed, from a URL and a deploy variable, whether to use production, and was wrong the first time. The item ids I had hard-coded were different in the real company. And a failing call to QuickBooks showed the user an error about something they could do nothing about, so I hid it. The order saved, and the accounting document silently didn't link. The commit message from that afternoon says it plainly: debug it in prod.
None of that was the real problem. The real problem was in the six route files that each refreshed the token by themselves.
A token that expires the first time you use it
QuickBooks' OAuth works in two layers. A short-lived access token authorizes calls. A long-lived refresh token gets a new access token when the old one expires. The unusual part is that every refresh also returns a new refresh token, and the old one stops working. If you lose the new one, you have to send a human back through the consent screen.
- Caller A to Token manager: needs a token (expired)
- Caller B to Token manager: needs a token, same moment
- Token manager: one refresh in flight; B waits
- Token manager to Intuit: refresh(RT1)
- Intuit to Token manager: AT2 + RT2. RT1 is now dead
- Token manager to Store: save AT2 + RT2. before anyone uses it
- Token manager to Caller A: AT2
- Token manager to Caller B: AT2 (no second refresh)
The rule that follows: save the new tokens before you use them. If the process crashes between receiving them and writing them down, the old refresh token is already dead and the new one is gone.
My integration did not have a rule. It had the same block copy-pasted into six route handlers: read the tokens, check whether the time has passed, refresh, save. No lock, no margin before expiry, and one route, the status check, refreshed the token and threw the result away. Whether that ever bit production I can't tell you; there is no record of it. But it is exactly the kind of mistake rotation punishes, and it hid in plain sight because the refresh was written six times.
One place that does it
In the kit, a token manager does it. If twenty-five callers need a token while it's expiring, one refresh happens and the other twenty-four wait for its answer; a test fires twenty-five at once and counts one request. It refreshes sixty seconds early, so a token never dies in the middle of a call. It writes the new tokens to the store before returning the access token. And it tells apart a revoked connection, which needs a human, from a network blip, which does not. The origin flattened every failure into one reconnect message.
- Server 1 to Intuit: refresh(RT1)
- Server 2 to Intuit: refresh(RT1), same moment
- Intuit to Server 1: new tokens (RT2)
- Server 1 to Shared store: save RT2
- Intuit to Server 2: invalid_grant: RT1 already used
- Server 2 to Shared store: re-read the store
- Shared store to Server 2: RT2 differs: adopt it. no reconnect needed
The case a single process can't solve is two servers refreshing at the same moment. Without a lock, one wins, and the other is told its token is dead. Rather than giving up, it looks in the shared store, sees that the refresh token has changed, and adopts the winner's. That works, at the cost of one wasted call to Intuit; the tests show both servers end up with working tokens. If you can give the kit a lock, such as a Postgres advisory lock, the second server waits and never makes the call.
Never mix up sandbox and production
In the origin, production was whatever a URL check said it was. In the kit it's explicit config, and anything other than production or prod resolves to sandbox. Every stored token set is tagged with its environment and refused if it doesn't match, so sandbox tokens can't be used against a real company. Item ids come from the connected company at runtime, because they differ between companies.
The second invoice
The estimate-to-invoice route had no protection against being called twice. A retry or a double click would create two invoices. The data to detect it was already there: the integration was writing a link from every invoice back to its estimate.
- Read the estimate. It carries links to any documents already made from it.
- Is there already an invoice linked?. Yes: stop with ALREADY_INVOICED and the existing invoice id. (An explicit override exists for the rare legitimate second invoice.)
- No: build the invoice from the estimate's lines. Item, quantity, price, tax code, and the customer's billing email if the estimate has one.
- Create it, with a unique request id. A retry of the same call can't create a second one.
- Link it back and record an audit entry. Ids only. Never tokens, never request bodies.
So converting an estimate first looks for an invoice already linked to it, and if it finds one, it refuses with an ALREADY_INVOICED error and the existing invoice id. An explicit override exists for the rare legitimate second invoice. My own reading is that this is check-then-create, not atomic, so two truly simultaneous calls could both get through; it protects against retries and double clicks, not races, and I haven't written a test for it. Retries of a single create reuse one request id, so a lost response does not make a duplicate.
An audit log that cannot leak
The log records what happened, with QuickBooks ids only. It stores no tokens and no request bodies, and a test checks that tokens never appear in it. A pure function summarizes the last day of entries: failures, failed refreshes, things that need a person. The origin kept counters in memory and stored user emails in each entry; the kit derives health from the log itself, so it survives a restart when the log is persistent.
Testing against a fake Intuit
There's no way to test a rotating refresh token against the real service without burning real tokens, so the kit ships a small fake Intuit server that runs in the test process. Its refresh tokens rotate, its auth codes are single use, and it can be told to fail the next call, delay the refresh to widen a race window, or revoke everything. Seventy tests run offline in about a second.
- POST requests that fail with a 5xx are not retried, because retrying could create twice. GETs and throttling responses are.
- Route handlers for Next.js require an authorize function. Without it, you can't construct them, and every route but the OAuth callback returns 403.
What I have and haven't proven
- This is version 0.1. Nobody but me has run it against a live production company, and it was built in one Claude Code session from patterns I'd suffered through. I don't call the older node-quickbooks library unmaintained; it still publishes. I wanted promises, bundled types and rotation I owned.
- The fake encodes my understanding of Intuit's behavior. Its token lifetimes (an hour, 101 days) are my test settings, not documented guarantees. Check Intuit's current docs.
- Scope is accounting: customers, items, estimates, invoices and sales receipts, with the connection and its health. It's not a full SDK.
- The built-in token stores hold plain JSON, so encrypt before you use them for real. Taking an app to production also requires Intuit's own assessment of your app.
I wrote this with Claude as my pair. The mistakes in the origin were mine, and so was the call on what the kit would refuse to do.
What's in the library
A token manager, an OAuth helper with signed state, a typed client with the duplicate-invoice guard, token stores, an audit log with a health summary, Next.js route handlers, a React status view, and a fake Intuit server for tests.
invoice total 300 | second conversion: ALREADY_INVOICED
You bring an Intuit developer app and, for production, Intuit's own app assessment; an encrypted token store; a shared lock (a Postgres advisory lock, say) if several servers refresh the same company's token; and everything outside accounting basics.
Get it
MIT-licensed, early (v0.1), written with Claude Code and directed and reviewed by me.
git clone https://github.com/27colab/quickbooks-online-kit.git
cd quickbooks-online-kit && npm ci && npm test
Who wrote this
Jesus Vasquez · Engineering · fractional CTO
Builds it: WordPress and Next.js, integrations and AI agents, with the tests, analytics and accessibility that keep it honest.