この記事は現在英語版のみです。翻訳は近日公開予定です。
By the end of this recipe, your Odoo 19 sales orders, customer invoices, and customer payments will sync to QuickBooks Online in near real time, with chart-of-accounts mapping, multi-currency support, and webhook-triggered updates that keep both systems in lockstep. Skill required: senior Odoo developer or accountant-developer hybrid. Time required: 6 hours setup, 4 hours testing across the full transaction lifecycle. ECOSIRE has built this for clients who use Odoo for operations and QuickBooks for the CPA-facing books, and the recipe below is the playbook we ship.
The reason this integration is harder than Shopify or Stripe: accounting data has strict double-entry constraints. Push a half-formed transaction and QuickBooks rejects it; retry with a slightly different payload and you create a duplicate. Idempotency keys, RequestId propagation, and a sync-state ledger are mandatory. The recipe below builds all three.
What you will need
- Odoo version: 17, 18, or 19. The OCA
connector_quickbooksmodule covers v17+; for serious deployments we typically build a thin custom connector. - QuickBooks Online: Plus or Advanced tier (Simple Start lacks the API). Activated company file with chart of accounts.
- Intuit developer account: register at
developer.intuit.com, create an app, get OAuth 2.0 client ID + client secret. - HTTPS endpoint: webhook delivery requires HTTPS.
- Skills: OAuth 2.0 token refresh, REST API consumption, accounting fundamentals (debits/credits, COA mapping).
- Time: 6 hours setup, 4 hours testing.
Step-by-step
1. Create the Intuit app
Log in to developer.intuit.com, go to "My Apps", create a new app. Choose "QuickBooks Online and Payments". Under "Keys & credentials", note the Client ID and Client Secret for both Sandbox and Production. Set the Redirect URI to https://your-odoo.com/quickbooks/oauth/callback. Verification: app dashboard shows both keys and the redirect URI is whitelisted.
2. Set up the Odoo OAuth flow
The OAuth 2.0 + OpenID flow requires Odoo to redirect the user to Intuit's auth page, then exchange the returned code for an access + refresh token. Build a small controller:
from odoo import http
from odoo.http import request
import requests
import secrets
QB_AUTH_URL = 'https://appcenter.intuit.com/connect/oauth2'
QB_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer'
class QuickBooksAuthController(http.Controller):
@http.route('/quickbooks/oauth/start', auth='user', methods=['GET'])
def start(self):
config = request.env['ir.config_parameter'].sudo()
client_id = config.get_param('quickbooks.client_id')
state = secrets.token_urlsafe(16)
request.session['qb_oauth_state'] = state
url = f"{QB_AUTH_URL}?client_id={client_id}&response_type=code&scope=com.intuit.quickbooks.accounting&redirect_uri=https://your-odoo.com/quickbooks/oauth/callback&state={state}"
return request.redirect(url)
@http.route('/quickbooks/oauth/callback', auth='user', methods=['GET'])
def callback(self, code=None, state=None, realmId=None, **kwargs):
if state != request.session.get('qb_oauth_state'):
return request.make_response('Invalid state', status=400)
config = request.env['ir.config_parameter'].sudo()
resp = requests.post(QB_TOKEN_URL,
auth=(config.get_param('quickbooks.client_id'), config.get_param('quickbooks.client_secret')),
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': 'https://your-odoo.com/quickbooks/oauth/callback',
},
timeout=30,
)
token = resp.json()
config.set_param('quickbooks.access_token', token['access_token'])
config.set_param('quickbooks.refresh_token', token['refresh_token'])
config.set_param('quickbooks.realm_id', realmId)
return request.redirect('/odoo/settings/quickbooks/connected')
Verification: navigate to /quickbooks/oauth/start, authorize, get redirected back, and confirm the three params (access_token, refresh_token, realm_id) are saved in ir.config_parameter.
3. Build the COA mapping
Each Odoo account.account needs a corresponding QB account. The QB account list comes from GET /v3/company/{realmId}/query?query=select * from Account. Build a mapping table:
class QuickbooksAccountMapping(models.Model):
_name = 'quickbooks.account.mapping'
odoo_account_id = fields.Many2one('account.account', required=True)
qb_account_id = fields.Char('QuickBooks Account ID', required=True)
qb_account_name = fields.Char('QuickBooks Account Name')
Populate via a wizard: pull all QB accounts, render a table of Odoo accounts with a dropdown, let the user map them. Verification: every account that will appear on a synced invoice has a mapping row.
4. Sync customers (master = Odoo)
When a customer is created/updated in Odoo, push to QB via POST /v3/company/{realmId}/customer. Use the Odoo id as a custom field on the QB customer (CustomField) to enable idempotent matching:
def _push_customer(self, partner):
headers = self._qb_headers()
payload = {
'DisplayName': partner.name,
'CompanyName': partner.is_company and partner.name or partner.parent_id.name,
'PrimaryEmailAddr': {'Address': partner.email},
'PrimaryPhone': {'FreeFormNumber': partner.phone or ''},
'BillAddr': {
'Line1': partner.street,
'City': partner.city,
'PostalCode': partner.zip,
'Country': partner.country_id.name,
},
'CustomField': [{'DefinitionId': '1', 'Type': 'StringType', 'StringValue': str(partner.id)}],
}
if partner.qb_customer_id:
payload['Id'] = partner.qb_customer_id
payload['SyncToken'] = partner.qb_sync_token
url = f"https://quickbooks.api.intuit.com/v3/company/{realm_id}/customer?minorversion=70"
resp = requests.post(url, json=payload, headers=headers, timeout=30)
resp.raise_for_status()
data = resp.json()['Customer']
partner.qb_customer_id = data['Id']
partner.qb_sync_token = data['SyncToken']
Verification: create a customer in Odoo, watch them appear in QB Online within 5 seconds.
5. Sync invoices (master = Odoo)
The invoice push requires line-by-line account mapping. Each Odoo invoice line maps to a QB SalesItemLineDetail with the corresponding Item or AccountRef:
def _push_invoice(self, invoice):
customer_ref = self._get_or_create_qb_customer(invoice.partner_id)
line_payload = []
for line in invoice.invoice_line_ids:
mapping = self.env['quickbooks.account.mapping'].search([
('odoo_account_id', '=', line.account_id.id),
], limit=1)
if not mapping:
raise UserError(f"No QB mapping for account {line.account_id.code}")
line_payload.append({
'Amount': line.price_subtotal,
'DetailType': 'SalesItemLineDetail',
'SalesItemLineDetail': {
'Qty': line.quantity,
'UnitPrice': line.price_unit,
'AccountRef': {'value': mapping.qb_account_id},
},
'Description': line.name,
})
payload = {
'CustomerRef': {'value': customer_ref},
'TxnDate': invoice.invoice_date.isoformat(),
'DueDate': invoice.invoice_date_due.isoformat(),
'DocNumber': invoice.name,
'Line': line_payload,
}
headers = {**self._qb_headers(), 'Request-Id': f'odoo-invoice-{invoice.id}-{invoice.write_date.timestamp()}'}
resp = requests.post(
f"https://quickbooks.api.intuit.com/v3/company/{realm_id}/invoice?minorversion=70",
json=payload, headers=headers, timeout=30,
)
resp.raise_for_status()
data = resp.json()['Invoice']
invoice.qb_invoice_id = data['Id']
invoice.qb_sync_token = data['SyncToken']
The Request-Id header is the idempotency key — re-submitting the same Request-Id within 24 hours returns the original response without duplicating the invoice. Verification: validate an Odoo invoice, watch QB show the invoice with the same number, due date, and line totals within 5 seconds.
6. Configure webhooks (QB > Odoo)
Subscribe to QB webhooks for events you care about: customer-modified, invoice-modified, payment-recorded. In Intuit Developer dashboard, go to your app > Webhooks. Set the endpoint to https://your-odoo.com/quickbooks/webhook. QB signs payloads with a verifier token — paste it into Odoo config.
@http.route('/quickbooks/webhook', auth='public', csrf=False, methods=['POST'])
def webhook(self, **kwargs):
body = request.httprequest.get_data()
signature = request.httprequest.headers.get('intuit-signature')
verifier = request.env['ir.config_parameter'].sudo().get_param('quickbooks.verifier_token')
digest = base64.b64encode(hmac.new(verifier.encode(), body, hashlib.sha256).digest()).decode()
if not hmac.compare_digest(digest, signature):
return request.make_response('Invalid', status=401)
payload = json.loads(body)
for notif in payload.get('eventNotifications', []):
for entity in notif.get('dataChangeEvent', {}).get('entities', []):
if entity['name'] == 'Payment':
request.env['quickbooks.sync'].sudo().with_delay()._pull_payment(
notif['realmId'], entity['id']
)
return request.make_response('OK', status=200)
Verification: record a payment in QB Online; within 30 seconds the corresponding Odoo invoice marks as paid.
7. Handle conflicts and the SyncToken
QB's optimistic concurrency uses SyncToken. Every read returns a SyncToken; every update must echo it. If both systems edit the same record at the same time, the second update fails with Stale Object Error. Catch the error, re-fetch the current record from QB, merge changes, and retry.
Verification: edit the same customer in Odoo and QB simultaneously, save both. Odoo's retry handler re-pulls and retries within 1 second.
8. Schedule a daily reconciliation
Even with webhooks, drift happens (network failures, API outages). Run a nightly cron that compares: open invoice count, total receivables, total payables. Slack-alert if any of those drift more than 0.1 percent.
-- Nightly reconciliation query in Odoo
SELECT COUNT(*) FROM account_move WHERE state = 'posted' AND payment_state = 'not_paid' AND move_type = 'out_invoice';
Compare against the QB API equivalent: GET /v3/company/{realmId}/query?query=select count(*) from Invoice where Balance > '0'.
Verification: counts match exactly between Odoo and QB on a clean dataset.
Common mistakes
- No idempotency keys. Re-trying a failed POST creates duplicate invoices in QB. Always use
Request-Id. - Skipping COA mapping. Sync fails silently for any account.account without a mapping — invoices stack up in a "failed" queue.
- Hardcoding
minorversion. Useminorversion=70(or the latest stable) to access newer fields. Without it you're on the 2017 schema. - Not refreshing the OAuth token. QB access tokens last 1 hour. Run a token-refresh cron every 30 minutes — refresh tokens last 100 days.
- Pushing tax-inclusive invoices to a tax-exclusive QB. The totals look right but the tax breakdown is wrong, breaking sales-tax reports.
Going further
Multi-company sync: each Odoo company maps to one QB realm. Build a per-company sync configuration so a holding-structure database hits the right QB file.
Multi-currency reconciliation: QB and Odoo compute FX gain/loss differently. Run a monthly reconciliation script that checks both 7990/7991 (FX accounts) match within a small tolerance.
Vendor bills + bill payments: the same pattern works in reverse for AP. Push Odoo vendor bills to QB, pull QB-recorded bill payments back to Odoo.
Mid-month cutover: when a client moves from QB to Odoo (or vice versa), run a 30-day parallel-run period where both systems get the same data and reconcile nightly.
For a fully managed QB-Odoo integration including custom field mapping, automated reconciliation, and CPA-facing reports, ECOSIRE accounting services build the entire pipeline. Pair this with how to integrate Power BI with Odoo for unified financial dashboards.
Frequently Asked Questions
Should I sync sales orders or only invoices?
For most CPAs, only validated invoices and recorded payments matter. Sales orders are pre-revenue and don't belong in the books. We sync invoices + payments by default and only push SOs when the client wants them visible to the CPA for forecasting.
What about QuickBooks Desktop?
The QuickBooks Desktop API is the legacy Web Connector, which is significantly more limited than QBO API. Most ECOSIRE clients moving from QBD to integrated Odoo migrate to QBO first, then layer on this integration.
How do I handle journal entries that span months (deferrals, accruals)?
Tag the Odoo move with a qb_skip = True flag. Deferral journal entries are usually CPA-curated in QB directly — syncing them creates more confusion than value.
What if QB rejects an invoice with "Account is inactive"?
QB accounts can be inactivated; sync fails. Run a weekly cron that pulls the QB COA, marks any inactive accounts, and prevents Odoo from selecting them on new invoices.
For a fixed-price QB-Odoo integration including data migration and 30-day parallel run, our Odoo migration team handles it end to end. Or read how to set up multi-currency in Odoo if you trade in multiple currencies.
執筆者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
関連記事
Odoo フォーム ビューにカスタム ボタンを追加する方法 (2026)
Odoo 19 フォーム ビューにカスタム アクション ボタンを追加します: Python アクション メソッド、ビューの継承、条件付き可視性、確認ダイアログ。製造テスト済み。
Studio を使用せずに Odoo にカスタムフィールドを追加する方法 (2026)
Odoo 19 のカスタム モジュールを介してカスタム フィールドを追加します: モデル継承、ビュー拡張、計算フィールド、ストア/非ストアの決定。コードファースト、バージョン管理。
外部レイアウトを使用して Odoo にカスタム レポートを追加する方法
web.external_layout: QWeb テンプレート、paperformat、アクション バインディングを使用して、Odoo 19 でブランド化された PDF レポートを構築します。印刷ロゴ + フッターのオーバーライド付き。