Syncing invoices to QuickBooks when the other side keeps changing under you
5 min read
Every “sync to QuickBooks” feature starts out looking like a POST request. Then it meets production, where an accountant deletes a customer in QuickBooks that your app still thinks exists, a token expires halfway through a batch, someone reconnects to a different QuickBooks company, and a retried job cheerfully creates the same invoice twice. On the field-service SaaS I built the backend for, invoicing was the feature customers cared about most and the one most exposed to someone else’s system misbehaving. This is what it took to make it boring.
Refresh the token before it dies, not after
The naive OAuth flow refreshes when a call returns 401. That’s a wasted round-trip and a race: two concurrent jobs both get the 401, both refresh, and one of them invalidates the other’s brand-new token. We refresh proactively — if the access token is within five minutes of expiring, refresh before making the call:
def self.token_needs_refresh?(user)
buffer_for_proactive_refresh = 5.minutes
return true if user.access_token_expires_at_quickbooks.nil?
user.access_token_expires_at_quickbooks < (Time.current + buffer_for_proactive_refresh)
end
And refresh is where you find out that “the token” is really three moving parts — the access token, its expiry, and sometimes a rotated refresh token. QuickBooks rotates the refresh token periodically, so you have to persist the new one when it changes or you’ll be locked out on the next cycle:
new_token_data = token_object_to_refresh.refresh!
attributes_to_update = {
access_token_quickbooks: new_token_data.token,
access_token_expires_at_quickbooks: Time.now + new_token_data.expires_in.to_i.seconds
}
if new_token_data.refresh_token.present? && new_token_data.refresh_token != current_refresh_token_value
attributes_to_update[:refresh_token_quickbooks] = new_token_data.refresh_token
end
user.update!(attributes_to_update)
I also gave auth its own error types, so callers can tell “the token is dead, the user must reconnect” apart from “QuickBooks is having a bad day, retry later”:
class AuthenticationError < StandardError; end
class TokenRefreshError < AuthenticationError; end
That distinction matters more than it looks. A TokenRefreshError should surface to the user as
“reconnect your QuickBooks”; a transient 5xx should just be retried by the job. Collapsing both into
a generic raise means either nagging users who don’t need to do anything, or silently dropping
syncs that would have succeeded on retry.
Idempotent by default, self-healing when it isn’t
Rule one for anything that can be retried: doing it twice must not create two things. The invoice create is a no-op if we’ve already synced it:
def create
return if invoice.quickbooks_id.present?
# ...
end
Rule two is the interesting one, because it’s about a failure you can’t prevent, only recover from.
An accountant deletes a customer in QuickBooks. Your app still has that customer’s QuickBooks id
stored. You send an invoice for them, and QuickBooks rejects it with error 6250 — invalid
customer. Most integrations just mark that as failed and page someone. Instead, we treat 6250 as a
signal to re-find the customer by name and retry:
def create
return if invoice.quickbooks_id.present?
begin
response = qbo_object.create(:invoice, payload: invoice_payload)
invoice.mark_quickbooks_sync_success(response['Id'], @user.realm_id_quickbooks)
rescue => e
if e.message.include?('Invalid Customer') && e.message.include?('6250')
found_customer = find_customer_by_display_name
if found_customer
update_client_quickbooks_id(found_customer['Id'])
retry_create_with_updated_customer
else
invoice.mark_quickbooks_sync_failed(StandardError.new("customer deleted in QuickBooks, no match found"))
end
else
invoice.mark_quickbooks_sync_failed(e)
end
end
end
find_customer_by_display_name is more than an exact lookup — it walks from exact DisplayName to
name-parts to a fuzzy score with accent-insensitive matching, because customer names drift between
two systems maintained by two different people. Is that overkill for a name match? Maybe. But it’s
the difference between “your invoices silently stopped syncing three weeks ago” and the customer
never noticing anything happened. I’ll take a little overkill on the recovery path.
Don’t let your own callbacks start a fight
Sync is triggered from model callbacks — save an invoice, it queues a sync. But the sync also writes back to the invoice (the QuickBooks id, the sync status), which fires the callback again. Left alone, that’s an infinite loop or, more commonly, a storm of redundant API calls. The guard is a flag and a block helper:
def with_quickbooks_sync_disabled
original_value = skip_quickbooks_sync
self.skip_quickbooks_sync = true
yield
ensure
self.skip_quickbooks_sync = original_value
end
def can_sync_to_quickbooks?
user_quickbooks_authenticated? && !skip_quickbooks_sync
end
Every write-back the sync performs happens inside with_quickbooks_sync_disabled, so recording the
result never re-triggers the sync. The ensure restores the previous value rather than hard-setting
it to false, which keeps it correct even when these blocks nest.
The multi-company trap
One user can connect QuickBooks, disconnect, and reconnect to a different company (a different “realm”). Your stored QuickBooks ids belong to the old company and mean nothing in the new one — send them and you’ll attach an invoice to a stranger. So sync state records which realm it belongs to, and we refuse to trust an id from the wrong company:
def has_valid_quickbooks_id_for_current_realm?
return false unless quickbooks_id.present?
if respond_to?(:quickbooks_realm_id) && quickbooks_realm_id.present?
return quickbooks_realm_id == User.current_user&.realm_id_quickbooks
end
true # legacy rows predate realm tracking; heal them on next sync
end
Sync status itself is a small state machine (not_sent → processing → sent / failed), so a job can
tell a record that’s mid-flight from one that genuinely needs sending, and reconcile anything stuck
in processing that already has a QuickBooks id — the classic “the write succeeded but we crashed
before recording it” case.
The honest part
If you audit this you’ll find a real wart: some of the QuickBooks customer lookups build the QBO query by interpolating names into a query string with hand-rolled escaping. It works, and QBO’s query language limits the blast radius, but it’s the thing I’d replace first — parameterize it, or lean entirely on id-based lookups with the name search as a pure fallback. I’d rather write that here than have you find it and wonder if I noticed.
The reason to tell that on yourself: a resilient integration isn’t one with no rough edges, it’s one where you know exactly where they are and what happens when they’re hit. Idempotent writes, proactive token refresh, typed auth errors, loop guards, realm checks, and a recovery path for the failure you can’t prevent — that’s what turned “sync to QuickBooks” from a support-ticket generator into something nobody had to think about.
I build and maintain financial integrations in Rails — QuickBooks, Stripe, bank feeds — the kind where “it synced twice” or “it charged the wrong company” is a real incident. If that’s the corner you’re in, get in touch.