Open source · Build an AI front desk, part 6 · MIT
QuickBooks Online without dead tokens
A refresh token that dies when you use it: what I learned from QuickBooks OAuth
- TypeScript
- Next.js
- QuickBooks Online
Early (v0.x), written with Claude Code and directed and reviewed by me. Install from npm where published, otherwise clone the repository.
At a glance
v0.1, 70 offline tests, one production company so far
Inside the library
- A token manager: one refresh for many callers, sixty seconds early, saved before use, revoked told apart from a blip.
- An OAuth helper with signed state, and sandbox and production kept apart by explicit config.
- A typed client with an ALREADY_INVOICED guard on estimate-to-invoice conversion.
- An audit log that stores no tokens, Next.js route handlers, a React status view, and a fake Intuit server for tests.
You bring
- An Intuit developer app and, for production, Intuit's own app assessment.
- An encrypted token store. The built-in stores hold plain JSON.
- A shared lock (a Postgres advisory lock, say) if several servers refresh the same company's token.
- Everything outside accounting basics: customers, items, estimates, invoices and sales receipts are in; the rest of the API is not.
A refresh token that dies when you use it: what I learned from QuickBooks OAuth
Notes on token rotation, a duplicate-invoice guard, and keeping sandbox and production apart, from a client project that went live in a single day.
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.
Run it yourself: quickbooks-online-kit
What is in the library, what you still have to set up around it, and how to run it offline before you spend a cent.
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_INVOICEDGet it
git clone https://github.com/27colab/quickbooks-online-kit.git
cd quickbooks-online-kit && npm ci && npm testWant this built into your product? Get in touch.