Documentation source
QuickBooks Online
Multi-company QuickBooks Online accounting sync via Intuit OAuth, the integrations substrate, and the Amble MCP — connect once per company, no re-auth.
## Overview
The QuickBooks connector pulls accounts, transactions, invoices, bills, and
vendors from QuickBooks Online into the entity graph as `qb-account`,
`qb-transaction`, `qb-invoice`, `qb-bill`, and `qb-vendor` records. It is a
**bridge**: a tenant authorizes each company once through Intuit OAuth, and from
then on agents and the Amble MCP read every authorized company's data with **no
re-authentication** — access tokens refresh automatically before each sync,
rotating the Intuit refresh token as needed.
This is the accounting source for the Marbella engagement (multi-entity family
office). Unlike Intuit's own MCP — which connects one company at a time — a
single Amble sync fans out across **every** connected QuickBooks company for the
tenant.
---
## Architecture
The connector rides the [Integration Substrate](/docs/integrations/integration-substrate)
and the generic agent-connection OAuth flow. Nothing QuickBooks-specific lives
outside `features/integrations/quickbooks/` except the connection **preset**
(one literal in `features/agents/connection-presets.ts`).
```text
Intuit OAuth (appcenter.intuit.com → oauth.platform.intuit.com)
-> /api/agent-connections/[id]/oauth/start (per-connection authorize redirect)
-> /api/oauth/connection-callback (STABLE callback; recovers id from state)
-> completeConnectionOAuth() (stores tokens + config.realmId)
QuickBooks API (quickbooks.api.intuit.com)
-> features/integrations/quickbooks/client.ts (REST v3 query, host-allowlisted)
-> sync.ts syncQuickBooks / buildQuickBooksDefinition
-> resolveQuickBooksConnections() (ALL companies for the tenant)
-> runMultiConnectionSync() (fan-out, per-company isolation)
-> definition.pull() (refresh OAuth token, then snapshot)
-> runRawIntegrationSync() (substrate runner)
-> upsertEntityKeyed() -> qb-account / -transaction / -invoice / -bill / -vendor
```
Key files:
| File | Purpose |
|------|---------|
| `client.ts` | QBO REST v3 client; host-allowlisted to the two Intuit hosts, https-only |
| `connection.ts` | `resolveQuickBooksConnections` (all companies) + `resolveQuickBooksConnection` (one) |
| `normalize.ts` | Pure transforms: QBO objects → entity payloads (money as integer cents) |
| `descriptor.ts` | `QUICKBOOKS_DESCRIPTOR` — registered in `features/integrations/registry.ts`; `connectionPresetIds: ["quickbooks-online"]` |
| `definition.ts` | `buildQuickBooksDefinition` + `getIntegrationDefinition("quickbooks")` registration |
| `sync.ts` | `syncQuickBooks` — multi-company fan-out source |
| `constants.ts` | `QUICKBOOKS_PRESET_IDS`, `QUICKBOOKS_SLUG`, base URLs, minor version |
The OAuth machinery is **generic** (shared with Dropbox, Canva, etc.):
| File | Role for QuickBooks |
|------|---------|
| `features/agents/connection-presets.ts` | `quickbooks-online` preset with the Intuit `oauth` block |
| `features/agents/oauth-config.ts` | `callbackConfigParams: ["realmId"]` — declares which callback params to persist |
| `features/agents/server/oauth-actions.ts` | token exchange, **rotating** refresh, `realmId` capture, `refreshConnectionOAuthRowById` |
---
## Credentials and Environment Variables
QuickBooks needs **no new environment variables**. The Intuit app's Client ID
and Secret are entered per connection in the admin UI and stored encrypted on the
`agent_connections` row — the same model as every other OAuth preset.
| Variable | Required | Description |
|----------|----------|-------------|
| `AGENT_CONNECTION_ENCRYPTION_KEY` | Yes | Shared key for `encryptCredentials` / `decryptCredentials` (already configured; used by all connectors) |
Tokens (access + refresh) and the Intuit client secret are **never** logged or
returned in API responses — they live only in `encrypted_credentials`.
### Intuit app setup (one-time)
1. Create an app at [developer.intuit.com](https://developer.intuit.com) (scope:
**Accounting** → `com.intuit.quickbooks.accounting`).
2. Add the **stable** redirect URI to the app's Redirect URIs:
`https://app.sprinter.ai/api/oauth/connection-callback` (and a localhost
variant for local dev). This single URI is shared by every company — Intuit
requires an exact match, which is why the callback does not embed the
connection id.
3. Copy the app's **Client ID** and **Client Secret** for the connect step below.
---
## Connection Provisioning — connect each company once
1. Admin visits **Admin > Connections**, clicks **Add connection**, and picks
**QuickBooks Online**.
2. Enters the Intuit app's **Client ID** and **Secret**, saves the connection,
then clicks **Connect**.
3. The browser is redirected to Intuit (`/api/agent-connections/[id]/oauth/start`
→ `appcenter.intuit.com`), where the user selects **which company** to
authorize.
4. Intuit redirects back to the stable callback with `?code=…&state=…&realmId=…`.
`completeConnectionOAuth` exchanges the code, stores the access + refresh
tokens, and persists `config.realmId` (the company id) from the `realmId`
callback param.
5. **Repeat Connect for each additional company.** Each authorization is a
separate `agent_connections` row keyed by its `realmId`; one tenant can hold
many. A single sync then pulls from all of them.
> **Sandbox:** while testing against an Intuit sandbox company, set the
> connection's Base URL to `https://sandbox-quickbooks.api.intuit.com`. Both
> Intuit hosts are allowlisted by the client; anything else is rejected.
### What a connected row carries
- `config.presetId: "quickbooks-online"` — the connector's resolution key
- `config.realmId` — the QuickBooks company id (from the OAuth callback)
- `encrypted_credentials.oauth2`: `{ clientId, clientSecret, accessToken, refreshToken, expiresAt }`
- `base_url` (optional) — defaults to production; set to the sandbox host for testing
---
## Token Lifecycle — no re-auth after the first connect
QuickBooks access tokens expire in **1 hour**; refresh tokens last ~**100 days**
and **rotate** on use. The bridge keeps every company signed in automatically:
- The connector's per-company `pull()` calls `refreshConnectionOAuthRowById`,
which refreshes the access token if it is within 60s of expiry and persists any
rotated refresh token before reading.
- The refresh runs inside the per-company isolation boundary of
`runMultiConnectionSync`: if one company's refresh token has genuinely lapsed
(>100 days idle), that company surfaces as a per-connection error and the
others still sync.
- The same generic refresher also runs on every connection read
(`getConnectionByIdInternal`) and MCP config resolution, so interactive reads
are equally fresh.
A user only re-authorizes a company if its refresh token fully expires (long
idle) or access is revoked in Intuit.
---
## Sync Mechanics
`syncQuickBooks` resolves **every** QuickBooks connection for the tenant
(optionally narrowed by `groupKey` or a single `connectionId`) and fans out via
`runMultiConnectionSync`. Each company runs independently; per-company failures
are isolated and surfaced as `connection`-resource conflicts in the aggregated
summary rather than aborting the whole run.
- **Resources:** `account`, `transaction`, `invoice`, `bill`, `vendor` (all five
by default).
- **Date window:** optional `startDate` / `endDate` (`YYYY-MM-DD`) bound the
`transaction` / `invoice` / `bill` pull.
- **`dry_run` vs `apply`:** `dry_run` (the route default) proposes without
writing; `apply` upserts via `upsertEntityKeyed` keyed on
(`external_source="quickbooks:<resource>"`, realm-namespaced `external_id`), so
applies are idempotent.
- **Synchronous fan-out cap:** a single `runIntegrationSync` call covers up to 10
companies; beyond that, narrow with `groupKey` or use the scheduled action.
---
## Agent / MCP Usage
Agents and external MCP clients (e.g. the Marbella MCP server) use the standard
integration tools — no QuickBooks-specific tool calls. The Marbella API key needs
the `tools:execute` scope, and its creator must hold `actions.team.read` /
`actions.team.run`.
### List connected companies
```typescript
// Tool: listIntegrationConnections
{ integrationSlug: "quickbooks" }
// Returns one entry per authorized company (realm)
```
### Pull every company at once
```typescript
// Tool: runIntegrationSync
{ integrationSlug: "quickbooks", mode?: "dry_run" | "apply", window?: { start, end } }
// Fans out across ALL connected companies for the tenant
```
### Query synced data
```typescript
// Tool: searchEntities / getEntity
{ entityTypeSlug: "qb-invoice" } // or qb-account / qb-transaction / qb-bill / qb-vendor
```
Every record carries `external_id` / `external_source` provenance
(`quickbooks:<resource>`, realm-namespaced), so any entity traces back to its
source object in `integration_objects`.
---
## Data Model
Entity types are declared in the Marbella tenant module
(`features/custom/tenants/marbella/declarations`). Each carries `realmId` +
`externalId` for multi-company addressing and idempotent upserts. Money is stored
as **integer cents**.
| Entity type | Key fields |
|-------------|-----------|
| `qb-account` | `name`, `accountType`, `currentBalanceCents`, `realmId`, `externalId` |
| `qb-transaction` | `title`, `transactionDate`, `accountName`, `totalAmountCents`, `realmId`, `externalId` |
| `qb-invoice` | `title`, `customerName`, `invoiceDate`, `dueDate`, `balanceCents`, `realmId`, `externalId` |
| `qb-bill` | `title`, `vendorName`, `billDate`, `dueDate`, `balanceCents`, `realmId`, `externalId` |
| `qb-vendor` | `name`, `companyName`, `taxId`, `balanceCents`, `realmId`, `externalId` |
---
## Design Decisions
- **Stable, app-level callback (`/api/oauth/connection-callback`).** Intuit
requires the `redirect_uri` to exactly match a pre-registered value, so it
cannot vary per connection id. The callback recovers the connection id from the
OAuth `state` (`"<connectionId>.<nonce>"`); CSRF protection is unchanged (the
per-connection state cookie + nonce remain the gate). This replaced the prior
per-id callback for all OAuth presets. The provider redirect lands
cross-origin (no `x-tenant-slug`), so the callback can't use
`requireAdmin()`/`getTenantContext()`; it authenticates via the session cookie
(`getUserId`) and authorizes against the connection's own tenant
(`isTenantAdminMember`, recovered from `state`).
- **`realmId` via `callbackConfigParams`.** Intuit returns the company id only as
a callback query param. A generic, preset-declared allowlist
(`oauth.callbackConfigParams`) persists it into `config.realmId` — no
QuickBooks-specific code in the platform OAuth flow.
- **Refresh in `pull()`.** OAuth refresh lives in the connector's per-company
`pull()` — exactly where the token is consumed — rather than the generic
runner. QuickBooks is the only OAuth data-pull connector today; when a second
arrives, the `refreshConnectionOAuthRowById` helper lifts into the shared
fan-out unchanged.
---
## Deferred Work
- **Report endpoints** (P&L, balance sheet, AR/AP aging) — the connector pulls
raw objects today; Intuit's report APIs are a follow-up.
- **Webhook-driven sync** — Intuit CDC/webhooks could replace polling; the
scheduled action covers the engagement in the interim.
- **QB↔Plaid reconciliation views** — cross-reference `qb-account` balances
against `plaid-account` balances for cash reconciliation.
- **>10-company single-call sync** — beyond the synchronous cap, use `groupKey`
or the scheduled action.
---
## Related Docs
- [Plaid](/docs/integrations/plaid) — the cash-visibility sibling connector
- [Integration Substrate](/docs/integrations/integration-substrate) — shared sync, provenance, and write-ledger contract
- [MCP OAuth](/docs/integrations/mcp-oauth) — connecting external MCP clients
- [Agent System](/docs/features/agent-system) — how agents invoke integration tools