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
596 lines
30 KiB
Markdown
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.
|