Services
Custom software developmentUmbraco developmentWeb developmentCRM developmentERP developmentAI & LLM integration.NET development & modernizationE-commerce developmentIntegrations & automationCloud & DevOpsMaintenance, support & project rescueDedicated teams & white-label
Our Work
Case studiesProducts
About Blog Contact
CRM & ERP

QuickBooks ERP Integration: How to Design a Reliable Two-Way Sync for a Custom ERP

How to build a QuickBooks two-way sync that finance can trust: pick the right API, decide which system owns each record, map IDs, make writes idempotent, run sync as background jobs and reconcile every day.

Key takeaways

  • Decide record ownership before writing code: one system creates each record type, the other only reads it or adds narrowly defined fields.
  • QuickBooks Online throttles at 500 requests per minute and 10 requests per second per company and app, so sync must be queued and rate-limited, not fired inline from the UI.
  • Webhooks tell you that something changed, Change Data Capture tells you what changed, and you need both because Intuit itself recommends CDC to cover missed events.
  • Send a unique requestid on every create or update and handle stale SyncToken errors, so retries never create duplicate invoices or overwrite newer edits.
  • A daily reconciliation report that compares ERP totals with QuickBooks totals is what turns a working integration into one the finance team relies on.

A reliable QuickBooks ERP integration comes down to five decisions made before any code: which QuickBooks product you are connecting to (Online or Desktop), which system owns each record type, how IDs are mapped between the two, how every write is made safe to retry, and how you prove each day that both systems hold the same numbers. Get those right and the API calls themselves are the easy part.

This guide walks through those decisions for a custom ERP that keeps QuickBooks as the ledger. It uses Intuit’s documentation as of October 2026 for limits and behavior, and two ERP projects OSKI has built as worked examples.

QuickBooks Online API vs QuickBooks Desktop: pick the integration path first

The two products look similar to accountants but are completely different to integrate with.

QuickBooks Online QuickBooks Desktop
Interface REST Accounting API (JSON), plus a GraphQL API for some features Desktop SDK with qbXML messages, or QBFC classes that build them
Where your code runs Anywhere, calls Intuit’s cloud Same machine or local network as QuickBooks, or a web service reached through the QuickBooks Web Connector
Auth OAuth 2.0 per company (realm ID) Application authorization inside QuickBooks, Web Connector config file
Change detection Webhooks and Change Data Capture You query for modified records yourself
Typical sync pattern Push and pull on your schedule, react to webhooks Web Connector polls your service, which hands it queued qbXML requests

QuickBooks Desktop. Intuit’s Desktop SDK lets applications talk to locally installed QuickBooks through qbXML, an XML message format, with a session manager handling the connection. The QuickBooks Web Connector is a Windows application that lets a web service exchange data with QuickBooks Desktop. In practice that means your cloud ERP cannot call QuickBooks directly; it keeps a queue of pending requests and waits for the Web Connector to ask for them. Also factor in the product’s direction: Intuit stopped selling new US subscriptions to Desktop Pro Plus, Premier Plus and Mac Plus after September 30, 2024, while Desktop Enterprise remains on sale. If a client is on Desktop today, ask whether a move to Online is planned before you build twice.

QuickBooks Online. The rest of this article focuses on Online, because that is where most new custom ERP integrations land, and because it gives you webhooks, Change Data Capture and request-level idempotency to build on.

OAuth 2.0: the connection itself needs maintenance

Every QuickBooks Online company is identified by a realm ID and connected through OAuth 2.0. The token rules shape your design:

  • Access tokens are valid for 60 minutes (3,600 seconds).
  • Refresh tokens have a rolling expiry of 100 days, and Intuit’s token response can report the remaining time in a five-year hard lifespan.
  • Intuit tells you to always store the latest refresh_token from the most recent response. If the refresh token expires, the user has to reauthorize the app.
  • Only send one request to exchange an authorization code. Intuit warns that multiple requests may invalidate tokens.

Practical consequences for an ERP:

  1. Store tokens encrypted, per realm ID, in the database, not in app settings.
  2. Refresh in one place (a single job or a locked service) so two workers never race to rotate the same refresh token.
  3. Show the connection status in the ERP’s admin screen, with a “Reconnect QuickBooks” button. When the connection breaks, someone in finance should see it within hours, not at month end.

Rate limits, batch limits and API costs

Intuit’s limits page for the QuickBooks Online REST API lists, for both sandbox and production:

  • 500 requests per minute per realm ID.
  • 10 requests per second per realm ID and app.
  • Batch: a recommended maximum of 30 payloads per batch request, with batch calls throttled at 120 per minute per realm ID and 40 per minute per realm ID and app.
  • HTTP 429 when throttled. Intuit advises waiting 60 seconds before retrying.
  • Requests that take longer than 120 seconds time out.
  • A query returns at most 1,000 entities, so use pagination.

The partner program FAQ also states that higher throttling limits are not currently available, so you cannot buy your way out of these numbers.

Cost is part of the design now. Since July 28, 2025, apps fall under the Intuit App Partner Program. Core calls (most data-in operations, such as creating or updating invoices, bills, customers and vendors) are unmetered. CorePlus calls (most data-out operations, such as reading accounts, querying and fetching reports) are metered. The free Builder tier includes 500,000 CorePlus calls per month and blocks calls beyond that; paid tiers add a monthly fee plus charges above their included credits. The pricing table varies by region, so check it for your account.

The design lesson: avoid “read everything every five minutes” polling. Use webhooks and CDC to read only what changed, and cache reference data such as accounts, tax codes and terms.

Which system owns which record

Most broken two-way syncs fail here, not in the HTTP layer. “Two-way” should never mean “both systems edit everything.” It should mean each record type has one owner, and data flows in a defined direction per field.

A typical split for a manufacturing or distribution ERP:

Record Owner Flows to Notes
Chart of accounts, tax codes, terms QuickBooks ERP (read only) Accountants manage these; the ERP caches them
Vendors ERP or QuickBooks, pick one The other If purchasing lives in the ERP, the ERP owns vendors
Customers ERP QuickBooks Store the QuickBooks ID on the ERP customer
Items / products ERP QuickBooks Map each ERP item to a QuickBooks Item ID
Purchase orders ERP QuickBooks Created where the buying happens
Invoices ERP QuickBooks QuickBooks is the ledger; edits after posting go through the ERP
Payments QuickBooks ERP Payment status flows back for order and credit views

Write this table down, agree it with the finance team, and enforce it in code. If a record owned by the ERP is edited in QuickBooks, the sync should flag it for review instead of silently copying the change back or overwriting it.

Worked example: a custom ERP with purchase orders and vendors in sync

On the custom ERP system with QuickBooks sync, inventory, orders and accounting had lived in separate places, and every retyped record was a chance for an error. OSKI built one platform on .NET 8, React and MySQL where customer profiles, inventory items and purchase orders share one database. Through the QuickBooks API, purchase orders and vendor data sync automatically, each purchase order links directly to its vendor, and customer profiles keep their QuickBooks ID alongside accounting, order and shipping tabs. QuickBooks stays the ledger. The case study records orders fulfilled 20% faster, reports generated 40% faster and a 30% improvement in automation efficiency.

ID mapping: never match records by name

QuickBooks assigns its own Id to every entity. Your ERP has its own primary keys. You need a mapping that survives renames, merges and deletions.

Two common options:

  1. Column on the ERP entity, for example QuickBooksId on the customer. Simple, and it is what the ERP system above does for customer profiles.
  2. A dedicated mapping table, which works better when you sync many entity types or several QuickBooks companies.
-- Illustrative schema, MySQL syntax
CREATE TABLE qb_entity_map (
  id              BIGINT AUTO_INCREMENT PRIMARY KEY,
  realm_id        VARCHAR(32)  NOT NULL,
  entity_type     VARCHAR(40)  NOT NULL,   -- 'Customer', 'Vendor', 'Item', 'Invoice'...
  erp_id          BIGINT       NOT NULL,
  qb_id           VARCHAR(32)  NOT NULL,
  qb_sync_token   VARCHAR(16)  NULL,       -- last SyncToken we saw
  last_synced_at  DATETIME(3)  NOT NULL,
  status          VARCHAR(20)  NOT NULL DEFAULT 'active', -- 'active', 'deleted', 'merged'
  UNIQUE KEY ux_erp (realm_id, entity_type, erp_id),
  UNIQUE KEY ux_qb  (realm_id, entity_type, qb_id)
);

Handle the edge cases explicitly:

  • Merges. QuickBooks webhooks support a Merge operation for entities including customers, vendors, items and accounts, and the merged event carries the deletedid of the record that disappeared. Repoint the mapping instead of creating a new record.
  • Deletes. CDC returns deleted entities with a Deleted status. Mark the mapping as deleted; do not hard-delete ERP history.
  • Remapping. When item IDs change on the QuickBooks side, lines on new invoices must use the new ID. On the One Motion ERP, automatic remapping of item IDs is part of the integration OSKI maintains.
  • Duplicate names. QuickBooks rejects a second customer with an existing name (error 6240). Look up by mapped ID first, then by name only as a guided fallback for a human to confirm.

Idempotency and conflict handling

Networks fail mid-request. If your job retries a “create invoice” call without protection, you get two invoices.

Use requestid on every write. Intuit’s API accepts a requestid query parameter. If the service receives another request with the same ID for the same company, it returns the original response instead of performing the operation again. Rules from the docs: unique per realm ID, up to 50 characters (36 for batch operations), and Intuit suggests a GUID. Reusing an ID for a different request returns error 600, Duplicate Request ID. The trick is to generate the request ID once, store it with the outbound job, and reuse it on every retry of that same job.

Respect SyncToken on updates. Every QuickBooks entity carries a SyncToken version. If someone edited the record after you read it, the update fails with error 5010, Stale Object. Do not blindly re-read and overwrite. Apply your ownership rules:

  • Field owned by the ERP: re-read, reapply only the ERP-owned fields, retry.
  • Field owned by QuickBooks, or ownership unclear: stop and put the item in the error queue for a person.

Two more conflict sources showed up on the One Motion ERP: invoice date mismatches between the systems, and tax and tariff rules that change what the invoice should contain. Both are business-rule conflicts, not transport errors, which is why the error queue must show the ERP value, the QuickBooks value and the rule that failed.

Webhooks plus Change Data Capture for the QuickBooks to ERP direction

Webhooks notify your endpoint when entities change in a connected company. Facts from Intuit’s docs that drive the design:

  • Payloads now follow the CloudEvents format, with a type such as qbo.customer.merged.v1, an intuitentityid and an intuitaccountid (the realm). Some events carry a data object, some do not, so parse per event type.
  • Verify each call: hash the raw payload with HMAC-SHA256 using your app’s verifier token as the key, and compare it with the intuit-signature header.
  • Respond with HTTP 200 within 3 seconds. Failed deliveries are retried at 10s, 20s, 30s, 5m, 20m, 2h, 4h, 6h, then every 6 hours, and you may not receive later events until the first is acknowledged.
  • Events can arrive out of order. The event timestamp is the source of truth.
  • Production and sandbox webhooks are configured separately.
// Illustrative ASP.NET Core endpoint: verify, enqueue, return fast
[HttpPost("webhooks/quickbooks")]
public async Task<IActionResult> Receive()
{
    using var reader = new StreamReader(Request.Body);
    var payload = await reader.ReadToEndAsync();

    var key = Encoding.UTF8.GetBytes(_options.VerifierToken);
    using var hmac = new HMACSHA256(key);
    var expected = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)));
    var received = Request.Headers["intuit-signature"].ToString();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(received)))
        return Unauthorized();

    await _inbox.SaveAsync(payload);                    // store raw event first
    BackgroundJob.Enqueue<QbInboundProcessor>(p => p.ProcessPendingAsync());
    return Ok();                                         // well under 3 seconds
}

Change Data Capture is the safety net. A CDC call returns full payloads for the entities you list that changed since a given time, up to 30 days back and up to 1,000 objects per response, so query short windows. Intuit’s own best practice is to run CDC back to the last successfully processed webhook event, and to run a daily CDC sweep for all required entities. CDC does not cover JournalCode, TaxAgency, TimeActivity, TaxCode or TaxRate, so treat those as reference data you refresh separately.

Background jobs: queue everything

Sync work should never run inside a user’s HTTP request. The UI saves to the ERP database and enqueues a job; jobs talk to QuickBooks.

On the One Motion ERP, the stack is .NET 7, ASP.NET Core Web API and MySQL, the integrations list names the QuickBooks SDK, and the sync runs as Hangfire jobs. The case notes that the jobs had to keep running reliably while live issues such as concurrency errors, API failures and wrong quantities were found and fixed. That list is a good checklist for any job design:

  • One queue per realm, processed in order. Intuit sends events one realm at a time and recommends linear processing to avoid handling the same change twice. In Hangfire, a dedicated queue plus [DisableConcurrentExecution] on the per-realm processor is a simple way to get there.
  • A client-side rate limiter set below 10 requests per second and 500 per minute per realm, shared by all workers.
  • Retry with backoff, then stop. On 429, wait at least 60 seconds. On 5xx or timeouts, retry a few times with the same requestid. On 400-class validation errors, do not retry; move to the error queue.
  • Outbox pattern. Write the ERP change and the “sync this” record in the same database transaction, so a crash between the two cannot lose a sync.
// Illustrative Hangfire job: idempotent invoice push
[AutomaticRetry(Attempts = 5)]
[DisableConcurrentExecution(timeoutInSeconds: 300)]
public async Task PushInvoiceAsync(long outboxId)
{
    var item = await _outbox.GetAsync(outboxId);
    if (item.Status == OutboxStatus.Done) return;           // already synced

    var requestId = item.RequestId;                          // generated once, reused on retry
    var result = await _qb.CreateOrUpdateInvoiceAsync(item.InvoiceId, requestId);

    if (result.IsValidationError)
    {
        await _errors.AddAsync(item, result);                // human review, no retry
        await _outbox.MarkFailedAsync(outboxId);
        return;
    }
    result.ThrowIfTransient();                               // let Hangfire retry
    await _map.UpsertAsync("Invoice", item.InvoiceId, result.QbId, result.SyncToken);
    await _outbox.MarkDoneAsync(outboxId);
}

Error queues and reconciliation reports

An integration without visible errors is an integration that fails quietly. Build two screens before go-live.

The sync error queue. One row per failed item: entity, ERP ID, QuickBooks ID, operation, error code, the message from QuickBooks, attempts, and buttons to retry or dismiss with a note. Finance staff should be able to fix a missing tax code and press retry without a developer.

The reconciliation report. Compare, per day or per period, what the ERP thinks was sent with what QuickBooks holds:

  • Count and total of invoices, bills and purchase orders per day in each system.
  • Records in the ERP with no QuickBooks ID older than a threshold, for example one hour.
  • Records whose amount or status differs between the two.
  • Open items in the error queue, by age.

On the One Motion ERP, Excel exports (EPPlus) and PDFs (iText7) have rounding, ordering and filters validated against accounting figures, and the stated outcome is that the ERP and QuickBooks hold the same financial figures. Precision issues are real there: weights move between LB, KG, STON and MTON, and invoices, reports and material certificates all depend on the conversion. Decide where rounding happens (once, at a defined step) and test that a converted, rounded line total matches what QuickBooks calculates.

Testing in the QuickBooks sandbox

Intuit gives every developer account a sandbox company. You can create up to 10 sandbox companies (QuickBooks Online Plus or Advanced, region-specific), each valid for two years, and reset their data. The sandbox base URL is https://sandbox-quickbooks.api.intuit.com/v3, and the documented REST limits match production, so throttling tests are meaningful.

A test plan that catches the expensive bugs:

  1. Happy path per entity: create, update, and delete or void from the owning side; confirm the other side.
  2. Retry safety: kill the job after the request is sent but before the response is stored; rerun and confirm one invoice, not two.
  3. Stale updates: edit a record in the sandbox UI between read and write; confirm a 5010 lands in the error queue or is resolved per your ownership rules.
  4. Throttling: push a backlog of several hundred records and confirm the limiter keeps you under the limits, and that a 429 waits instead of hammering.
  5. Missed webhooks: turn the endpoint off for an hour, turn it back on, and confirm the CDC catch-up fills the gap.
  6. Merges and renames: merge two customers in QuickBooks and confirm the mapping follows.
  7. Rounding and tax: run real invoice samples from the business, with their tax rules, and compare totals to the cent.
  8. Token expiry: revoke the connection and confirm the ERP shows it and queues work until reconnection.

Configure sandbox webhooks separately from production, and connect the sandbox company through OAuth first, or no notifications arrive.

A go-live checklist

  • Record ownership table agreed with finance and enforced in code.
  • ID mapping for every synced entity, including merge and delete handling.
  • requestid stored per outbound job and reused on retry.
  • SyncToken conflicts routed by ownership rules.
  • Webhook signature check, 3-second response, inbox table, ordered processing.
  • CDC catch-up after outages and a daily CDC sweep.
  • Per-realm rate limiter and 429 handling.
  • Token storage, single refresher, reconnect UI.
  • Error queue that finance can use.
  • Daily reconciliation report with totals and missing-ID checks.
  • Monitoring of CorePlus call volume against your App Partner Program tier.

When to bring in help

A QuickBooks sync is small in lines of code and large in edge cases. If your team is building an ERP around QuickBooks, OSKI’s ERP development and integrations teams have shipped and supported this exact pattern in production, including the unglamorous parts: error queues, reconciliation and the steady stream of change requests that follow go-live. If your accounting sync sits next to a CRM, see our CRM development work as well.

OSKI Engineering TeamSenior .NET, Umbraco and AI engineers

Written by the engineers who build and support OSKI client projects, reviewed by our CTO before publishing.

FAQ

Questions readers ask

Anything missing? Send us your question and an engineer will answer, usually within one business day.

Contact us
Can a custom ERP have a two-way sync with QuickBooks Online?+

Yes. The QuickBooks Online Accounting API supports creating, reading and updating customers, vendors, items, invoices, bills, purchase orders and other entities, and it offers webhooks and Change Data Capture to detect changes made in QuickBooks. A reliable two-way sync also needs clear record ownership, ID mapping, idempotent writes and a reconciliation report.

What are the QuickBooks Online API rate limits?+

Intuit documents 500 requests per minute per realm ID and 10 requests per second per realm ID and app for the REST API, with batch requests throttled at 120 per minute per realm ID and 40 per minute per realm ID and app. Throttled calls return HTTP 429, and Intuit advises waiting 60 seconds before retrying. A query returns at most 1,000 entities, so larger reads need pagination.

How long do QuickBooks OAuth tokens last?+

Access tokens are valid for 60 minutes. Refresh tokens have a rolling expiry of 100 days, and Intuit's docs describe a five-year hard lifespan. Always store the latest refresh token returned by the token endpoint, because using an old one will eventually force the user to reconnect.

Should I use QuickBooks webhooks or Change Data Capture?+

Use both. Webhooks notify you quickly when entities change, but events can be missed or arrive out of order. Intuit recommends a Change Data Capture call back to the last processed event, plus a daily CDC sweep, to make sure nothing is lost. CDC covers changes from the last 30 days.

Does the QuickBooks API cost money?+

Under the Intuit App Partner Program, Core calls (mostly data-in, such as creating invoices or vendors) are unmetered. CorePlus calls (mostly data-out, such as reads, queries and reports) are metered. The free Builder tier includes 500,000 CorePlus calls per month and blocks calls above that; paid tiers charge a monthly fee plus usage above their included credits.

How do you integrate a web-based ERP with QuickBooks Desktop?+

QuickBooks Desktop has no cloud REST API. Integrations use the QuickBooks Desktop SDK with qbXML messages, either from software on the same machine or network, or through the QuickBooks Web Connector, a Windows application that lets a web service exchange data with QuickBooks Desktop. The web service queues work and the Web Connector pulls it on a schedule.

Start a project

Tell us what you need to ship. We come back with a plan and a price.

A spec, a Figma file or three paragraphs is enough to start.

  1. First reply · from an engineer, not a sales rep
  2. 30-min call · with Roman, our CTO, to clarify scope
  3. After the call · a written proposal with plan, team and price
Roman Oleksuk
Roman Oleksuk, CTOPrefer to talk? Pick a slot in Roman's calendar
We reply from a human mailbox. No newsletters. Your brief stays under NDA on request. We use your details only to reply; see our privacy policy.