A developer guide to the QuickBooks Online API: OAuth 2.0 setup, querying data, pagination, rate limits, and syncing to PostgreSQL without the plumbing.
By Ilshaad Kheerdali · 15 Jun 2026
If you're integrating with QuickBooks Online, the Intuit API is powerful but it has a learning curve: OAuth 2.0 with rotating refresh tokens, a SQL-like query language, per-company rate limits, and sandbox/production environments that behave differently. This guide walks through the whole flow end to end, so you can go from zero to reading live company data, then shows the shortcut if you'd rather skip the plumbing entirely.
Everything below targets the QuickBooks Online Accounting API (the cloud product), not the older QuickBooks Desktop SDK.
What the QuickBooks API Is
The QuickBooks Online API is a REST API hosted by Intuit. You authenticate against a specific company (Intuit calls it a realm, identified by a realmId) and read or write accounting entities: Customer, Invoice, Payment, Bill, Vendor, Item, Account, and around 30 others.
Two things make it different from a typical REST API:
- Every request is scoped to a realm. The company ID is part of the URL, so a single access token can only touch the company that authorised it.
-
Reads use a query language, not REST filters. Instead of
GET /customers?active=true, you send a SQL-like string to a single/queryendpoint.
Step 1: Create an Intuit Developer App
Before any code, you need credentials:
- Sign up at the Intuit Developer portal and create an app under the QuickBooks Online and Payments platform.
- Grab your Client ID and Client Secret from the app's Keys & credentials section. There's a separate pair for Development (sandbox) and Production.
- Add a Redirect URI (e.g.
https://yourapp.com/callback). It must match exactly what you send during OAuth. - Note the scope you need:
com.intuit.quickbooks.accountingfor accounting data.
Intuit also provisions a free sandbox company so you can develop against realistic data without touching a real business.
Step 2: Authenticate with OAuth 2.0
QuickBooks uses the OAuth 2.0 authorization code flow. The user is redirected to Intuit, approves access, and you exchange the returned code for tokens.
import OAuthClient from 'intuit-oauth';
const oauthClient = new OAuthClient({
clientId: process.env.QB_CLIENT_ID,
clientSecret: process.env.QB_CLIENT_SECRET,
environment: 'sandbox', // or 'production'
redirectUri: process.env.QB_REDIRECT_URI,
});
// 1. Send the user here to authorise
const authUri = oauthClient.authorizeUri({
scope: [OAuthClient.scopes.Accounting],
state: 'a-random-csrf-token',
});
// 2. In your redirect handler, exchange the code for tokens
app.get('/callback', async (req, res) => {
const authResponse = await oauthClient.createToken(req.url);
const token = authResponse.getJson();
// token.access_token -> use for API calls (valid ~1 hour)
// token.refresh_token -> use to get new access tokens (valid ~100 days)
// realmId comes in as a query param on the redirect
const realmId = req.query.realmId;
// Persist refresh_token + realmId securely (you'll need both later)
});
Three things trip people up here:
-
The
realmIdis not in the token. It arrives as a separate query parameter on the redirect. Store it alongside the tokens — you need it in every API URL. - Access tokens expire in ~1 hour. Short-lived by design. You refresh them with the refresh token.
- Refresh tokens rotate. Each refresh can return a new refresh token and the old one eventually stops working. Always persist the latest one you receive, or you'll get locked out after ~100 days of inactivity.
// Refresh an expired access token
const refreshResponse = await oauthClient.refreshUsingToken(
storedRefreshToken,
);
const newToken = refreshResponse.getJson();
// Save newToken.refresh_token — it may have changed
Step 3: Make Your First API Call
API URLs follow the pattern /v3/company/{realmId}/{resource}, against different base hosts for sandbox and production:
- Sandbox:
https://sandbox-quickbooks.api.intuit.com - Production:
https://quickbooks.api.intuit.com
Here's reading a single customer by ID:
const baseUrl = 'https://sandbox-quickbooks.api.intuit.com';
const res = await fetch(
`${baseUrl}/v3/company/${realmId}/customer/1?minorversion=75`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
);
const data = await res.json();
console.log(data.Customer.DisplayName);
Always pin a minorversion — Intuit uses it to version response payloads. As of 2026 the minimum supported (and default) is 75; Intuit deprecated versions 1–74 in 2025, so pin an explicit current version rather than relying on "latest".
Step 4: Query Data with the QuickBooks Query Language
For anything beyond fetching by ID, you use the /query endpoint with a SQL-like statement. This is how you list, filter, and page through records.
const query = "SELECT * FROM Invoice WHERE TxnDate > '2026-01-01'";
const res = await fetch(
`${baseUrl}/v3/company/${realmId}/query?query=${encodeURIComponent(query)}&minorversion=75`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
);
const data = await res.json();
const invoices = data.QueryResponse.Invoice ?? [];
Pagination is manual. The API returns up to 1,000 rows per call, and you walk the result set with STARTPOSITION and MAXRESULTS:
let startPosition = 1;
const pageSize = 1000;
const allInvoices = [];
while (true) {
const q = `SELECT * FROM Invoice STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}`;
const res = await fetch(
`${baseUrl}/v3/company/${realmId}/query?query=${encodeURIComponent(q)}&minorversion=75`,
{ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } },
);
const page = (await res.json()).QueryResponse.Invoice ?? [];
allInvoices.push(...page);
if (page.length < pageSize) break; // last page
startPosition += pageSize;
}
Note the query language is a subset of SQL — no JOINs, limited functions, and each entity is queried separately.
Step 5: Handle Rate Limits and Errors
QuickBooks throttles per company (realm). As of 2026, Intuit documents 500 requests per minute per realm and a maximum of 10 concurrent requests in production, with the batch endpoint throttled separately at 40 requests per minute per realm. These limits change over time, so check the current limits in Intuit's docs. Exceed them and you get HTTP 429.
Errors come back in a Fault object, not as plain HTTP status text:
if (!res.ok) {
const body = await res.json();
const fault = body.Fault?.Error?.[0];
// e.g. { Message: 'message', Detail: '...', code: '3200' }
throw new Error(`QuickBooks error ${fault?.code}: ${fault?.Message}`);
}
Build in exponential backoff on 429 and 5xx, and use the batch endpoint (/batch, up to 30 operations per call) to cut request volume when you're reading or writing many records — though note the batch endpoint has its own, tighter per-minute throttle, so it reduces total calls rather than letting you burst past the realm limit.
Step 6: Keep Data in Sync with Change Data Capture
Polling everything on a schedule wastes calls. For incremental updates, QuickBooks offers a Change Data Capture (CDC) endpoint that returns only entities changed since a timestamp:
const since = '2026-06-01T00:00:00-00:00';
const entities = 'Customer,Invoice,Payment';
const res = await fetch(
`${baseUrl}/v3/company/${realmId}/cdc?entities=${entities}&changedSince=${since}&minorversion=75`,
{ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } },
);
CDC is the backbone of any efficient sync: pull a full snapshot once, then poll CDC for deltas.
The Hard Parts (and Why Many Teams Don't Build This Themselves)
A working integration is achievable, but keeping it running is the real cost:
- Token lifecycle. Refreshing every hour, persisting rotating refresh tokens, and recovering when a refresh fails.
- Sandbox vs production. Separate credentials, separate base URLs, separate company data — easy to misconfigure on go-live.
- Pagination and rate limits. Every entity paginated separately, backoff on throttling, batching to stay under caps.
-
Schema mapping. QuickBooks objects are deeply nested; flattening
Invoice.Line[],LinkedTxn, and custom fields into clean relational tables is real work. - Ongoing maintenance. Minor-version bumps, new fields, and deprecations mean the integration is never truly "done."
If your end goal is simply getting QuickBooks data into your own database to query and report on, all of the above is plumbing that doesn't differentiate your product.
A Simpler Path: Sync QuickBooks to PostgreSQL with No Code
If you'd rather skip the OAuth dance, pagination, and schema mapping, Codeless Sync connects QuickBooks to your PostgreSQL database (Supabase, Neon, Railway, AWS RDS, or any Postgres host) in about 5 minutes. You authorise QuickBooks via OAuth once, and CLS handles token refresh, CDC-based incremental sync, pagination, rate limits, and table creation for you. Your data lands as clean relational tables, ready for SQL:
-- Top 10 customers by invoiced revenue this year
SELECT c.display_name, SUM(i.total_amt) AS invoiced
FROM quickbooks_invoices i
JOIN quickbooks_customers c ON c.id = i.customer_id
WHERE i.txn_date >= '2026-01-01'
GROUP BY c.display_name
ORDER BY invoiced DESC
LIMIT 10;
The same model works for Stripe, Xero, and Paddle too, so multi-provider billing all lands in one database. There's a free tier, no credit card required.
For a step-by-step walkthrough, see How to Sync QuickBooks Data to PostgreSQL Automatically. If you're weighing export options more broadly, How to Export QuickBooks Data to a Database compares five methods side by side.
Frequently Asked Questions
Is the QuickBooks API free to use?
Yes. Access to the QuickBooks Online Accounting API is free for developers — there's no per-call charge from Intuit. You do need a QuickBooks Online subscription (or the free sandbox company) for the data, and your own infrastructure to run the integration.
How long do QuickBooks access tokens last?
Access tokens are valid for about one hour. Refresh tokens last around 100 days but rotate — each refresh can return a new refresh token, and you must persist the latest one or you'll lose access after the window expires.
Does QuickBooks have webhooks?
Yes. QuickBooks Online offers Event Notifications (webhooks) covering most major entities — including Customer, Invoice, Payment, Bill, Item, Account, and around two dozen others — for create, update, delete, void, and merge events. They're genuinely useful for reacting to changes in real time. What they don't give you is historical backfill or a guaranteed gap-free feed, so for keeping a database fully in sync, teams typically combine a one-time snapshot with the Change Data Capture (CDC) endpoint and scheduled polling. Note Intuit is migrating webhook payloads to the CloudEvents format, with all apps required to move by July 31, 2026; both the old and new formats are supported during the transition, so check your payload parsing against the current spec.
What's the QuickBooks API rate limit?
As of 2026, Intuit throttles per company (realm) at 500 requests per minute, with a maximum of 10 concurrent requests in production. The batch endpoint is throttled separately at 40 requests per minute per realm. These limits change over time, so check Intuit's official limits documentation and implement exponential backoff on HTTP 429 responses.
Can I sync QuickBooks data to PostgreSQL without writing code?
Yes. Tools like Codeless Sync handle the OAuth flow, token refresh, pagination, and schema mapping for you, writing QuickBooks data straight into PostgreSQL tables. You authorise once and the data stays in sync on a schedule.
Related:
Top comments (0)