wulf-pulse/QBO_INTEGRATION_HANDOFF.md
lorentz ea8a36b394 docs(quick-260718-9qg): add self-contained QBO integration handoff document
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
2026-07-18 07:07:26 -04:00

596 lines
30 KiB
Markdown

# 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=<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>
```
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 <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.
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=<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
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=<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:
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<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.