How to Integrate Your App with QuickBooks Online: A Complete Guide for 2026

A practical guide to building a production-ready QuickBooks Online integration — covering OAuth setup, data sync architecture, webhooks, production approval, the Intuit App Store, and how Apideck cuts months of work down to days.

Saurabh RaiSaurabh Rai

Saurabh Rai

13 min read
How to Integrate Your App with QuickBooks Online: A Complete Guide for 2026

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:

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_expiry value 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:

TierMonthly FeeCorePlus Calls IncludedOverage Rate
BuilderFree500,000Blocked
Silver$3001,000,000$3.50/1k
Gold$1,700Higher allocationBetter rates
Platinum$4,500Up 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:

  1. Get production access (app assessment must be complete)
  2. Build a landing page with a clear description, privacy policy, and support contact
  3. Submit for App Store review in the developer portal
  4. 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:

  1. Create your developer account at developer.intuit.com
  2. Set up a sandbox company and test your OAuth flow
  3. Build and test sync logic against sandbox data
  4. Complete the app assessment to get production credentials
  5. Submit for App Store listing

If you're using Apideck:

  1. Sign up and get your API key
  2. Embed Vault for OAuth
  3. 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.

Ready to get started?
Talk to an expert

Trusted by fast-moving product & engineering teams

JobNimbus
Blue Zinc
Drata
Octa
Nmbrs
Apideck Blog

Insights, guides, and updates from Apideck

Discover company news, API insights, and expert blog posts. Explore practical integration guides and tech articles to make the most of Apideck's platform.