You've decided to integrate QuickBooks Online into your app. Good call — it's the accounting platform your customers actually use. Now comes the part nobody warns you about: there's a big difference between getting it working in a sandbox and shipping something real.
This guide covers the full path from initial setup to production, including the parts Intuit's documentation glosses over: getting production OAuth approval, keeping tokens alive, syncing data reliably, and what the Intuit App Partner Program actually means for your costs.
If you want the technical deep-dive on specific APIs, check out:
- How to get your QuickBooks API Key
- Exploring the QuickBooks Online Accounting API
- How to integrate the QuickBooks Invoice API
- QuickBooks API Pricing and the Intuit App Partner Program
This post is about architecture decisions, production readiness, and what the integration actually costs you to build and maintain.
What You're Building
Before writing a line of code, nail down which type of integration you need.
Push-only integrations write data into QuickBooks from your system — creating invoices when deals close, syncing payments from your payment processor, creating expense records from employee submissions. These are mostly POST operations, which are free under Intuit's current pricing regardless of volume.
Pull integrations read data from QuickBooks — syncing the chart of accounts, pulling transaction history, fetching reconciliation data. These are GET operations (CorePlus API calls), which are metered starting at 500,000 calls/month on the free tier.
Bidirectional sync does both. This is the most common real-world requirement and the most complex to implement correctly, especially around conflict resolution when both systems can modify the same record.
Knowing which type you need determines your architecture, your costs under the Intuit App Partner Program, and how much operational complexity you're signing up for.
The OAuth 2.0 Flow
QuickBooks uses OAuth 2.0 with a few quirks worth knowing before you build.
Setting Up
Create your app at developer.intuit.com. You get two sets of credentials:
- Development: Works with sandbox companies only. No approval needed.
- Production: Works with real QuickBooks accounts. Requires app assessment (more on this later).
Set your redirect URIs in the app settings. In development, http://localhost is fine. In production, you need HTTPS — no exceptions.
The Authorization Flow
// Step 1: Redirect user to QuickBooks consent page
const authUrl = `https://appcenter.intuit.com/connect/oauth2?
client_id=${CLIENT_ID}
&response_type=code
&scope=com.intuit.quickbooks.accounting
&redirect_uri=${encodeURIComponent(REDIRECT_URI)}
&state=${generateSecureState()}`;
res.redirect(authUrl);
// Step 2: Handle the callback
app.get('/callback', async (req, res) => {
const { code, realmId, state } = req.query;
// Validate state to prevent CSRF
if (state !== getStoredState()) {
return res.status(400).send('State mismatch');
}
const tokens = await exchangeCodeForTokens(code, realmId);
await saveTokens(realmId, tokens);
res.redirect('/dashboard');
});
// Step 3: Exchange code for tokens
async function exchangeCodeForTokens(code, realmId) {
const response = await fetch('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', {
method: 'POST',
headers: {
'Authorization': `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI
})
});
const { access_token, refresh_token, expires_in } = await response.json();
return {
accessToken: access_token,
refreshToken: refresh_token,
expiresAt: Date.now() + (expires_in * 1000),
realmId
};
}
Token Lifecycle Management
This is where most integrations break in production. Access tokens expire after 60 minutes. Refresh tokens for the com.intuit.quickbooks.accounting scope have a maximum validity of 5 years (as of Intuit's updated November 2025 policy — previously they were rolling with no hard expiry as long as used every 100 days).
What this means practically:
- Build proactive token refresh (refresh before expiry, not after a 401)
- Tokens will eventually reach their 5-year hard expiry — build a reconnection flow for when that happens
- Store the
refresh_token_expiryvalue returned in the token response to know when a user will need to reauthorize
async function getValidAccessToken(realmId) {
const tokens = await getStoredTokens(realmId);
// Refresh if within 5 minutes of expiry
if (tokens.expiresAt - Date.now() < 5 * 60 * 1000) {
return await refreshAccessToken(realmId, tokens.refreshToken);
}
return tokens.accessToken;
}
async function refreshAccessToken(realmId, refreshToken) {
const response = await fetch('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', {
method: 'POST',
headers: {
'Authorization': `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken
})
});
if (!response.ok) {
await markConnectionExpired(realmId);
throw new Error('TOKEN_EXPIRED');
}
const { access_token, refresh_token, expires_in } = await response.json();
await saveTokens(realmId, {
accessToken: access_token,
refreshToken: refresh_token,
expiresAt: Date.now() + (expires_in * 1000)
});
return access_token;
}
Store tokens in a database, not in memory or session storage. Index by realmId (the QuickBooks company ID). One user can have multiple companies.
Making API Calls
The base URL differs between sandbox and production:
const BASE_URL = process.env.NODE_ENV === 'production'
? 'https://quickbooks.api.intuit.com'
: 'https://sandbox-quickbooks.api.intuit.com';
async function qboRequest(realmId, method, path, body = null) {
const accessToken = await getValidAccessToken(realmId);
const response = await fetch(
`${BASE_URL}/v3/company/${realmId}${path}?minorversion=75`,
{
method,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: body ? JSON.stringify(body) : null
}
);
if (!response.ok) {
const error = await response.json();
throw new QBOError(error, response.status);
}
return response.json();
}
Always include minorversion=75 in your requests. This pins you to a specific API version so Intuit's schema changes don't silently break your integration.
Data Sync Patterns
The hardest part of a QBO integration isn't the API calls — it's keeping data consistent between two systems.
Initial Sync
For a new connection, you need to pull all historical data. QuickBooks uses a SQL-like query language with pagination:
async function syncAllInvoices(realmId) {
let startPosition = 1;
const pageSize = 1000; // Max allowed
let hasMore = true;
while (hasMore) {
const result = await qboRequest(
realmId,
'GET',
`/query?query=SELECT * FROM Invoice STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}`
);
const invoices = result.QueryResponse?.Invoice || [];
await upsertInvoices(realmId, invoices);
hasMore = invoices.length === pageSize;
startPosition += pageSize;
}
}
For large accounts, this can take a while. Run it as a background job, not in the request lifecycle.
Incremental Sync
After the initial sync, only pull records modified since your last sync:
async function syncRecentInvoices(realmId, lastSyncTime) {
const since = lastSyncTime.toISOString().split('.')[0];
const result = await qboRequest(
realmId,
'GET',
`/query?query=SELECT * FROM Invoice WHERE MetaData.LastUpdatedTime >= '${since}'`
);
return result.QueryResponse?.Invoice || [];
}
Store the last successful sync time per-company in your database. If a sync fails partway through, rerun from the last checkpoint.
Conflict Resolution with SyncToken
QuickBooks uses SyncToken for optimistic locking. Any update must include the current SyncToken or QuickBooks rejects it with error code 5010 (stale object):
async function updateInvoice(realmId, invoiceId, changes) {
// Fetch current version to get SyncToken
const current = await qboRequest(realmId, 'GET', `/invoice/${invoiceId}`);
const invoice = current.Invoice;
const response = await qboRequest(realmId, 'POST', '/invoice', {
...invoice,
...changes,
Id: invoiceId,
SyncToken: invoice.SyncToken,
sparse: true
});
return response.Invoice;
}
If you get 5010, refetch and retry.
Webhooks
Polling for changes works but burns through your CorePlus API quota. Use webhooks for real-time updates.
In the Intuit Developer Portal, go to your app → Webhooks and register your endpoint.
app.post('/webhooks/quickbooks', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['intuit-signature'];
const payload = req.body;
if (!verifyWebhookSignature(payload, signature)) {
return res.status(401).send('Invalid signature');
}
// Respond immediately — Intuit disables slow endpoints
res.status(200).send('OK');
// Process async
processWebhookPayload(JSON.parse(payload)).catch(console.error);
});
function verifyWebhookSignature(payload, signature) {
const computed = crypto
.createHmac('sha256', WEBHOOK_VERIFIER_TOKEN)
.update(payload)
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computed)
);
}
async function processWebhookPayload(payload) {
for (const notification of payload.eventNotifications) {
const { realmId, dataChangeEvent } = notification;
for (const entity of dataChangeEvent.entities) {
await queueSyncJob(realmId, entity.name, entity.id, entity.operation);
}
}
}
Two non-negotiables: verify the signature (Intuit will disable your webhooks if you skip this), and respond with 200 before doing any processing.
Webhooks tell you what changed, not the new value. You still need to fetch the updated record via API. They can also arrive out of order or be delayed by hours during incidents. Design sync logic to be idempotent, and run a periodic poll as a fallback.
Getting to Production
Moving from sandbox to production requires Intuit's approval — this is where teams get surprised.
The App Assessment
Before you can use production credentials, you need to complete Intuit's app assessment:
- Security questionnaire: How you store tokens, your data retention policy, whether you're SOC 2 compliant
- Use case review: What your app does, which APIs you use, who your users are
- Demo: For some apps, Intuit wants to see it working
- Terms of Service: Developer TOS plus the Intuit App Partner Program terms
Budget 1-3 weeks. Fill in the questionnaire thoroughly and you'll get through it.
The Intuit App Partner Program
Once approved, your tier determines API costs:
| Tier | Monthly Fee | CorePlus Calls Included | Overage Rate |
|---|---|---|---|
| Builder | Free | 500,000 | Blocked |
| Silver | $300 | 1,000,000 | $3.50/1k |
| Gold | $1,700 | Higher allocation | Better rates |
| Platinum | $4,500 | Up to 75M | $0.25/1k |
Core (write) API calls are free at every tier. For most apps, Builder is fine at launch.
Listing on the Intuit App Store
Optional but worth it — the App Store is a real distribution channel. Customers searching for integrations inside QuickBooks can find your app.
To list:
- Get production access (app assessment must be complete)
- Build a landing page with a clear description, privacy policy, and support contact
- Submit for App Store review in the developer portal
- Pass a UX review: Intuit checks that your OAuth flow is clean
Being listed also signals legitimacy to enterprise buyers doing procurement reviews.
The Apideck Alternative
Everything above is the native path. It works, but it takes weeks to build and requires ongoing maintenance — complexity that scales with each additional accounting platform your customers use.
Apideck's Unified Accounting API handles OAuth, token management, data normalization, and rate limit handling for QuickBooks and 30+ other platforms through a single integration.
Native vs Apideck
Native QuickBooks:
// You're managing:
// OAuth 2.0 + CSRF, token storage + refresh (60-min access token expiry,
// 5-year refresh token maximum), reconnection flows, sandbox vs prod routing,
// QBO data model (SyncToken, DetailType, ItemRef nesting), CorePlus quota
// tracking, platform-specific error codes, webhook signature verification,
// pagination, minorversion pinning, app assessment + App Store listing...
// ...multiplied by every additional accounting platform you add later
const invoice = await qboRequest(realmId, 'POST', '/invoice', {
CustomerRef: { value: customerId },
Line: lineItems.map(item => ({
Amount: item.qty * item.price,
DetailType: 'SalesItemLineDetail',
SalesItemLineDetail: {
ItemRef: { value: item.productId },
Qty: item.qty,
UnitPrice: item.price
}
})),
DueDate: dueDate
});
With Apideck:
import { Apideck } from '@apideck/node';
const apideck = new Apideck({
apiKey: process.env.APIDECK_API_KEY,
appId: process.env.APIDECK_APP_ID
});
// Same code works for QuickBooks, Xero, NetSuite, Sage
const { data } = await apideck.accounting.invoices.add({
consumerId: userId,
serviceId: 'quickbooks', // swap to 'xero' or 'netsuite' anytime
invoice: {
customer_id: customerId,
line_items: lineItems.map(item => ({
description: item.name,
quantity: item.qty,
unit_price: item.price
})),
due_date: dueDate
}
});
Apideck's Vault handles the OAuth flow. You embed a pre-built UI component, users connect their QuickBooks account, and Apideck manages credentials from there.
Build native if: QuickBooks is the only platform you'll ever need, you have deep customization requirements, or you have the bandwidth to build and maintain it.
Use Apideck if: You'll need more than one accounting integration, you want to ship in days rather than weeks, or you'd rather not manage OAuth infrastructure.
For most B2B SaaS companies, QuickBooks is first but not last. The second integration is when teams switch to a unified API.
Common Production Issues
Token expiry during long-running jobs: If a background sync exceeds 60 minutes, your access token expires mid-job. Refresh proactively before starting any long operation, and handle token refresh inside your sync loop.
Multiple company files: One QuickBooks user can have multiple companies. Don't assume one user = one realmId.
Sandbox vs production data divergence: Sandbox data resets periodically. Your integration needs to handle IDs that exist in sandbox but not production.
Rate limits at scale: 500 requests per minute per company seems generous until you're doing an initial sync for a customer with a decade of transaction history. Implement request queuing and respect Retry-After headers.
Webhook reliability: Intuit webhooks can be delayed by hours during incidents. Don't rely on them as your only sync mechanism.
Next Steps
If you're going native:
- Create your developer account at developer.intuit.com
- Set up a sandbox company and test your OAuth flow
- Build and test sync logic against sandbox data
- Complete the app assessment to get production credentials
- Submit for App Store listing
If you're using Apideck:
- Sign up and get your API key
- Embed Vault for OAuth
- Start calling the Unified Accounting API
The investment is real either way. So is the payoff — QuickBooks integration is a checklist item on a lot of procurement reviews, and having it is often the difference between winning and losing deals in the verticals where QuickBooks dominates.
Related guides:
Ready to get started?
Scale your integration strategy and deliver the integrations your customers need in record time.








