この記事は現在英語版のみです。翻訳は近日公開予定です。
Shopify-Odoo Deep Integration 2026: Inventory, Orders, Accounting Sync
A real Shopify-Odoo connector is more than the off-the-shelf "sync products" app. Production integrations have to handle: bi-directional inventory across multiple warehouses, order ingestion with idempotency, customer dedup, refunds and returns, accounting postings (revenue, COGS, tax, fees), multi-currency, and partial fulfillment. Most off-the-shelf Shopify-Odoo apps cover the happy path; the long tail is what bites you in production. This article is the architecture playbook we use across our Shopify-Odoo integrations — covering both Odoo 17 and Odoo 19, on-prem and SH.
Key Takeaways
- Odoo is the system of record for inventory and accounting; Shopify is the system of record for the customer journey
- Use Shopify webhooks (Pub/Sub destination for high volume) plus a periodic reconciliation pass — never webhooks alone
- Map Shopify variants to Odoo
product.product(variants), notproduct.template— many connectors get this wrong- Inventory sync: Odoo → Shopify push on
stock.moveconfirmation, Shopify → Odoo only for sales (no manual stock edits in Shopify)- Order ingestion uses
X-Shopify-Webhook-Idfor idempotency, persisted in a dedicated Odoo model- Accounting: book Shopify Payments fees as a separate journal line on each invoice, not as a "service product"
- Plus shops with multi-currency need price lists per Shopify market matched to Odoo
pricelists
Architecture overview
A production connector has six logical pipelines, each running independently:
- Product/variant sync (Odoo → Shopify)
- Inventory sync (Odoo → Shopify, near-real-time on stock moves)
- Order ingestion (Shopify → Odoo via webhooks)
- Customer sync (Shopify → Odoo on first order, with dedup)
- Refund/return sync (Shopify → Odoo, generates credit notes)
- Reconciliation (nightly, catches anything webhooks missed)
Each pipeline has its own queue, its own retry semantics, and its own observability. Trying to put them in one cron job is the #1 reason naive connectors fail under load.
+------------------+
| Shopify |
+--------+---------+
| webhooks (Pub/Sub)
v
+--------+---------+
| Connector |
| (NestJS/Python) |
+--------+---------+
| XML-RPC / JSON-RPC
v
+--------+---------+
| Odoo |
+------------------+
We build the connector layer in NestJS (TypeScript) for ECOSIRE Plus customers, using @ecosire/odoo-client for the Odoo side. Some clients run a Python connector inside Odoo as a custom module — both approaches work; the NestJS approach scales better and isolates Odoo upgrades from connector code.
Product and variant mapping
Shopify products have variants via option combinations. Odoo products have product.template (the SKU family) and product.product (the actual sellable variant).
| Shopify | Odoo |
|---|---|
| Product | product.template |
| Variant | product.product |
| Option (Color, Size) | product.attribute + product.attribute.value |
| Option value | product.template.attribute.line |
The mapping we keep in a custom Odoo model:
class ShopifyProductMap(models.Model):
_name = 'ecosire.shopify.product.map'
odoo_product_id = fields.Many2one('product.product', required=True)
shopify_variant_id = fields.Char(required=True, index=True)
shopify_product_id = fields.Char(required=True, index=True)
last_synced = fields.Datetime()
sync_direction = fields.Selection([
('o2s', 'Odoo to Shopify'),
('s2o', 'Shopify to Odoo'),
('both', 'Bi-directional'),
])
For most ECOSIRE clients we run Odoo as the master for product data — Odoo is the single source of truth for SKU, cost, BOM, supplier — and push to Shopify on save.
Inventory sync
The hardest part. Three failure modes to avoid:
- Oversell: Shopify shows in-stock after Odoo already committed the stock to a B2B picking
- Stuck inventory: Shopify shows out-of-stock when Odoo has plenty
- Drift: Shopify and Odoo disagree by 1-2 units per SKU after a few weeks
The pattern that works:
- Odoo → Shopify push: on every
stock.moveconfirmation, recompute theqty_availablefor affected products and push to Shopify via GraphQLinventoryAdjustQuantitiesmutation. - Shopify → Odoo pull: only for sales orders, never for manual edits. The merchant should not edit inventory directly in Shopify.
- Reconciliation: nightly job pulls full inventory from Shopify, compares to Odoo, logs deltas. Auto-correct only when Odoo is authoritative.
def sync_inventory(self, product_ids):
inventory_updates = []
for p in self.env['product.product'].browse(product_ids):
mapping = self.env['ecosire.shopify.product.map'].search([
('odoo_product_id', '=', p.id)
], limit=1)
if not mapping:
continue
for warehouse in self.env['stock.warehouse'].search([]):
qty = p.with_context(warehouse=warehouse.id).qty_available
shopify_location_id = warehouse.shopify_location_id
inventory_updates.append({
'inventoryItemId': mapping.shopify_inventory_item_gid,
'locationId': shopify_location_id,
'quantity': int(qty),
})
return self._push_to_shopify(inventory_updates)
The GraphQL mutation:
mutation AdjustInventory($changes: [InventoryAdjustQuantityInput!]!) {
inventoryAdjustQuantities(input: { reason: "correction", changes: $changes }) {
inventoryAdjustmentGroup { id reason changes { name delta } }
userErrors { field message }
}
}
For a 50,000-SKU catalog with 4 warehouses, the recon job runs about 8 minutes (mostly on Shopify's side; the GraphQL bulk operation takes most of the time).
Order ingestion
Webhooks (orders/create, orders/paid, orders/cancelled, orders/edited) deliver order events. Each is processed idempotently using the webhook ID as the dedup key:
class ShopifyWebhookEvent(models.Model):
_name = 'ecosire.shopify.webhook.event'
webhook_id = fields.Char(required=True, index=True) # X-Shopify-Webhook-Id
topic = fields.Char(required=True)
shop_domain = fields.Char(required=True)
payload = fields.Text(required=True)
received_at = fields.Datetime(default=fields.Datetime.now)
processed_at = fields.Datetime()
state = fields.Selection([
('pending', 'Pending'),
('done', 'Done'),
('error', 'Error'),
], default='pending')
_sql_constraints = [
('webhook_id_uniq', 'unique(webhook_id)', 'Webhook already received'),
]
The unique constraint on webhook_id makes duplicate inserts a no-op, so retried webhook deliveries (which Shopify sends until you respond 200) don't double-process.
Once persisted, a queued worker (Odoo queue_job module or external worker) processes the event:
def _process_orders_create(self, payload):
order = self.env['sale.order'].search([
('shopify_order_id', '=', payload['id'])
], limit=1)
if order:
return order # idempotent
partner = self._upsert_customer(payload['customer'])
order = self.env['sale.order'].create({
'partner_id': partner.id,
'shopify_order_id': str(payload['id']),
'shopify_order_name': payload['name'],
'date_order': payload['created_at'],
'currency_id': self._get_currency(payload['currency']).id,
'order_line': [
(0, 0, self._line_vals(li)) for li in payload['line_items']
],
})
if payload.get('financial_status') == 'paid':
order.action_confirm()
return order
For more on webhook delivery and HMAC verification, see our Shopify webhooks production guide.
Accounting postings
This is where naive connectors fail. A Shopify order produces this in your books:
| Account | Debit | Credit |
|---|---|---|
| Bank (Shopify Payouts clearing) | $100 | |
| Shopify Payments fees | $3.20 | |
| Sales revenue | $93.46 (US, ex-tax) | |
| Sales tax payable | $9.74 |
Most off-the-shelf connectors record only the gross sale and dump the fee as a "service" line item, which breaks tax reporting. The right pattern: separate journal entries for the sale (Sale Order → Customer Invoice → Payment) and the gateway fee.
In Odoo:
- Create the customer invoice for the sale (Sale Order → invoice)
- On
orders/paid, create a payment against the invoice - On Shopify Payouts webhook (
payouts/paid), create a journal entry for the fee deduction
def _book_payout_fees(self, payout):
fee_account = self.env.company.shopify_fees_account_id
bank_account = self.env.company.shopify_payouts_account_id
self.env['account.move'].create({
'journal_id': self.env.company.shopify_journal_id.id,
'date': payout['date'],
'line_ids': [
(0, 0, {'account_id': fee_account.id, 'debit': payout['fees'], 'credit': 0}),
(0, 0, {'account_id': bank_account.id, 'debit': 0, 'credit': payout['fees']}),
],
})
Plus shops should map fees to a sub-account per gateway (Shopify Payments vs Stripe vs PayPal) for reconciliation against gateway statements.
Refunds and returns
Refund webhook (refunds/create) maps to an Odoo credit note. Partial refunds reduce specific line amounts; full refunds cancel the invoice. Restocking depends on the Shopify refund's restock_type field:
Shopify restock_type | Odoo action |
|---|---|
no_restock | Credit note only, no inventory change |
cancel | Cancel the picking if not shipped, credit note |
return | Create return picking, then credit note on receipt |
legacy_restock | Legacy — treat as return |
Returns specifically (where the customer ships goods back) need stock.picking of type "Return" tied to the original delivery. We model this in custom code — Odoo's RMA module handles the workflow but doesn't natively know about Shopify return reasons.
Multi-currency
If the merchant uses Shopify Markets with multiple currencies, every Odoo invoice must carry the original Shopify currency. Pricelists in Odoo map 1:1 to Shopify Markets. See our Shopify Markets pricing guide for the storefront side.
When Shopify Payments converts EUR sales to USD payouts, you have:
- Invoice in EUR (customer-facing)
- Payment in EUR
- Payout in USD
- Currency exchange gain/loss in your books
Odoo handles this via the bank statement reconciliation flow if you import payouts as bank lines. Don't try to compute the FX manually — Odoo's accounting engine does it correctly.
Reconciliation
A nightly reconciliation pass pulls:
- All Shopify orders from the last 7 days that should be in Odoo
- All Shopify inventory levels for SKUs in scope
- All Shopify refunds from the last 7 days
Compares to Odoo, logs anything missing, and either auto-creates or flags for review. This catches webhook delivery failures, manual fixes, and subscription auto-deletions (when Shopify auto-deletes a webhook subscription after 19 failures, you stop receiving events — only reconciliation catches it).
Performance benchmarks
For a Plus shop doing 5,000 orders/day:
| Pipeline | Throughput | Latency |
|---|---|---|
| Inventory push (Odoo → Shopify) | 50,000 SKUs in 8 min | Real-time on stock.move |
| Order ingestion | 5,000 orders/day, 0% lag | <2 sec from Shopify event to Odoo SO |
| Refund processing | 500/day | <5 sec |
| Reconciliation | Full sweep in 35 min | Nightly |
Odoo on a properly tuned Postgres (PG17) handles this comfortably on 8 vCPU / 32 GB RAM.
Frequently Asked Questions
Why not use the Odoo Shopify Connector module from the App Store?
It's fine for under 100 orders/day with a simple catalog. Beyond that, the off-the-shelf connector lacks: idempotent webhook processing, multi-warehouse inventory, Shopify Markets, refund/return RMAs, gateway fee accounting, and proper reconciliation. We've replaced 12+ off-the-shelf connectors with custom builds for clients hitting these limits.
Do I need a separate connector per Shopify store?
If you run multiple Shopify stores against one Odoo company, one connector with multi-tenancy works. If each store is a separate Odoo company, run one connector per store for blast-radius isolation.
How do I handle Shopify draft orders?
Draft orders represent quotes/manual orders not yet completed. Sync them as Odoo quotations, then convert to confirmed sale orders when the draft order is completed. Two webhooks: draft_orders/create → SO quote, draft_orders/update → SO update, then orders/create → confirmation.
What about B2B customers and price lists?
Shopify B2B companies map to Odoo res.partner with is_company=True. Each B2B company can have its own Shopify catalog and price list, which maps to an Odoo pricelist. Sync B2B-specific Net 30 terms via Odoo payment terms.
Can the connector handle Shopify Functions (custom discounts)?
Functions execute inside Shopify checkout — your connector receives the resulting order with discounts already applied. Map the discount lines to Odoo using discount and note on the SO line. For full custom-discount logic in Odoo, see our Shopify Functions guide.
ECOSIRE has built 18+ Shopify-Odoo connectors, including for Plus shops with 50K+ SKUs and 5K+ daily orders. Our Shopify-Odoo integration team handles architecture, build, deployment, and on-call support. See our Odoo implementation services for end-to-end ERP rollouts.
執筆者
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 レポートを構築します。印刷ロゴ + フッターのオーバーライド付き。