> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# QuickBooks

> Accounting entities, queries, batch, change data capture, OAuth 2.0, and webhooks.

Stateful QuickBooks Online emulation of the Accounting API v3 at minor version 75. It covers customers (with jobs), vendors, items (inventory, non-inventory, and service), accounts, terms, and payment methods; invoices, estimates, payments, credit memos, and sales receipts, with create, read, full and sparse update, delete, and void; `SyncToken` checks; and balances that stay consistent: invoice balances, customer balances, A/R, deposit accounts, and inventory quantity and value are recomputed on every write. It also covers the query language, CompanyInfo and Preferences, batch, change data capture, `requestid` replay, Intuit's fault envelope, OAuth 2.0 with a company picker and rotating refresh tokens, OpenID userinfo and ID tokens, and webhooks signed with `intuit-signature`. The tests drive it with [`node-quickbooks`](https://github.com/mcohen01/node-quickbooks) (2.0.50) and Intuit's [`intuit-oauth`](https://github.com/intuit/oauth-jsclient) (4.2.5).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/quickbooks` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `quickbooks` |
| HTTP operations | 40, browsable at `http://localhost:3999/docs/quickbooks` while it runs ([API reference](/api-reference.md)) |

```bash CLI theme={null}
npx @deploybaseline/cli start --service quickbooks
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const quickbooks = await createEmulator({ service: "quickbooks" });
// quickbooks.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "quickbooks": {
    "companies": [
      {
        "realm_id": "9341453050298367",
        "name": "Baseline Bikes",
        "owner": {
          "email": "owner@example.com",
          "given_name": "Ada",
          "family_name": "Lovelace"
        },
        "customers": [
          {
            "DisplayName": "Amy's Bird Sanctuary",
            "PrimaryEmailAddr": {
              "Address": "birds@example.com"
            }
          }
        ],
        "items": [
          {
            "Name": "Road bike",
            "Type": "Inventory",
            "UnitPrice": 1200,
            "PurchaseCost": 700,
            "QtyOnHand": 10,
            "InvStartDate": "2026-01-01",
            "IncomeAccountRef": {
              "name": "Sales of Product Income"
            },
            "ExpenseAccountRef": {
              "name": "Cost of Goods Sold"
            },
            "AssetAccountRef": {
              "name": "Inventory Asset"
            }
          }
        ],
        "invoices": [
          {
            "CustomerRef": {
              "name": "Amy's Bird Sanctuary"
            },
            "Line": [
              {
                "DetailType": "SalesItemLineDetail",
                "Amount": 1200,
                "SalesItemLineDetail": {
                  "ItemRef": {
                    "name": "Road bike"
                  },
                  "Qty": 1,
                  "UnitPrice": 1200
                }
              }
            ]
          }
        ]
      }
    ],
    "apps": [
      {
        "name": "My app",
        "client_id": "ABmyAppClientId",
        "client_secret": "my-app-client-secret",
        "redirect_uris": ["http://localhost:3000/quickbooks/callback"],
        "verifier_token": "my-verifier-token"
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service quickbooks
```

QuickBooks starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`. Without seed config, one company (realm `9341453050298367`) starts with the default chart and lists, and a built-in app is available with client ID `ABbaselineClientId0000000000000000000000000000000`, secret `baselineClientSecret00000000000000000000`, and any redirect URI.

node-quickbooks keeps the API base on the instance, and its token URLs are statics:

```ts theme={null}
import QuickBooks from "node-quickbooks";

const base = process.env.QBO_EMULATOR_URL ?? "http://localhost:4000";
QuickBooks.TOKEN_URL = `${base}/oauth2/v1/tokens/bearer`;
QuickBooks.REVOKE_URL = `${base}/v2/oauth2/tokens/revoke`;
QuickBooks.USER_INFO_URL = `${base}/v1/openid_connect/userinfo`;

const qbo = new QuickBooks(clientId, clientSecret, accessToken, false, realmId, true, false, "75", "2.0", refreshToken);
qbo.endpoint = `${base}/v3/company/`;

qbo.findCustomers({ DisplayName: "Amy's Bird Sanctuary" }, (err, data) => console.log(data.QueryResponse.Customer));
```

intuit-oauth takes the OAuth endpoints through `setAuthorizeURLs`, and the JWKS URL as a static:

```ts theme={null}
import OAuthClient from "intuit-oauth";

const oauth = new OAuthClient({ clientId, clientSecret, environment: "sandbox", redirectUri });
oauth.setAuthorizeURLs({
  authorizeEndpoint: `${base}/connect/oauth2`,
  tokenEndpoint: `${base}/oauth2/v1/tokens/bearer`,
  revokeEndpoint: `${base}/v2/oauth2/tokens/revoke`,
  userInfoEndpoint: `${base}/v1/openid_connect/userinfo`,
});
OAuthClient.jwks_uri = `${base}/op/v1/jwks`;
```

## URL mapping

| Real Intuit URL | Emulator URL |
| - | - |
| `https://sandbox-quickbooks.api.intuit.com/v3/company/{realmId}/...` | `$QBO_EMULATOR_URL/v3/company/{realmId}/...` |
| `https://quickbooks.api.intuit.com/v3/company/{realmId}/...` | `$QBO_EMULATOR_URL/v3/company/{realmId}/...` |
| `https://appcenter.intuit.com/connect/oauth2` | `$QBO_EMULATOR_URL/connect/oauth2` |
| `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer` | `$QBO_EMULATOR_URL/oauth2/v1/tokens/bearer` |
| `https://developer.api.intuit.com/v2/oauth2/tokens/revoke` | `$QBO_EMULATOR_URL/v2/oauth2/tokens/revoke` |
| `https://accounts.platform.intuit.com/v1/openid_connect/userinfo` | `$QBO_EMULATOR_URL/v1/openid_connect/userinfo` |
| `https://oauth.platform.intuit.com/op/v1/jwks` | `$QBO_EMULATOR_URL/op/v1/jwks` |

## Auth

Accounting requests send `Authorization: Bearer <token>`.

* A token the CLI accepts (for example `test_token_admin`) may call every company.
* `tokens[]` seeds access tokens that do not expire, each for one company (`realm_id`).
* OAuth access tokens last an hour and call the company chosen on the connect page. They need the `com.intuit.quickbooks.accounting` scope.

A missing, unknown, expired, or revoked token answers 401 with code `3200`. A token for another company, or one without the accounting scope, answers 403 with code `3100`. Both use Intuit's gateway layout (a lowercase `fault.error[]` body), as the real service does.

## OAuth 2.0

* `GET /connect/oauth2` — Connect page: with `client_id`, `redirect_uri`, `response_type=code`, `scope`, and `state`, it shows the companies to connect. Choosing one redirects with `code`, `state`, and `realmId` (only when the accounting or payment scope was asked for). Cancel redirects with `error=access_denied`.
* `POST /oauth2/v1/tokens/bearer` — Get tokens: takes the client credentials as Basic auth (or in the body) and a form body. `grant_type=authorization_code` answers an hour long access token, a refresh token good for 100 days, and with `openid` an RS256 `id_token` with Intuit's issuer, `aud` as an array, and `realmid`. Exchanging a code twice revokes what the first exchange issued.
* With `grant_type=refresh_token` the same call answers a new access token. Within 24 hours of a refresh token's issue it keeps its value and the 100 days roll forward; after that it rotates and the old value stops working. Refresh tokens expire outright after five years; send `x-include-refresh-token-hard-expires-in: true` for `x_refresh_token_hard_expires_in`. Set `refresh_rotation_ms` to shorten the 24 hours in tests.
* `POST /v2/oauth2/tokens/revoke` — Revoke tokens: with `{ "token": ... }` (JSON or a form) it ends the whole connection, both tokens.
* `GET /v1/openid_connect/userinfo` — Get user info: answers the company owner's `sub`, plus `email`, `givenName`, `familyName`, `phoneNumber`, and `address` as the scopes allow.
* Discovery at `/.well-known/openid_configuration` and `/.well-known/openid_sandbox_configuration`; keys at `/op/v1/jwks`, so `validateIdToken` in intuit-oauth succeeds.

## Requests and responses

* Responses are JSON. An `Accept` header that asks for XML only, or `format=xml`, answers 406 with an XML fault; an XML request body answers code `500`. Both messages are Baseline's own, since the real service would answer XML.
* `minorversion` is optional. Values below 75 and a missing value are served as 75, as Intuit does since August 2025. A value that is not a number answers `2010`.
* `requestid` on a POST makes it idempotent: the same `requestid` for the same company replays the first answer, faults included. It may be 50 characters long (36 for batch, where `bId` may then be 10).
* Errors use Intuit's envelope, `{ "Fault": { "Error": [{ "Message", "Detail", "code", "element" }], "type" }, "time" }`, with Intuit's codes: `610` object not found, `2000` unknown entity, `2010` invalid property, `2020` required parameter missing, `2030` invalid id, `2050` invalid length, `2170` invalid enumeration, `2500` invalid reference, `4000` and `4001` query errors, `5010` stale object, `6000` business validation, `6070` line amount mismatch, `6140` duplicate document number, `6240` duplicate name. Business validation messages are Baseline's wording.
* Every response carries an `intuit_tid` header and a `time` in Pacific time with an offset.
* Entity names in paths match without case (`/Invoice/12` and `/invoice/12`).

## Entities

* `POST /v3/company/{realmId}/{entity}` — Create or update entity

* `GET /v3/company/{realmId}/{entity}/{id}` — Read entity

* Updates send `Id` and `SyncToken`. `"sparse": true` changes only the fields sent; without it, writable fields left out are cleared. A `SyncToken` that is not the current one answers `5010`.

* `?operation=delete` deletes a transaction and answers the `{ "status": "Deleted" }` stub. List entities (customers, vendors, items, accounts, terms, payment methods) cannot be deleted: set `Active: false`, which renames them with ` (deleted)`.

* `?operation=void` voids an invoice, payment, sales receipt, or credit memo: amounts and quantities go to zero, links are cleared, and `PrivateNote` starts with `Voided`. node-quickbooks voids a payment as an update with `include=void`, which works too.

* `MetaData` carries `CreateTime` and `LastUpdatedTime`. Ref names (`CustomerRef.name`, `ItemRef.name`) are filled on every read.

| Entity | Notes |
| - | - |
| `customer` | `DisplayName` comes from the name parts when left out and is unique across customers and vendors (`6240`). Jobs use `ParentRef` and get `FullyQualifiedName` and `Level`. `Balance` and `BalanceWithJobs` are derived |
| `vendor` | Same naming rules as customers |
| `item` | `Inventory` needs income, expense, and asset accounts, `QtyOnHand`, and `InvStartDate`; `QtyOnHand` moves with sales. `Service` and `NonInventory` need an income or expense account |
| `account` | `AccountType` or `AccountSubType` is required, and a subtype must belong to its type. `Classification` and `CurrentBalance` are derived |
| `term` | `STANDARD` with `DueDays`, or `DATE_DRIVEN` with `DayOfMonthDue` |
| `paymentmethod` | `CREDIT_CARD` or `NON_CREDIT_CARD` |
| `invoice` | Lines are `SalesItemLineDetail`, `DescriptionOnly`, `DiscountLineDetail` (amount or percent), with a `SubTotalLine` added. `Amount` must equal `UnitPrice` times `Qty` (`6070`). `DueDate` follows `SalesTermRef` or the default terms. `Balance` drops as payments apply. Linking an estimate in `LinkedTxn` closes it |
| `estimate` | `TxnStatus` is `Pending` until an invoice links it, then `Closed`. Only one invoice may link an estimate |
| `payment` | `Line[].LinkedTxn` applies to invoices and credit memos of the same customer or its jobs; the rest is `UnappliedAmt`. Overpaying an invoice, or using more credit than a credit memo has left, answers `6000` |
| `creditmemo` | `RemainingCredit` drops as payments use it |
| `salesreceipt` | Paid at once: deposits to `DepositToAccountRef` (Undeposited Funds by default) and moves inventory |

`DocNumber` is numbered from 1001 unless `Preferences.SalesFormsPrefs.CustomTxnNumbers` is on. With custom numbers, a duplicate answers `6140` unless the request sends `include=allowduplicatedocnum`.

New companies start with a chart of accounts (Checking, Savings, Accounts Receivable, Undeposited Funds, Inventory Asset, Accounts Payable, Opening Balance Equity, Retained Earnings, Sales of Product Income, Services, Discounts given, Cost of Goods Sold, Office Expenses), the terms Due on receipt, Net 15, Net 30 (the default), and Net 60, the payment methods Cash, Check, Visa, MasterCard, and American Express, and the items Services and Hours.

## Company

* `GET /v3/company/{realmId}/companyinfo/{realmId}` — Read company info: `POST .../companyinfo` makes a sparse update
* `GET /v3/company/{realmId}/preferences` — Read preferences: `POST .../preferences` makes a sparse update

## Query

* `GET /v3/company/{realmId}/query?query=...` — Query entities: or `POST .../query` with the statement as an `application/text` body

```sql theme={null}
SELECT * FROM Invoice WHERE TotalAmt > '100' AND TxnDate >= '2026-01-01' ORDERBY TxnDate DESC STARTPOSITION 1 MAXRESULTS 50
SELECT COUNT(*) FROM Customer WHERE DisplayName LIKE '%Bird%'
SELECT * FROM Invoice WHERE Id IN ('12', '13') AND MetaData.LastUpdatedTime > '2026-10-01T00:00:00-07:00'
```

* Operators are `=`, `<`, `>`, `<=`, `>=`, `IN`, and `LIKE` (with `%`), joined by `AND`. `OR`, `NOT`, `!=`, joins, and grouping answer `4000` with a parser message naming the line and column. Strings are single-quoted, with `\'` for a quote.
* An unknown property answers `4001` (`property 'X' is not queryable`), and `Id` takes only `=` and `IN`. Refs compare by their value, string comparison ignores case, and dates and times compare as instants.
* Lists return only active entries unless the query filters on `Active`.
* `MAXRESULTS` defaults to 100 and is capped at 1000. The response's `maxResults` is the number returned, and an empty result is `"QueryResponse": {}`. `COUNT(*)` answers `totalCount`.
* A field list (`SELECT Id, DisplayName FROM Customer`) is accepted and checked, and whole objects are returned.

## Batch

* `POST /v3/company/{realmId}/batch` — Batch request: up to 30 `BatchItemRequest` entries (`1040` beyond that)

Each has a unique `bId` and is a `Query` or an entity with `operation` `create`, `update`, `delete`, or `void`. Items run in order and on their own: a failed item answers its `Fault` under its `bId` and the rest still run.

## Change data capture

* `GET /v3/company/{realmId}/cdc?entities=Invoice,Customer&changedSince=2026-10-01T00:00:00-07:00` — Change data capture

It answers every object of those types changed since then, deleted transactions as `{ "status": "Deleted" }` stubs, at most 1000 objects. `changedSince` must be within the last 30 days. When nothing changed, `QueryResponse` is `[{}]`.

## Webhooks

An app with a `webhook_url` hears changes to the companies it is connected to (an unrevoked OAuth connection) and the companies in its `webhook_realms` (every company when left out), for the entities in `webhook_entities` (every entity when left out). The changes one request makes go out together, in one POST per app.

The body is CloudEvents by default (a JSON array, Intuit's format since May 2026):

```json theme={null}
[
  {
    "specversion": "1.0",
    "id": "88cd52a5-...",
    "source": "intuit.dKI...",
    "type": "qbo.invoice.created.v1",
    "datacontenttype": "application/json",
    "time": "2026-10-20T16:15:02.123Z",
    "intuitentityid": "130",
    "intuitaccountid": "9341453050298367",
    "data": {}
  }
]
```

`webhook_format: legacy` sends the older `eventNotifications` envelope instead. Either way the `intuit-signature` header is the base64 HMAC-SHA256 of the raw body keyed with the app's `verifier_token`. Requests also carry `intuit-created-time`, `intuit-t-id`, `intuit-notification-schema-version: 0.1`, and `User-Agent: intuit_notification_server/0.1`. A delivery that does not answer 2xx within three seconds is retried after 1, 2, and 3 seconds.

## Seed config

```json theme={null}
{
  "quickbooks": {
    "companies": [
      {
        "realm_id": "9341453050298367",
        "name": "Baseline Bikes",
        "owner": {
          "email": "owner@example.com",
          "given_name": "Ada",
          "family_name": "Lovelace"
        },
        "preferences": {
          "SalesFormsPrefs": {
            "CustomTxnNumbers": false
          }
        },
        "accounts": [
          {
            "Name": "Business Checking",
            "AccountType": "Bank",
            "OpeningBalance": 5000
          }
        ],
        "customers": [
          {
            "DisplayName": "Amy's Bird Sanctuary",
            "PrimaryEmailAddr": {
              "Address": "birds@example.com"
            }
          }
        ],
        "items": [
          {
            "Name": "Road bike",
            "Type": "Inventory",
            "UnitPrice": 1200,
            "PurchaseCost": 700,
            "QtyOnHand": 10,
            "InvStartDate": "2026-01-01",
            "IncomeAccountRef": {
              "name": "Sales of Product Income"
            },
            "ExpenseAccountRef": {
              "name": "Cost of Goods Sold"
            },
            "AssetAccountRef": {
              "name": "Inventory Asset"
            }
          }
        ],
        "invoices": [
          {
            "CustomerRef": {
              "name": "Amy's Bird Sanctuary"
            },
            "Line": [
              {
                "DetailType": "SalesItemLineDetail",
                "Amount": 1200,
                "SalesItemLineDetail": {
                  "ItemRef": {
                    "name": "Road bike"
                  },
                  "Qty": 1,
                  "UnitPrice": 1200
                }
              }
            ]
          }
        ]
      }
    ],
    "apps": [
      {
        "name": "My app",
        "client_id": "ABmyAppClientId",
        "client_secret": "my-app-client-secret",
        "redirect_uris": ["http://localhost:3000/quickbooks/callback"],
        "verifier_token": "my-verifier-token",
        "webhook_url": "http://localhost:3000/quickbooks/webhooks",
        "webhook_entities": ["Invoice", "Payment"]
      }
    ],
    "tokens": [
      {
        "access_token": "qbo-local-token",
        "realm_id": "9341453050298367"
      }
    ]
  }
}
```

* `companies[]` take entities in API form under `accounts`, `terms`, `payment_methods`, `customers`, `vendors`, `items`, `estimates`, `invoices`, `credit_memos`, `sales_receipts`, and `payments`. Refs may name their target (`{ name: Road bike }`) instead of an Id. `OpeningBalance` on a balance sheet account sets its starting balance.

## Inspector

`GET /_inspector` has tabs for the company (with a company switcher), customers and vendors, items, accounts, transactions, and apps with their connections and webhook deliveries.

## Limits

Reports, attachments and uploads, PDFs and email sending, bills, purchases, journal entries, deposits, transfers, classes, departments, tax codes and sales tax, employees and time activities, multicurrency, recurring transactions, entity merges, the Payments API, and XML. Baseline never rate limits; inject Intuit's errors with `faults` to test retries.

## Conformance

| | |
| - | - |
| Routes | 42 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | no public spec |
| SDKs tested | `intuit-oauth@4.2.5`, `node-quickbooks@2.0.50` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.