Browse technical docs

QuickBooks Sync & Write Policy

This page covers how a connected QuickBooks Online company moves data into a RoboLedger graph, and how entries RoboLedger drafts move back out. It covers the sync windows, what a sync loads, the write policy that decides which ledger is the book of record, the close-time write-back outbox, reconciling items for transactions edited in QuickBooks after they synced, and the two ways to disconnect.

The product docs at roboledger.ai describe this for end users (Nothing writes to QuickBooks until you post and When QuickBooks changes after a sync). This page covers the contracts underneath: the operations, their payloads, and why they behave the way they do.

Table of Contents

Overview

A QuickBooks connection is a platform row (Connection) scoped to one graph. It carries the OAuth credentials, the realm id, the timestamp and outcome of the last sync, and a write policy. Data flows two ways:

DirectionWhat movesWhen
Inbound (sync)Chart of accounts, transactions, customers, vendors, employees, company infoOn connect, and whenever a sync is requested
Outbound (write-back)Journal entries that RoboLedger drafted (schedule entries, manual adjustments)At period close, or when one entry is published on its own with execute-event-block

The two directions are independent. A sync reads QuickBooks and never writes to it. Write-back sends only entries that originated in RoboLedger. A transaction that came in from QuickBooks is never sent back.

Set credentials once for the examples below:

export ROBOSYSTEMS_API_KEY=rfs...   # Settings → API keys at robosystems.ai
export GRAPH_ID=kg...
export CONN_ID=...                  # from GET /v1/graphs/$GRAPH_ID/connections

Syncing

The three windows

A sync pulls one date window from QuickBooks. The request body picks the window:

Request bodyWindowPre-sync wipe
{}The last 60 daysNo
{"since_date": "2026-01-01"}From that date to todayNo
{"full_rebuild": true}All history (from 2000-01-01)Yes, of unposted events only (see below)

full_rebuild takes precedence over since_date. The 60-day default suits day-to-day use. If someone edits a transaction in QuickBooks that is older than 60 days, the default sync never sees it. Use since_date from a date that covers the edit, or full_rebuild.

curl -X POST "https://api.robosystems.ai/v1/graphs/$GRAPH_ID/connections/$CONN_ID/sync" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Idempotency-Key: sync-$(date +%s)" \
  -H "Content-Type: application/json" \
  -d '{"since_date": "2026-04-01"}'

The same call is the sync-connection MCP tool, which takes connection_id, full_rebuild and since_date.

The initial sync

Completing the OAuth consent starts a sync on its own. When the connection has never synced before, that first sync is a full_rebuild, so the graph receives the company's full history. If you reconnect a connection that has synced before, it gets the default 60-day window.

The first sync also does two one-time setup steps:

  • Bootstraps the fiscal calendar, so the close workflow is reachable without a separate initialize call. See Period Close.
  • Starts AI auto-mapping of the chart of accounts when the mapping structure has no associations yet. This is the one sync step that consumes credits. See Chart of Accounts Mapping.

On demand only

Syncs run when something requests them: the Connections page in the app, POST .../sync, or the sync-connection MCP tool. The platform does not schedule QuickBooks syncs. The connection's auto_sync_enabled field is stored but currently has no effect.

Tracking a sync

POST .../sync returns 202 with a pending envelope whose result.task_id identifies the pipeline run. A sync already running on the same connection returns 409. When the run finishes, the outcome is on the connection:

curl "https://api.robosystems.ai/v1/graphs/$GRAPH_ID/connections/$CONN_ID" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY"

metadata.last_sync is the completion timestamp, and metadata.last_sync_result is the outcome summary:

{
  "status": "succeeded",
  "synced_at": "2026-09-02T14:05:11Z",
  "window": {"since_date": "2026-04-01", "full_rebuild": false},
  "counts": {
    "events_captured": 41,
    "events_updated": 3,
    "reconciling_items": 1,
    "dispatch_failed": 0,
    "cross_source_matched": 2,
    "skipped_same_sync_token": 388,
    "skipped_stale_sync_token": 0,
    "dropped_unbalanced_entries": 0,
    "dropped_empty_transactions": 0,
    "elements": 64,
    "agents_inserted": 2,
    "agents_updated": 5
  },
  "errors": []
}

A failed run records last_sync_result without advancing last_sync. The distinction matters because the close gate reads last_sync: a failed sync never makes the books look current. On MCP, get-graph-sync-status reads the same fields.

What a Sync Loads

A sync extracts accounts (active and inactive, since historical lines can reference retired accounts), the journal-entry stream, customers, vendors and employees, the per-type transaction headers (invoices, bills, payments, purchases, sales receipts), and company info. The pipeline normalizes them into RoboLedger's shape:

  • Accounts become chart-of-accounts Element rows.
  • Customers, vendors and employees become Agent rows.
  • Transactions become events. Because QuickBooks is already the book of record for them, each one posts immediately and lands fulfilled with its Transaction → Entry → LineItem rows. A transaction that can't post (an account not yet in the chart, a closed month) waits in the inbox as captured.
  • Entries RoboLedger wrote back are recognized when they come round again. The loader matches them by their QuickBooks transaction id and does not create a duplicate. This is cross_source_matched in the counts.

Re-syncing is idempotent. Events are upserted by (source, external_id), and a transaction whose QuickBooks version token hasn't changed is skipped without comparison (skipped_same_sync_token).

What full_rebuild wipes. A full rebuild first deletes the connection's captured and classified events together with their draft GL rows, then reloads. Posted history survives: committed, fulfilled and voided events are never wiped. Your mapping associations also survive, because account elements are upserted in place and keep their ids. A full rebuild that would delete rows inside a closed month is refused. The sync then fails and nothing changes.

Every successful sync marks the graph stale. That schedules a rematerialization of the analytical graph, so fact grids and Cypher reads catch up. Operations that read the ledger directly, such as live-financial-statement, see the new data as soon as the sync finishes.

Write Policy

A connection's write_policy decides which ledger is the book of record, and therefore whether RoboLedger-originated entries are written back.

PolicyBook of recordRoboLedger-originated entries
qb_authoritativeQuickBooksWritten to QuickBooks at close, or when published on their own with execute-event-block. They stay draft in RoboLedger until QuickBooks accepts them.
nativeRoboSystemsPost locally. Nothing is written to any external system.

A new QuickBooks connection defaults to qb_authoritative. Other connection types default to native. native describes a graph with no authoritative external ledger: before any connection, after a sever, or books kept natively. It does not mean "connected, but keep the ledgers apart." The platform assumes no steady state in which QuickBooks stays connected while the two ledgers diverge.

hybrid is reserved and cannot be set. The WritePolicy enum declares a third value, hybrid, for a future QuickBooks-authoritative mode that would flag exceptions (low-confidence mappings, amounts over a threshold) for review. That mode is not implemented, and the write-policy endpoint rejects it. The close and publish code paths list hybrid alongside qb_authoritative in their eligibility checks, so a row that somehow carried it would behave exactly like qb_authoritative. No supported path creates such a row. In practice there are two policies.

Switch policies with:

curl -X PUT "https://api.robosystems.ai/v1/graphs/$GRAPH_ID/connections/$CONN_ID/write-policy" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"write_policy": "native"}'

The same control is the set-write-policy MCP tool. Changing the policy requires a member or admin role on the graph. A viewer can't opt a graph into writing to its book of record.

The Write-Back Outbox

On a qb_authoritative graph, the drafts RoboLedger holds for a month are the outbox, and close-period flushes it. One predicate decides whether a draft publishes. The close uses that predicate to write, and the draft preview uses the same one to report, so the preview can't disagree with what the close does.

Which drafts are written back

A draft entry in the month being closed publishes when both of these hold:

  1. The graph has a write-back connection. A non-deleted QuickBooks connection with a write-back policy exists. If several exist, the newest wins.
  2. The draft's event publishes. The event is not voided or superseded, it has not already been written (no metadata.qb_external_id), and either:
    • its metadata.publish_to_source is true, or
    • publish_to_source is unset and its source is schedule or manual.

Synced QuickBooks transactions (source: "quickbooks") already live in QuickBooks. system entries are local by definition. Neither is ever published unless publish_to_source: true says otherwise.

Keeping one entry in RoboLedger

publish_to_source: false on an event's metadata keeps its entry local whatever its source. It is the override for an entry that mirrors a change already made in QuickBooks, which would be applied twice if it were written back. Set it when you record the entry:

curl -X POST "https://api.robosystems.ai/extensions/roboledger/$GRAPH_ID/operations/create-event-block" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "journal_entry_recorded",
    "event_category": "adjustment",
    "event_class": "economic",
    "occurred_at": "2026-08-31T00:00:00Z",
    "source": "manual",
    "amount": 48000,
    "currency": "USD",
    "description": "Reclass already booked in QuickBooks",
    "apply_handlers": true,
    "metadata": {
      "posting_date": "2026-08-31",
      "memo": "Mirror of QuickBooks reclass (local only)",
      "status": "draft",
      "type": "adjusting",
      "publish_to_source": false,
      "line_items": [
        {"element_id": "elem_coa_software", "debit_amount": 48000},
        {"element_id": "elem_coa_office_expense", "credit_amount": 48000}
      ]
    }
  }'

You can also patch the flag onto an existing event with update-event-block and "metadata_patch": {"publish_to_source": false}. The flag must be a JSON boolean. A string such as "false" is rejected.

Previewing the split

Before closing, read the month's drafts. Each carries will_publish_to_qb, and the summary counts both lanes:

curl -X POST "https://api.robosystems.ai/extensions/$GRAPH_ID/graphql" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ periodDrafts(period: \"2026-08\") { draftCount allBalanced qbWritePolicy qbPublishCount localOnlyCount drafts { entryId memo totalDebit balanced willPublishToQb } } }"}'

The MCP equivalent is list-period-drafts.

What happens at close

close-period publishes eligible drafts to QuickBooks before it changes anything else. Each draft is sent with its event id as the QuickBooks request id. The returned QuickBooks transaction id is stored on the event as metadata.qb_external_id, and the draft is promoted to posted. Everything the publish step didn't send is posted locally in one bulk transition. The close receipt reports the two lanes separately: entries_published_to_qb, entries_posted_locally, and their total entries_posted. On a native graph the first is always zero. The full close sequence is on Period Close.

Rejections and retry

The publish step tries every eligible draft and collects every failure instead of stopping at the first. If QuickBooks rejects any of them, the close fails with 422 and nothing else about the close happens:

{
  "detail": {
    "code": "WRITE_BACK_FAILED",
    "message": "Cannot close: 1 draft(s) failed to publish to QuickBooks. Fix the offending entries and retry the close.",
    "failed_events": [
      {
        "event_id": "evt_01J...",
        "entry_id": "je_01J...",
        "memo": "August depreciation — delivery van",
        "posting_date": "2026-08-31",
        "qb_error": {"code": "...", "message": "..."}
      }
    ]
  }
}

The drafts that did reach QuickBooks keep their qb_external_id. Those markers are committed before the failure is raised, on purpose. A QuickBooks journal entry is an external write that has already happened. If the marker were rolled back, the retried close would publish the same entry again as a duplicate. So retrying is safe: fix the rejected entries (usually an account mapping, an unbalanced line, or a period already closed in QuickBooks), then call close-period again. The retry sends only what hasn't been sent.

Publishing one entry on its own

execute-event-block publishes a single event outside a close:

curl -X POST "https://api.robosystems.ai/extensions/roboledger/$GRAPH_ID/operations/execute-event-block" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_id": "evt_01J..."}'

On a write-back connection it writes the entry to QuickBooks, records qb_external_id, moves the event to fulfilled (or back to pending if QuickBooks rejects it), and posts the draft. A native event, or one carrying publish_to_source: false, passes through with no external write. A 401 means QuickBooks refused the credentials. The connection is flagged for re-authorization and needs a fresh OAuth consent.

Reconciling Items

People edit QuickBooks. When a sync brings a changed payload for a transaction that RoboLedger has already posted, the loader does not overwrite the posted entry. The old figures may sit in a closed month, a stamped statement, or a report someone has received. Instead the loader leaves the posted GL and the accepted payload as they are, stores the incoming payload next to them, and flags the event as a reconciling item. The sync's counts.reconciling_items reports how many it found.

The flag stays until someone settles the item. Each later sync reports it again, and the close refuses to run over it (see Period Close).

Finding them

curl -X POST "https://api.robosystems.ai/extensions/$GRAPH_ID/graphql" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "{ eventBlocks(isReconcilingItem: true) { id eventType externalId occurredAt amount description } }"}'

On MCP, this is list-event-blocks with is_reconciling_item=true.

Previewing one

preview-reconciling-item shows what changed and what each disposition would do. It writes nothing.

curl -X POST "https://api.robosystems.ai/extensions/roboledger/$GRAPH_ID/operations/preview-reconciling-item" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_id": "evt_01J..."}'

The result (ReconcilingItemPlan) carries:

FieldMeaning
prior_entries / accepted_entriesThe posted entries, and the entries the new payload would produce
delta[]Per-account net change in cents, debit-positive: prior_net, accepted_net, delta
no_gl_effectThe edit moves no money (a memo or reference change)
closed_periodsClosed months the event's entries sit in
default_dispositionWhat resolve-reconciling-item does when you don't choose
default_posting_dateWhere a catch-up entry lands by default: the end of the earliest open period
restate_blockersWhy restating isn't available, if it isn't
unmapped_element_external_idsQuickBooks accounts in the new payload with no mapping. Map them before any disposition that writes.

The three dispositions

DispositionWhat it doesUse when
restateRegenerates the event's entries from the new payload, in place. The original month's figures change.Every affected month is open. This is the usual case inside the current fiscal year, and if the month is closed you can reopen it first.
catch_upLeaves the original entries alone and posts the difference as one alignment entry in an open period. That entry is local-only (publish_to_source: false): QuickBooks already has the edit, so writing it back would apply it twice.The original months are closed and their statements have gone out and should stand.
acknowledgeRecords that the difference was handled elsewhere and clears the flag without touching the ledger. A note is required, and reference_event_id should name the entry that covered it.Someone already booked the difference by hand.

The default is restate when every period the event touches is open and nothing blocks it, and catch_up otherwise. restate is blocked by a closed period, by an entry that was reversed or isn't posted, or by entries from elsewhere sharing the event's transaction.

Resolving one

# Catch up in the open month, drafted for review at close
curl -X POST "https://api.robosystems.ai/extensions/roboledger/$GRAPH_ID/operations/resolve-reconciling-item" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "evt_01J...",
    "disposition": "catch_up",
    "posting_date": "2026-09-30",
    "status": "draft",
    "note": "March invoice amount corrected in QuickBooks; Q1 statements already with the lender."
  }'

# Mark handled: the difference was booked by hand
curl -X POST "https://api.robosystems.ai/extensions/roboledger/$GRAPH_ID/operations/resolve-reconciling-item" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_id": "evt_01J...",
    "disposition": "acknowledge",
    "note": "Covered by the August true-up entry.",
    "reference_event_id": "evt_01J..."
  }'

posting_date and status (draft, the default, or posted) apply to catch_up only. A drafted catch-up entry appears in the month's drafts and posts locally when that month closes. Omit disposition to take the preview's default.

Resolving an item sets the event's payload to the accepted one. The next sync then sees no difference and does not raise the flag again. The response echoes the delta. It includes a catch_up block (event, entry and posting date) when a catch-up entry was written, and a regenerated block (transaction and entry ids) for a restate.

Errors: 404 for an unknown event, 409 when the event isn't a reconciling item (or another writer holds it), and 422 for a blocked restate, a closed target period, an unmapped account, or an unbalanced result.

Disconnect vs. Sever

Deleting a connection takes a disposition query parameter, and the two values end in different places:

# Disconnect (default): revoke access, keep the books, reconnectable
curl -X DELETE "https://api.robosystems.ai/v1/graphs/$GRAPH_ID/connections/$CONN_ID" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY"

# Sever: cut over to native books, permanently
curl -X DELETE "https://api.robosystems.ai/v1/graphs/$GRAPH_ID/connections/$CONN_ID?disposition=sever" \
  -H "X-API-Key: $ROBOSYSTEMS_API_KEY"
disconnectsever
OAuth grantRevokedRevoked
Synced booksStay in the graphStay in the graph
Chart of accountsStill owned by the QuickBooks connectionStamped as the graph's own native chart
Write policyn/a (the connection is inactive)Drops to native
Reconnecting the same companyRevives the connection and picks up where it left offNever revives it. QuickBooks can't resume over books kept natively since.

Both require the admin role on the graph. sever applies to QuickBooks only, and other providers return 400. Choose sever when the graph is leaving QuickBooks for good and will keep its books in RoboLedger from now on. Choose disconnect for everything else.

Gotchas and Pitfalls

The default window is 60 days

An edit to a transaction older than 60 days is invisible to a default sync, and so is any reconciling item it would raise. Sync with since_date from a date that covers it.

A catch-up entry is local on purpose

Don't flip a catch-up entry's publish_to_source to true. QuickBooks already holds the edit that the entry mirrors, and writing it back doubles the change there.

A failed write-back is not a failed publish of everything

After a WRITE_BACK_FAILED close, some drafts may already be in QuickBooks with qb_external_id set. Don't delete and redraft them to "start clean". Fix only the rejected entries and retry the close.

hybrid can't be set

PUT .../write-policy accepts native and qb_authoritative only. See Write Policy.

Self-hosted deployments

Connecting QuickBooks to your own stack needs your own Intuit app, OAuth redirect and tunnel. That setup is on Connecting QuickBooks Locally. Everything on this page applies unchanged once the connection exists. Use http://localhost:8000 and the key from just demo-user.

Support