QuickBooks to Postgres: how to replicate your ledger into a database you can query
9 min read Data engineering The Adapters team
Last updated July 2026
Field mapping auto-plugged · tap a port to rewire
Plug a source port into
Transform on this cable
JSON in
JSON out
5 sample records ready
To replicate QuickBooks Online into Postgres, read the ledger objects you care about (Invoice, Bill,
Payment, Customer, Vendor, Item, Account, JournalEntry) through the Intuit Accounting API v3, query the
/query endpoint with its SQL-like language, page with STARTPOSITION and
MAXRESULTS, filter each incremental run on MetaData.LastUpdatedTime or the
Change Data Capture endpoint, flatten every Ref to a foreign-key column, store amounts as
NUMERIC(19,4), and upsert with INSERT ... ON CONFLICT (id) DO UPDATE keyed on
the QuickBooks Id. Your apps and reports then read the copy instead of calling Intuit live.
Key takeaways
- This is QuickBooks Online, not Desktop. You read through the Intuit Accounting API v3 with OAuth 2.0, a
realmIdper company, and a SQL-like/queryendpoint. - Upsert on the QuickBooks Id.
ON CONFLICT (id) DO UPDATEmakes a retried batch or an overlapping window update in place instead of doubling rows. - Watermark on MetaData.LastUpdatedTime, or use CDC. Pull only changed records since your last run and overlap the window slightly so an out-of-order commit is never skipped.
- Flatten Ref, store money as NUMERIC(19,4). A
CustomerRef.valuebecomes a foreign key; keep transaction currency and home currency separate when a company runs multi-currency.
How do you connect QuickBooks to Postgres?
You connect QuickBooks to Postgres by reading QuickBooks Online objects through the Intuit Accounting
API v3 and writing them into Postgres tables, because QuickBooks Online has no native database
export. Something sits in the middle: a managed connector, or a scheduled job that authenticates with
OAuth 2.0, queries each entity, and upserts every batch into your schema keyed on the QuickBooks
Id.
The connection has three parts. First, an Intuit developer app with OAuth 2.0 credentials and the
accounting scope, which issues a refreshable access token and identifies the company you are reading
through its realmId. Access tokens are short lived and the refresh token rotates, so the
job has to persist and rotate both. Second, a Postgres landing spot: a database, a schema, and a role
that can create tables and run upserts. Third, the transfer loop itself, which pages each entity
through the /query endpoint, lands each batch in staging, and upserts into the target.
The managed path is the
QuickBooks to Postgres integration,
which owns the OAuth token rotation, the paging, the watermark, the staging tables, the upserts, and
the backoff.
| Extraction method | What it is | When it fits and what it costs you |
|---|---|---|
| Full query reload | A paged SELECT * FROM Entity against the /query endpoint that walks every record with STARTPOSITION and MAXRESULTS |
The first backfill, and small reference tables like Account or Item. Wasteful and slow to rerun on transaction tables that only change a little |
| LastUpdatedTime watermark | The same query with WHERE MetaData.LastUpdatedTime > '2026-07-19T00:00:00-07:00', resuming from your stored maximum |
The default for steady-state incremental reads. Misses hard deletes, so pair it with reconciliation |
| Change Data Capture (CDC) | The /cdc endpoint returns every entity of the types you name that changed since a single timestamp, including soft-deleted ones |
One call covers many entity types at once and surfaces deletes. Bounded lookback window, so it is a companion to the watermark, not a full replacement |
| Webhooks | Intuit POSTs an event notification to your endpoint when named entities change on a realm | Near real time refresh hints. Best effort only: verify the HMAC signature, treat it as a nudge to re-read, never as the source of truth |
| Managed sync tool | A hosted connector that owns OAuth, paging, throttling, deletes, and idempotent loads | When you would rather not maintain token rotation and delete reconciliation. Less control over exact field selection |
Most production setups combine three of these: a full reload for the initial backfill, the
LastUpdatedTime watermark for the steady state, and CDC (plus webhooks) to catch deletes
and shorten latency. If your reporting lives in a warehouse rather than an application database, the
sibling path is
QuickBooks to Snowflake, and the
modeling discipline below carries over unchanged.
Does QuickBooks Online have an API to export data?
Yes. QuickBooks Online exposes the Intuit Accounting API v3, a REST API with a SQL-like query
language, but it is not a database and it is not a bulk dump. You authenticate with OAuth 2.0,
address a company by its realmId, and read one entity type at a time through the
/query endpoint. There is no direct database connection, no read replica, and no
credentials to point psql at.
The query language looks like SQL but is deliberately narrow. You write statements such as
SELECT * FROM Invoice WHERE MetaData.LastUpdatedTime > '...' STARTPOSITION 1 MAXRESULTS
1000 against a single entity, with no joins and a capped page size of 1000 rows. The entities
you will replicate for a full ledger are Invoice, Bill, Payment, BillPayment, Customer, Vendor,
Item, Account (the chart of accounts), JournalEntry, CreditMemo, Deposit, and Transfer. That gap
between a query API and a database is the whole reason this pipeline exists. Calling the Intuit API
live from a dashboard is slow and easily throttled: results are paged 1000 at a time, requests are
rate limited per realm, and a report that ties a payment to the invoices it settled turns into many
round trips because the API cannot join. Worse, you cannot join API responses to your own tables.
Once the ledger sits in Postgres, attributing revenue to your product plans, or reconciling
QuickBooks customers against your CRM, is one join instead of a paging loop, which is exactly what a
data integration platform is for.
How QuickBooks Online objects map to Postgres tables and types
Model transactions and their lines as separate tables. Flattening an invoice's Line
array into the invoice row (as a JSON blob or as line_1_amount, line_2_amount
columns) destroys line-level analysis: you cannot group revenue by item or account, join lines to the
replicated Item table, or answer which product drives credit memos without unnesting on every query.
A child table keyed to the invoice id keeps all of that as ordinary SQL. The other rule that matters
everywhere: QuickBooks returns linked objects as Ref types with a value
(the id) and a name. Flatten Ref.value to a foreign-key column that joins to
the replicated customers, vendors, or accounts table, and keep the name only as a cached
label.
| QuickBooks Online field | Postgres table.column | Type and notes |
|---|---|---|
Id (every entity) |
qbo_invoice.id, qbo_customer.id, ... | TEXT primary key. Unique per entity per realm. The upsert conflict target |
Invoice DocNumber |
qbo_invoice.doc_number | TEXT. The human-facing invoice number, not the primary key |
Invoice TotalAmt |
qbo_invoice.total_amt | NUMERIC(19,4). Arrives as a JSON number. Store as NUMERIC so cents never drift |
Invoice Balance |
qbo_invoice.balance | NUMERIC(19,4). Open amount still owed. Zero once fully paid |
Invoice CustomerRef |
qbo_invoice.customer_id | TEXT FK to qbo_customer.id. Flatten CustomerRef.value; keep .name as a label |
Invoice TxnDate |
qbo_invoice.txn_date | DATE. The accounting date of the transaction |
Invoice DueDate |
qbo_invoice.due_date | DATE, nullable. Drives aging and receivables reports |
Invoice Line (array) |
qbo_invoice_line (child table) | Own table keyed to invoice id. Do not flatten: lines carry per-item amount, ItemRef, and account detail |
Payment TotalAmt |
qbo_payment.total_amt | NUMERIC(19,4). The amount received on the payment |
Payment LinkedTxn |
qbo_txn_link (link table) | Ties a payment to the invoices it settles. Store txn_id, txn_type, and amount per link row |
Account AcctType |
qbo_account.acct_type | TEXT. Income, Expense, Bank, Accounts Receivable, and so on |
Account AcctNum |
qbo_account.acct_num | TEXT, nullable. The chart-of-accounts number when the company uses account numbers |
Customer DisplayName |
qbo_customer.display_name | TEXT. Unique per realm. The name shown across the ledger |
Customer Balance |
qbo_customer.balance | NUMERIC(19,4). Open receivable for the customer |
Vendor DisplayName |
qbo_vendor.display_name | TEXT. The payee name for bills and bill payments |
Any MetaData.LastUpdatedTime |
*.last_updated_time | TIMESTAMPTZ. The incremental watermark. Index it per table |
CurrencyRef |
*.currency_code, *.exchange_rate | TEXT (3-char) plus NUMERIC. Transaction currency in multi-currency companies. Keep home currency separate |
The money trap is worth its own paragraph. QuickBooks returns monetary values as JSON numbers, and it
is tempting to keep them as a floating point type because they already parse as numbers. Do not.
Binary floating point cannot represent every two-decimal cent exactly, so summing thousands of invoice
totals drifts by pennies that accounting will eventually flag. Store every amount as
NUMERIC(19,4) and let Postgres hold exact decimals. Multi-currency companies add a second
trap: each transaction carries a CurrencyRef for the currency the document was written in,
while the company also has a home currency, and an ExchangeRate converts between them.
Keep the transaction currency and the home currency in separate columns, store the exchange rate, and
never blend amounts across currencies into one total. With the ledger modeled cleanly in Postgres you
can join it to the rest of the business and
turn the ledger into board-ready financial statements without
re-reading Intuit for every refresh.
How do you sync QuickBooks incrementally instead of a full export?
Sync incrementally by filtering each query on MetaData.LastUpdatedTime, or by calling the
Change Data Capture endpoint, so you only fetch records that changed since your last run. Store the
maximum LastUpdatedTime you saw as a watermark, resume from it next time, and overlap the
window by a few minutes so a commit that landed out of clock order is never skipped.
Every QuickBooks entity carries a MetaData block with a LastUpdatedTime. A
typical run reads the stored watermark, issues SELECT * FROM Invoice WHERE
MetaData.LastUpdatedTime > '2026-07-19T00:00:00-07:00' STARTPOSITION 1 MAXRESULTS 1000,
advances STARTPOSITION by the page size until a page returns fewer than 1000 rows, then
moves the watermark forward to the max LastUpdatedTime returned. The Change Data Capture
endpoint is the denser alternative: one call to /cdc names several entity types and a
single since-timestamp, and Intuit returns everything of those types that changed, including records
that were soft-deleted. CDC is the better tool for catching deletes and for keeping many small tables
fresh in one request, while the per-entity watermark gives you fine control over the busy transaction
tables. Overlap protects correctness in both: subtract a few minutes from the watermark before you
query, because re-reading a handful of rows costs nothing when the load is idempotent, which is the
next section.
How do you handle the QuickBooks API rate limit?
Handle it by pacing requests under the per-realm limit rather than firing as fast as you can and reacting to failures. Intuit caps a company at roughly 500 requests per minute per realm and about 10 concurrent requests, so budget your paging against those numbers, keep a small concurrency pool, and when a throttle response comes back, back off and retry from the same page instead of hammering the endpoint.
The model is per realm, which has two consequences. First, denser calls win: pulling 1000 rows a page
with MAXRESULTS and filtering on the watermark means a steady-state run touches only the
rows that changed, which is a tiny fraction of the ledger, so you rarely approach the ceiling at all.
Second, concurrency is shared across everything hitting that realm, including any other integration
the finance team runs, so keep your pool well under 10 and treat throttling as expected rather than
exceptional. Wrap each request in retry logic with exponential backoff that recognizes rate errors,
waits, and resumes from the same STARTPOSITION so no page is skipped or double read.
Reserve full-table reloads for the initial backfill, and run that backfill in chunks by entity type
so no single window saturates the minute budget. If you later add a warehouse target the same pacing
discipline applies to
QuickBooks to Snowflake.
How do you keep a QuickBooks Postgres sync from creating duplicates?
Make the load idempotent. Give every table a primary key on the QuickBooks Id, land each
batch in a staging table, and upsert with INSERT ... ON CONFLICT (id) DO UPDATE SET ....
Because the id is stable within a realm, replaying an overlapping LastUpdatedTime window
or retrying a failed batch updates the existing row in place rather than inserting a second copy, so
the target converges to the same state a clean run would produce.
The load has a consistent shape. Land each page in staging, then upsert from staging into the target
in one statement so inserts and updates happen together. Lines need the same discipline one level
down: when an invoice is edited, its Line set can change, so a plain upsert on line id
leaves orphaned rows for lines that were removed. The clean pattern is to delete all
qbo_invoice_line rows for the invoice ids in the batch and reinsert them inside the same
transaction as the invoice upsert, so an invoice and its children are never briefly inconsistent. The
same applies to LinkedTxn rows in the link table.
Deletes are the case a watermark can never catch. QuickBooks soft-deletes: when a record is voided or
removed its status becomes Deleted, and the CDC endpoint returns it so you can mark the
local row deleted. But a record that is hard-absent, gone with no tombstone, will never appear in a
LastUpdatedTime query, so your replica quietly keeps rows the company no longer has. Two
mitigations, and you want both: read CDC so soft-deletes flow through as status changes, and run a
periodic reconciliation that pulls the current id set for each entity over a trailing window and flags
local ids that Intuit no longer returns. Keep a loaded_at column on every table so
freshness checks and drift debugging are a one-line query.
How often should you sync QuickBooks to your database?
For most finance and ops teams, every fifteen to sixty minutes on the LastUpdatedTime
watermark is the right steady state, with a nightly wider CDC and reconciliation pass to catch
deletes. Match the interval to the decision the data drives: cash and receivables dashboards want
fifteen minutes, while month-end close and cohort reporting are fine hourly or daily. Syncing more
often than anyone reads just spends per-realm request budget for nothing.
Backfilling a company is a different job from the steady state and should be built as one. Run a full
query reload per entity, chunk the large transaction tables by TxnDate range so each
chunk is a bounded paging walk you can retry independently, and record which chunks completed so a
failure at 80 percent resumes rather than restarts. Load into staging first, then upsert. Only after
the backfill lands should you set the watermark, and set it to the start time of the backfill rather
than its end so anything that changed while the backfill was running gets picked up on the first
incremental pass. Metered per-row pipeline pricing interacts badly with sync frequency and with a busy
month-end, since both push row counts up exactly when you least want a surprise invoice. Our
flat pricing is by plan rather than per row, so a heavy close does
not change the bill. If you want to see the pair running against your own company before you wire
anything up, book a walkthrough or map it yourself with
the QuickBooks to Postgres
connector.
Replicate the QuickBooks Online ledger into Postgres without duplicates
LastUpdatedTime watermarks and CDC, flattened Refs, NUMERIC(19,4) money, ON CONFLICT upserts, and delete reconciliation. Map the pair in the browser and pay a flat price from $49 a month.
No credit card required.