Documents Pulse's QuickBooks Online integration (OAuth2 authorization-code flow, token storage/refresh, sandbox vs production API base URLs, scopes, minor version, and gotchas learned from the AR reconciliation/soft-delete work) so a new app's team can build their own QBO connection without access to the Pulse codebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
30 KiB
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:
- 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.
- Write (one-way, Pulse → QBO): Pulse can also create records in QBO —
specifically
Payment(a "Receive Payment" applied against one or more open invoices) andDeposit(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:
- Create an account at the Intuit Developer portal (https://developer.intuit.com) and create a new app under "My Apps".
- Choose the QuickBooks Online and Payments (or just QuickBooks
Online Accounting API) scope for the app — this is what exposes the
com.intuit.quickbooks.accountingOAuth scope. - 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.
- 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. - 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/eulaand/legal/privacypages specifically to satisfy Intuit's app review/assessment step. Budget time for this if you haven't already published one. - 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_IDandQBO_CLIENT_SECRETbeing present to decide whether to show QBO as "configured" in its admin integrations UI — it does not separately validateQBO_REALM_ID. A new app should validate all three (CLIENT_ID,CLIENT_SECRET,REALM_ID) before attempting any API call, because the actualQboClientconstructor 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.envthis variable is not set at all, which means in production the redirect URI silently becomesundefined/api/qbo/authunless something else setsNEXTAUTH_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:
-
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). -
Server builds the authorize redirect. It generates a
statevalue (Pulse currently usesMath.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 generatesstatebut 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=<QBO_CLIENT_ID> &scope=com.intuit.quickbooks.accounting &redirect_uri=<url-encoded redirect URI, e.g. https://your-app.example.com/api/qbo/auth> &response_type=code &state=<state> -
User authenticates with Intuit and approves the connection, choosing which QBO company ("realm") to connect if they have access to more than one.
-
Intuit redirects back to the exact
redirect_uriregistered 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), andstate(echoed back — verify it here). If the user declines or something goes wrong, Intuit instead appends anerrorparam and nocode/realmId. -
Server exchanges the code for tokens. POST to the token URL:
- Header:
Authorization: Basic <base64(client_id:client_secret)> - Header:
Content-Type: application/x-www-form-urlencoded - Header:
Accept: application/json - Body (form-encoded):
grant_type=authorization_code&code=<code>&redirect_uri=<same redirect_uri used in step 2, byte-for-byte>
The redirect_uri sent here must match the one used in the initial authorize redirect exactly, or Intuit rejects the exchange.
- Header:
-
Intuit responds with a token payload:
{ "access_token": "...", "refresh_token": "...", "expires_in": 3600, "x_refresh_token_expires_in": 8726400, "token_type": "bearer" }expires_inis in seconds and is short — access tokens are valid for 1 hour.x_refresh_token_expires_inis 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). -
Persist the tokens (see section 5) keyed by
realmId, computing absolute expiry timestamps fromexpires_in/x_refresh_token_expires_inat the moment of exchange (now + expires_in seconds, etc.) rather than storing the raw relative seconds. -
Redirect the user back into your own app's UI (not back to Intuit) — e.g. an admin settings page — with a success indicator.
-
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=<stored 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- 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=<access_token or refresh_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 <valid access token>
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": "<QBO customer id>" },
"TotalAmt": 539.40,
"TxnDate": "2026-05-18",
"DepositToAccountRef": { "value": "<QBO bank account id>" },
"PaymentRefNum": "0000997294",
"Line": [
{ "Amount": 539.40, "LinkedTxn": [ { "TxnId": "<invoice id>", "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:
-
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.
-
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.
-
Report endpoints reject the wrong parameter combinations. Early attempts to fetch P&L/Balance Sheet passed a
summarize_column_byparameter that QBO's API rejected outright; the fix was to drop it and instead explicitly passaccounting_method(sourced from the company's real preference, see section 6) plusshowrows=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 genericqueryendpoint. -
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.NoReportDataset 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 forHeader.NoReportData === 'true'and skip persisting anything for that period. -
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=truetoggle 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. -
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. -
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.
-
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_IDup 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. -
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.
-
CSRF
statehandling needs to be real, not decorative. Generating astatevalue 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 trustingcode/realmId. Skipping the verification half (which Pulse currently does) leaves the callback endpoint accepting anycode/realmIdpair presented to it with no way to confirm it originated from an authorization flow you actually initiated. -
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:
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<string> {
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<T>(realmId: string, path: string, options: RequestInit = {}): Promise<T> {
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<T>;
}
// 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<any>(realmId, `/query?query=${q}&minorversion=65`);
return data.QueryResponse.Invoice ?? [];
}
// Example write
async function createReceivePayment(realmId: string, payload: any) {
const data = await qboRequest<any>(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.