# QuickBooks Online Integration — Handoff Document This document captures everything the Pulse team learned building a QuickBooks Online (QBO) integration, so a separate team building a new application can implement their own QBO connection with a head start. It assumes zero access to the Pulse codebase — every detail that matters is spelled out inline. Where useful, a parenthetical note says "(in Pulse this lives in …)" purely as provenance; treat those as historical footnotes, not as things you need to go look up. Pulse's implementation was originally built March 2026, extended with Payment/Deposit write support in May 2026, and had a soft-delete + AR reconciliation fix land in July 2026 after a real production discrepancy (see Gotchas below). This document reflects the accumulated, working state as of July 2026. ## 1. Overview QuickBooks Online is Intuit's hosted accounting SaaS. Pulse connects to a single QBO company ("realm") belonging to the business the dashboard serves, and uses the connection for two purposes: 1. **Read / sync (one-way, QBO → Pulse):** invoices, customer payments, bank deposits, purchases/expenses, journal entries, and pre-built financial reports (Profit & Loss, Balance Sheet, Cash Flow, and Aged Receivable reports) are pulled on a schedule and mirrored into Pulse's own database. This lets Pulse show finance dashboards, accounts-receivable aging, and engagement/finance widgets without hitting QBO's API on every page load. 2. **Write (one-way, Pulse → QBO):** Pulse can also *create* records in QBO — specifically `Payment` (a "Receive Payment" applied against one or more open invoices) and `Deposit` (grouping several payments into a single bank deposit line so QBO's bank-feed reconciliation matches the actual bank deposit slip). This was added to support a semi-automated bank remittance reconciliation workflow: a CLI script parses a bank's remittance file, maps each check to an invoice, and posts Payments + a single Deposit to QBO via the API instead of a human re-keying each line into the QBO UI. The integration is intentionally single-tenant: it connects to exactly one QBO company (one `realmId`) per deployment, configured via environment variables. There is no concept of "connect a QBO account per customer" — this was built for one business's own books, not as a multi-tenant SaaS feature. ## 2. Prerequisites Before writing any code, set up an app in Intuit's developer ecosystem: 1. Create an account at the Intuit Developer portal (https://developer.intuit.com) and create a new app under "My Apps". 2. Choose the **QuickBooks Online and Payments** (or just **QuickBooks Online Accounting API**) scope for the app — this is what exposes the `com.intuit.quickbooks.accounting` OAuth scope. 3. Every Intuit app ships with **two separate credential sets**: one for the **Sandbox** environment (a fake QBO company you can safely test writes against) and one for **Production** (real customer/company data). Treat these as two entirely different client id/secret pairs — do not assume sandbox credentials work against the production API base URL or vice versa. 4. Register a **redirect URI** (Intuit calls this the "Redirect URI" under the app's Keys & OAuth section) that exactly matches the callback URL your app will use — e.g. `https://your-app.example.com/api/qbo/auth`. Intuit validates this on every authorization request; a mismatch (including trailing slash or http vs https) fails the authorization step outright. 5. Intuit requires a **published EULA and Privacy Policy URL** on the app listing before you can move an app from Sandbox-only to Production/Go Live — Pulse added simple static `/legal/eula` and `/legal/privacy` pages specifically to satisfy Intuit's app review/assessment step. Budget time for this if you haven't already published one. 6. Decide up front which QBO scope(s) you need. Pulse only ever requests `com.intuit.quickbooks.accounting` (the general accounting scope that covers invoices, payments, deposits, purchases, journal entries, and reports). If you also need QBO Payments (charging cards) that's a different, additional scope not covered here. ## 3. Environment variables Pulse's QBO code reads the following variables directly via `process.env` — these are the *real* names in use, discovered by grepping the source (not assumed from a `.env.example`, since none of these currently appear in Pulse's committed `.env` — meaning this integration is present in code but not yet live-configured in this particular deployment). | Variable | Purpose | Example / placeholder | |---|---|---| | `QBO_CLIENT_ID` | Intuit app's OAuth2 client id (Sandbox or Production, depending on `QBO_SANDBOX`) | `ABxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | | `QBO_CLIENT_SECRET` | Intuit app's OAuth2 client secret (matching the same environment as the client id) | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | | `QBO_REALM_ID` | The QBO company id ("realm") this deployment talks to. Received once from Intuit on the OAuth callback, then hardcoded into config for a single-tenant deployment | `123146xxxxxxxxx` | | `QBO_SANDBOX` | String toggle. When exactly `"true"`, the client points at the Sandbox API base URL; any other value (including unset) means Production | `true` or `false` | Two things worth calling out explicitly: - Pulse's health-check / integration-status layer only checks for `QBO_CLIENT_ID` and `QBO_CLIENT_SECRET` being present to decide whether to show QBO as "configured" in its admin integrations UI — it does **not** separately validate `QBO_REALM_ID`. A new app should validate all three (`CLIENT_ID`, `CLIENT_SECRET`, `REALM_ID`) before attempting any API call, because the actual `QboClient` constructor throws if any of the three is missing. - The OAuth redirect-URI construction (see next section) reads a **fourth**, unrelated env var — `NEXTAUTH_URL` — for the base URL used to build both the callback redirect URI sent to Intuit and the post-auth redirect back into the admin UI. This is a legacy holdover from an earlier auth library Pulse used before switching to its current auth stack; it is **not** the same variable as the app's primary "base URL" env var used everywhere else in the codebase. In Pulse's actual `.env` this variable is not set at all, which means in production the redirect URI silently becomes `undefined/api/qbo/auth` unless something else sets `NEXTAUTH_URL`. **Do not repeat this mistake** — in your new app, use a single canonical "public base URL" env var for constructing OAuth redirect URIs, and make sure it's actually present wherever the OAuth code runs. No `client id`/`secret` values from Pulse's real `.env` are reproduced here — `.env` did not contain `QBO_*`/`QUICKBOOKS_*`/`INTUIT_*` keys at all at the time this document was written, so there was nothing to redact; the examples above are placeholders only. ## 4. OAuth2 connection flow QBO uses the standard OAuth2 **authorization-code** grant. Two Intuit endpoints matter: - **Authorize URL:** `https://appcenter.intuit.com/connect/oauth2` - **Token URL (exchange + refresh):** `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer` Step by step, this is exactly what Pulse's implementation does: 1. **User clicks "Connect to QBO"** in the admin UI, which hits a single backend endpoint (in Pulse: `GET /api/qbo/auth`, with no query params). 2. **Server builds the authorize redirect.** It generates a `state` value (Pulse currently uses `Math.random().toString(36).slice(2)` — cryptographically weak; a new app should use a proper random token, e.g. `crypto.randomUUID()` or a signed value, and *should* persist/verify it server-side before trusting the callback — Pulse's implementation generates `state` but does **not** persist or verify it on callback, which is a real CSRF gap worth closing in a new implementation). It then redirects the browser to: ``` https://appcenter.intuit.com/connect/oauth2 ?client_id= &scope=com.intuit.quickbooks.accounting &redirect_uri= &response_type=code &state= ``` 3. **User authenticates with Intuit and approves the connection**, choosing which QBO company ("realm") to connect if they have access to more than one. 4. **Intuit redirects back** to the exact `redirect_uri` registered on the Intuit app, appending query params: `code` (the authorization code), `realmId` (the QBO company id the user just authorized — this is how you discover the realm; it is not something you choose ahead of time unless you already know the company), and `state` (echoed back — verify it here). If the user declines or something goes wrong, Intuit instead appends an `error` param and no `code`/`realmId`. 5. **Server exchanges the code for tokens.** POST to the token URL: - Header: `Authorization: Basic ` - Header: `Content-Type: application/x-www-form-urlencoded` - Header: `Accept: application/json` - Body (form-encoded): `grant_type=authorization_code&code=&redirect_uri=` The redirect_uri sent here **must match** the one used in the initial authorize redirect exactly, or Intuit rejects the exchange. 6. **Intuit responds with a token payload:** ```json { "access_token": "...", "refresh_token": "...", "expires_in": 3600, "x_refresh_token_expires_in": 8726400, "token_type": "bearer" } ``` `expires_in` is in seconds and is **short** — access tokens are valid for 1 hour. `x_refresh_token_expires_in` is also in seconds and is **long** — the refresh token is valid for 100 days (8,726,400 seconds) from issuance, but note: **every time you use a refresh token to get a new access token, Intuit issues a brand-new refresh token as well**, and the 100-day clock resets. You must persist the new refresh token every single time or the old one on file will eventually go stale and you'll be forced back through the full user-consent flow (step 1). 7. **Persist the tokens** (see section 5) keyed by `realmId`, computing absolute expiry timestamps from `expires_in` / `x_refresh_token_expires_in` at the moment of exchange (`now + expires_in seconds`, etc.) rather than storing the raw relative seconds. 8. **Redirect the user back into your own app's UI** (not back to Intuit) — e.g. an admin settings page — with a success indicator. 9. **On every subsequent API call**, before hitting QBO, check whether the stored access token's expiry has passed. If it has, and the refresh token is still valid, silently call the **refresh** grant (same token URL, same Basic-auth header) with `grant_type=refresh_token&refresh_token=`, persist the new access + refresh token pair, and proceed. If the *refresh* token itself has also expired, there is no way to recover programmatically — you must re-run the full authorize flow (step 1) with a human present. Pulse's implementation checks and refreshes **proactively** (before each API call, based on stored expiry timestamps) rather than reactively (catching a 401 from the API and retrying) — this is simpler and avoids ever sending a guaranteed-to-fail request, at the cost of trusting your stored clock/expiry bookkeeping to be accurate. ## 5. Token storage & refresh Pulse stores exactly one row per connected realm in a dedicated tokens table. Generalized (not Postgres-specific — use whatever your new app's data layer supports, even a simple encrypted-at-rest key/value store), the record shape that must be persisted is: | Field | Type | Purpose | |---|---|---| | `realm_id` | string, unique | The QBO company id this token pair belongs to. Unique constraint because Pulse only ever supports one active connection per realm | | `access_token` | string (secret) | Short-lived bearer token sent on every API call | | `refresh_token` | string (secret) | Long-lived token used to mint new access tokens | | `access_token_expires_at` | timestamp | `now + expires_in seconds` at time of exchange/refresh | | `refresh_token_expires_at` | timestamp | `now + x_refresh_token_expires_in seconds` at time of exchange/refresh | | `created_at` / `updated_at` | timestamp | Standard audit columns | Refresh strategy (generalized pseudocode): ``` function getValidAccessToken(realmId): token = loadToken(realmId) if token is null: raise "not connected — run OAuth flow" if now() < token.access_token_expires_at: return token.access_token if now() >= token.refresh_token_expires_at: raise "refresh token expired — re-authorization required" return refreshAccessToken(token.refresh_token) # calls token URL, persists new pair, returns new access_token ``` Treat both tokens as secrets: encrypt at rest if your storage layer supports it, never log them, and never return them in any API response (Pulse's "connection status" endpoint returns only a derived `valid | expired | missing` enum — never the token values themselves — and the codebase's own security posture doc calls out that this table's contents, plus the whole `.env` file, must never be echoed or logged verbatim). **Disconnect is soft, not a full teardown.** Pulse's disconnect action only deletes the local token row; it does **not** call Intuit's token-revocation endpoint. This means Intuit's side may still consider the connection "authorized" until the refresh token naturally expires. A more correct implementation should also POST to Intuit's revoke endpoint (`https://developer.api.intuit.com/v2/oauth2/tokens/revoke`, same Basic-auth pattern, body `token=`) as part of disconnect, so the authorization is actually torn down on Intuit's side too. ## 6. API usage Base URLs (these differ **only** by the `sandbox-` prefix): - **Sandbox:** `https://sandbox-quickbooks.api.intuit.com` - **Production:** `https://quickbooks.api.intuit.com` Path shape for every accounting-entity call: ``` {baseUrl}/v3/company/{realmId}/{resource-or-query} ``` Pulse pins **`minorversion=65`** as a query parameter on every request. QBO's "minor version" controls which incremental API revision you get (new fields, behavior changes); pin an explicit value rather than omitting it, so QBO adding a new minor version later doesn't silently change your response shape. Check Intuit's current minor-version changelog before picking a value for a new integration — don't just copy `65` — but the pattern of pinning one explicit version is the right one to keep. Every authenticated request needs: ``` Authorization: Bearer Accept: application/json Content-Type: application/json ``` **Reading data** — QBO exposes a SQL-like query language over each entity. Example: list invoices, paginated (QBO caps each response at a max page size; keep paging with `STARTPOSITION`/`MAXRESULTS` until you get a short page back): ``` GET {baseUrl}/v3/company/{realmId}/query ?query=SELECT * FROM Invoice STARTPOSITION 1 MAXRESULTS 1000 &minorversion=65 ``` For incremental sync, add a `WHERE` clause filtering on QBO's own last-modified metadata, e.g.: ``` SELECT * FROM Invoice WHERE MetaData.LastUpdatedTime > '2026-07-01T00:00:00.000Z' STARTPOSITION 1 MAXRESULTS 1000 ``` **Writing data** — POST the entity's JSON body to its dedicated endpoint, e.g. creating a Payment: ``` POST {baseUrl}/v3/company/{realmId}/payment?minorversion=65 Content-Type: application/json { "CustomerRef": { "value": "" }, "TotalAmt": 539.40, "TxnDate": "2026-05-18", "DepositToAccountRef": { "value": "" }, "PaymentRefNum": "0000997294", "Line": [ { "Amount": 539.40, "LinkedTxn": [ { "TxnId": "", "TxnType": "Invoice" } ] } ] } ``` QBO echoes back the full created object (including its new `Id` and `SyncToken`) on success — always check for that `Id` in the response before considering the write successful; a 200-with-unexpected-body is possible and should be treated as a failure. **Reports** — pre-aggregated reports (Profit & Loss, Balance Sheet, Cash Flow, Aged Receivable Detail/Summary) are separate GET endpoints under `/v3/company/{realmId}/reports/{ReportName}` with their own query params (`start_date`, `end_date`, `accounting_method`, `report_date`, `aging_method`, plus `showrows=all&showcols=all` to get fully expanded rows/columns rather than a collapsed summary). Reports come back as a deeply nested `Header` / `Columns` / `Rows` tree that must be walked recursively — there is no flat tabular shape. Fetch the company's accounting preference (`GET /v3/company/{realmId}/preferences?minorversion=65`, look at `Preferences.ReportPrefs.ReportBasis`, e.g. `"Accrual"` or `"Cash"`) once and pass it explicitly as `accounting_method` on P&L/Balance Sheet requests — don't assume Accrual. **Rate limits / transient errors** — Intuit enforces per-app-per-realm rate limits (roughly 500 requests/minute per realm at time of writing — verify current limits in Intuit's docs, they change). Every QBO response includes an `intuit_tid` header (sometimes lowercased as `intuit-tid` depending on proxy/CDN normalization — check both) — capture it in any error you log or surface, since Intuit support requests require this transaction id to investigate a specific failed call. ## 7. Gotchas & lessons learned These are real issues Pulse hit, in rough order of how much time they cost: 1. **QBO never reports deletions — you must diff to find them.** The Invoice (and other entity) query endpoints only return records that *currently exist* in QBO. When an invoice is voided or deleted in QBO, it simply stops appearing in query results going forward — there is no "deleted" flag, no tombstone record, nothing to subscribe to. If your sync only *upserts* what it gets back, deleted/voided invoices silently linger forever in your own database and keep inflating any aggregate (e.g. "Total Accounts Receivable") that sums over them. Pulse hit this in production (an invoice for one customer had been voided in QBO but kept counting toward Pulse's headline A/R for weeks). **Fix:** on every *full* sync, collect the full set of entity IDs QBO actually returned, then mark (soft-delete) any locally-stored row for that realm whose id was *not* in that set. Do **not** apply this same logic on an *incremental* sync (one that only asks for rows changed since a timestamp) — an incremental response is deliberately a small subset of all records, so diffing against "everything QBO returned this call" would wrongly tombstone your entire historical ledger. Only do the tombstone diff on syncs that fetch the complete current entity set. 2. **Reconcile against QBO's own reports periodically, not just raw entity sync.** Even with the tombstone fix above, subtle drift can still appear between "sum of invoice balances I have stored" and "what QBO's own Aged Receivable report says." Building a small reconciliation check — pull QBO's live Aged-Receivable-Detail report, pull your own stored open-invoice balances, and diff by invoice/document number in three buckets (present in QBO but missing locally = sync miss; present locally but missing from QBO = likely stale/tombstone candidate; present in both but balance differs = a data-mapping or timing bug) — is cheap insurance and caught issues that a pure "did the sync job succeed" check would not have surfaced. 3. **Report endpoints reject the wrong parameter combinations.** Early attempts to fetch P&L/Balance Sheet passed a `summarize_column_by` parameter that QBO's API rejected outright; the fix was to drop it and instead explicitly pass `accounting_method` (sourced from the company's real preference, see section 6) plus `showrows=all&showcols=all`. If a report call returns an error, check the exact param set against Intuit's current report-endpoint docs rather than assuming your first guess is right — report endpoints are pickier about params than the generic `query` endpoint. 4. **Empty report periods return a distinct "no data" marker, not an empty array.** When you request a report for a period where the company had no activity, QBO returns a response with `Header.NoReportData` set to the *string* `"true"` rather than a normal empty report body. If you don't check for this explicitly, you'll either crash trying to parse a report with no rows, or you'll store a garbage "empty" report row and have to distinguish "the business had zero P&L that month" from "we never successfully fetched this month" later. Check for `Header.NoReportData === 'true'` and skip persisting anything for that period. 5. **Sandbox and Production are separate universes with separate credentials.** It is easy to assume "sandbox" is just a URL flag with the same client id/secret — it isn't. Each Intuit app has independent Sandbox and Production OAuth client id/secret pairs, independent authorized companies, and independent data. A `QBO_SANDBOX=true` toggle only changes which API *base URL* you hit — you still need to swap the client id/secret pair to match, or every request will fail authentication. Keep the credential pair and the sandbox/production flag changed together, never independently. 6. **Bank check numbers don't round-trip cleanly.** When reconciling bank remittance files against invoices/payments, physical paper checks often have leading zeros in their check number (e.g. `0000996226`) that the bank's own electronic remittance file strips down to a bare number (`996226`). If you're matching check numbers between an external file and QBO/your own records, normalize both sides (e.g. strip leading zeros before comparing) or you'll get false "no match found" failures on otherwise-correct data. 7. **Idempotency for write operations needs its own mechanism — QBO does not give you one for free.** POSTing the same Payment or Deposit twice creates two separate records in QBO; there's no natural dedupe key QBO enforces on your behalf for a client-supplied write like an idempotency key header would provide. Any script or job that writes to QBO (Payments, Deposits, or otherwise) needs its own tracking of "have I already successfully posted this specific record" (Pulse's reconciliation script does this with a sibling JSON manifest that records the QBO-assigned id after each successful POST, and skips any item already recorded on re-run) so that retrying a partially-failed batch job doesn't duplicate the successful half. 8. **Lazy-construct the API client if any code path shouldn't require credentials.** A tool that has a "dry run" or "preview" mode (e.g. validate + print what *would* be sent, without actually calling QBO) should not eagerly construct the QBO client at the top of its execution path, because a client constructor that validates `CLIENT_ID` / `CLIENT_SECRET` / `REALM_ID` up front will throw immediately in environments where those aren't configured — even though the dry run never needed a real connection. Defer client construction to the exact point where you're about to make a real, live API call. 9. **The disconnect action is local-only unless you explicitly call revoke.** See section 5 — a "disconnect" button that only deletes your locally stored tokens does not actually revoke the authorization on Intuit's side. Decide deliberately whether "disconnect" in your new app means "forget locally" or "forget locally AND revoke with Intuit," and implement the revoke call if you mean the latter. 10. **CSRF `state` handling needs to be real, not decorative.** Generating a `state` value and sending it to Intuit is only half of CSRF protection — you must also store what you sent (session, signed cookie, or a short-lived server-side record) and verify the value that comes back on the callback matches, before trusting `code`/`realmId`. Skipping the verification half (which Pulse currently does) leaves the callback endpoint accepting any `code`/`realmId` pair presented to it with no way to confirm it originated from an authorization flow you actually initiated. 11. **The OAuth callback and any scheduler-triggered sync endpoints must be reachable without a logged-in session, but everything else should stay behind auth.** Intuit's redirect back to your app after user consent is an unauthenticated browser request — if your app's normal auth middleware requires a session cookie on every route, the OAuth callback route (and, if you expose a webhook/scheduler-triggered "run a sync now" endpoint, that route too) need to be carved out as public routes, while read-oriented admin/diagnostic endpoints stay behind normal authorization (Pulse gates its AR-diagnostic endpoint behind an admin-only check, for example, while the OAuth callback and the sync trigger are unauthenticated by design). ## 8. Minimal code-flow example Framework-agnostic sketch (TypeScript-flavored pseudocode) of the whole lifecycle — this is illustrative, not a copy-paste of Pulse's actual files: ```ts const AUTHORIZE_URL = 'https://appcenter.intuit.com/connect/oauth2'; const TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer'; const REVOKE_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke'; function basicAuthHeader(clientId: string, clientSecret: string): string { return 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); } // 1. Kick off the flow function buildAuthorizeUrl(clientId: string, redirectUri: string, state: string): string { const params = new URLSearchParams({ client_id: clientId, scope: 'com.intuit.quickbooks.accounting', redirect_uri: redirectUri, response_type: 'code', state, }); return `${AUTHORIZE_URL}?${params}`; } // -> persist `state` server-side (session/db) before redirecting the browser here // 2. Handle the callback async function handleCallback(code: string, realmId: string, state: string, expectedState: string, redirectUri: string) { if (state !== expectedState) throw new Error('CSRF: state mismatch'); const tokens = await exchangeCodeForTokens(code, redirectUri); await saveTokens(realmId, tokens); } async function exchangeCodeForTokens(code: string, redirectUri: string) { const res = await fetch(TOKEN_URL, { method: 'POST', headers: { Authorization: basicAuthHeader(CLIENT_ID, CLIENT_SECRET), 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri }), }); if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`); return res.json(); // { access_token, refresh_token, expires_in, x_refresh_token_expires_in, ... } } async function saveTokens(realmId: string, tokens: any) { const now = Date.now(); await tokenStore.upsert(realmId, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, accessTokenExpiresAt: new Date(now + tokens.expires_in * 1000), refreshTokenExpiresAt: new Date(now + tokens.x_refresh_token_expires_in * 1000), }); } // 3. Get a valid access token for any API call, refreshing if needed async function getValidAccessToken(realmId: string): Promise { const token = await tokenStore.load(realmId); if (!token) throw new Error('not connected'); if (Date.now() < token.accessTokenExpiresAt.getTime()) return token.accessToken; if (Date.now() >= token.refreshTokenExpiresAt.getTime()) throw new Error('refresh token expired; reconnect required'); const res = await fetch(TOKEN_URL, { method: 'POST', headers: { Authorization: basicAuthHeader(CLIENT_ID, CLIENT_SECRET), 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: token.refreshToken }), }); if (!res.ok) throw new Error(`token refresh failed: ${res.status} ${await res.text()}`); const fresh = await res.json(); await saveTokens(realmId, fresh); return fresh.access_token; } // 4. Call the actual API async function qboRequest(realmId: string, path: string, options: RequestInit = {}): Promise { const accessToken = await getValidAccessToken(realmId); const base = USE_SANDBOX ? 'https://sandbox-quickbooks.api.intuit.com' : 'https://quickbooks.api.intuit.com'; const res = await fetch(`${base}/v3/company/${realmId}${path}`, { ...options, headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'Content-Type': 'application/json', ...options.headers, }, }); const tid = res.headers.get('intuit_tid') ?? res.headers.get('intuit-tid') ?? 'unknown'; if (!res.ok) throw new Error(`QBO ${res.status} on ${path} [intuit_tid=${tid}]: ${await res.text()}`); return res.json() as Promise; } // Example read async function getInvoicesUpdatedSince(realmId: string, since: Date) { const q = encodeURIComponent( `SELECT * FROM Invoice WHERE MetaData.LastUpdatedTime > '${since.toISOString()}' STARTPOSITION 1 MAXRESULTS 1000` ); const data = await qboRequest(realmId, `/query?query=${q}&minorversion=65`); return data.QueryResponse.Invoice ?? []; } // Example write async function createReceivePayment(realmId: string, payload: any) { const data = await qboRequest(realmId, `/payment?minorversion=65`, { method: 'POST', body: JSON.stringify(payload), }); if (!data?.Payment?.Id) throw new Error('createPayment returned no Payment.Id'); return data.Payment; } // Optional: full teardown (not just forgetting locally stored tokens) async function revokeToken(token: string) { await fetch(REVOKE_URL, { method: 'POST', headers: { Authorization: basicAuthHeader(CLIENT_ID, CLIENT_SECRET), 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ token }), }); } ``` That's the whole lifecycle: authorize redirect → callback with verified `state` → code-for-token exchange → persisted token record → transparent refresh on every call → reads via the `query` endpoint or report endpoints → writes via entity-specific POST endpoints, each guarded by your own idempotency bookkeeping since QBO provides none for you.