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_tokenfrom 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:
- Store tokens encrypted, per realm ID, in the database, not in app settings.
- Refresh in one place (a single job or a locked service) so two workers never race to rotate the same refresh token.
- 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:
- Column on the ERP entity, for example
QuickBooksIdon the customer. Simple, and it is what the ERP system above does for customer profiles. - 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
deletedidof the record that disappeared. Repoint the mapping instead of creating a new record. - Deletes. CDC returns deleted entities with a
Deletedstatus. 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
typesuch asqbo.customer.merged.v1, anintuitentityidand anintuitaccountid(the realm). Some events carry adataobject, 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-signatureheader. - 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:
- Happy path per entity: create, update, and delete or void from the owning side; confirm the other side.
- Retry safety: kill the job after the request is sent but before the response is stored; rerun and confirm one invoice, not two.
- 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.
- 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.
- Missed webhooks: turn the endpoint off for an hour, turn it back on, and confirm the CDC catch-up fills the gap.
- Merges and renames: merge two customers in QuickBooks and confirm the mapping follows.
- Rounding and tax: run real invoice samples from the business, with their tax rules, and compare totals to the cent.
- 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.
requestidstored per outbound job and reused on retry.SyncTokenconflicts 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.
- Custom ERP → Customers, items, purchase orders and invoices QuickBooks
- QuickBooks → Payment status for order and credit views Custom ERP
- QuickBooks → Chart of accounts, tax codes, terms: read only Custom ERP
- Custom ERP ⇄ Vendors: pick one owner, usually where purchasing lives QuickBooks
- 500/minrequests per realm ID
- 10/srequests per realm ID and app
- 60 minaccess token lifetime
- 100 daysrolling refresh token expiry
From Intuit's developer documentation as of October 2026.


