Integrating Odoo with an e-commerce platform is a system design problem before it is a coding problem. The naive approach — "sync everything both ways every few minutes" — produces a working demo and a broken production system. Real integrations are built around explicit data ownership, idempotent operations, layered sync strategies, and observable failure modes.
This guide walks through the architecture and implementation for connecting Odoo to Magento, Shopify, WooCommerce, and PrestaShop. The architecture and idempotency patterns are platform-agnostic; per-platform notes (Magento 2 / Shopify / WooCommerce / PrestaShop) flag where each platform diverges from the common shape. Code examples target Odoo 19 (JSON-2 API + ORM API). The patterns and architecture apply to Odoo 17/18 too; the API endpoint differs.
Odoo integration architecture: which system owns which data
Every integration failure I have debugged in the past five years started with the same root cause: nobody decided, before writing code, which system owns which piece of data. In a Odoo + e-commerce integration, four data types need explicit ownership decisions:
| Data type | Typical owner | Why |
|---|---|---|
| Product catalog | Odoo | SKU master data, costs, supplier links live in the back office. The storefront publishes a subset. |
| Inventory levels | Odoo | Inventory is a function of receipts, sales, and adjustments — Odoo is the only system that sees all three. |
| Orders | E-commerce platform | Orders originate at the storefront. Odoo receives them as sale.order records. |
| Customers | Bidirectional | New customers register on the storefront; B2B customers are often created in Odoo first. Use external IDs to merge. |
The key principle: an attribute should be writable in one system and read-only in the other. Once both sides can edit the same field, you have a conflict resolution problem with no clean answer.
Authentication and API selection (Odoo 19+)
For new integrations against Odoo 19 and later, use the External JSON-2 API. It is the canonical external interface going forward. The older XML-RPC and JSON-RPC endpoints (/xmlrpc/2/object and /jsonrpc) are deprecated in Odoo 19 — they still work, but they are scheduled for removal in Odoo 22 (fall 2028), with earlier removal on Odoo Online 21.1 (winter 2027).
Before you choose JSON-2: pre-flight checklist
Five things to confirm before architecting an integration around JSON-2. Discovering any of them mid-build costs more than checking now.
- Plan availability. External API access is included only on the Odoo Custom plan. One App Free and Standard plans do not allow /json/2/ access. Verify against your subscription before designing.
- API key. Generate per-integration keys at Settings › Users › <User> › Account Security › New API Key. One key per integration; never reuse across environments.
- X-Odoo-Database header. Required only when multiple databases share a domain. Single-database hosts ignore it; including it harms nothing, and integrations that work in one tenant break silently in another without it.
- Rate-limiting. Odoo applies plan-dependent throttling on the JSON-2 endpoints. Plan for retry-with-backoff on 429 responses from the start; do not rely on a fixed request rate.
- Staging parity. JSON-2 keys are environment-scoped. Generate a separate key per environment (dev, staging, prod) and verify the staging environment is a true production clone — not just connectivity-OK.
What each Odoo deployment lets you do
The patterns in this guide are not equally available across Odoo deployments. Custom modules, stock hooks, and post-commit jobs require code-level access that Odoo Online does not provide.
| Capability | Odoo Online (SaaS) | Odoo.sh (PaaS) | On-premise |
|---|---|---|---|
| External JSON-2 API | Yes (Custom plan) | Yes | Yes |
| Custom Odoo modules | No | Yes (Git-deployed) | Yes |
stock.quant.write hooks | No (no custom modules) | Yes | Yes |
| Custom fields via Odoo Studio | Yes | Yes | Yes |
| Scheduled actions (UI cron) | Yes | Yes | Yes |
Queue workers (OCA queue_job, etc.) | No | Yes | Yes |
| Direct database / SSH access | No | Limited (logs, shell) | Full |
| Choice | JSON-2 | Legacy XML-RPC / JSON-RPC | Custom REST controller |
|---|---|---|---|
| Status in Odoo 19 | Recommended for new integrations | Deprecated; removal in Odoo 22 (fall 2028) and Odoo Online 21.1 (winter 2027) | Stable but adds maintenance surface |
| Auth | API key (bearer) | API key or password via uid | Whatever you implement |
| Endpoint shape | POST /json/2/<model>/<method> | /xmlrpc/2/object + execute_kw — or /jsonrpc | Custom path on http.controller |
| When to choose | Default for any integration starting in 2026+ | Only to extend an existing system already on it; plan migration before next Odoo upgrade | When external system needs a stable contract decoupled from Odoo's model |
JSON-2: endpoint and authentication
The endpoint pattern places the model and method directly in the URL: POST /json/2/<model>/<method>. Authentication uses an API key in the Authorization: bearer header — no separate login step, no uid to track. Body is a JSON object with the method's named arguments, plus ids (omit for @api.model methods) and an optional context.
Search and read partners
curl -X POST https://yourodoo.example.com/json/2/res.partner/search_read \
-H "Authorization: bearer YOUR_API_KEY" \
-H "X-Odoo-Database: production" \
-H "Content-Type: application/json" \
-d '{
"domain": [["is_company", "=", true]],
"fields": ["id", "name", "vat", "country_id"],
"limit": 100
}'Create a record
curl -X POST https://yourodoo.example.com/json/2/res.partner/create \
-H "Authorization: bearer YOUR_API_KEY" \
-H "X-Odoo-Database: production" \
-H "Content-Type: application/json" \
-d '{"name": "New B2B Partner", "email": "[email protected]", "is_company": true}'Python with httpx
import httpx
client = httpx.Client(
base_url='https://yourodoo.example.com',
headers={
'Authorization': 'bearer YOUR_API_KEY',
'X-Odoo-Database': 'production', # only required when multiple DBs share a domain
'Content-Type': 'application/json',
},
timeout=30.0,
)
# Read modified products since last sync
resp = client.post('/json/2/product.product/search_read', json={
'domain': [
['active', '=', True],
['type', '=', 'product'],
['write_date', '>', last_sync_iso],
],
'fields': ['id', 'default_code', 'name', 'list_price', 'qty_available'],
'limit': 1000,
})
resp.raise_for_status()
products = resp.json()execute_kw, you cannot pass positional arguments. Every parameter must be named in the JSON body. The trade-off is a cleaner contract — no array-position juggling — at the cost of slight verbosity for simple calls.API keys (required, not optional)
Generate a per-integration API key at Settings ‣ Users ‣ <User> ‣ Account Security ‣ New API Key. Treat it as a secret: store in your secrets manager, rotate per-integration on compromise, revoke when the integration is decommissioned. JSON-2 has no fallback to login/password — the API key is the auth.
Legacy: XML-RPC and JSON-RPC (transitional)
XML-RPC remains documented and functional on Odoo 17, 18, and 19, and is the right choice if you are extending an existing integration that already uses it. Endpoint /xmlrpc/2/object; authenticate via /xmlrpc/2/common.authenticate(db, login, password_or_apikey) to get a uid; then execute_kw against models. Treat any new XML-RPC code as legacy by default — it has a deprecation deadline.
Sync flow 1: products — Odoo → e-commerce
Products flow from Odoo to the storefront. The Odoo product model is more complex than most storefronts'; the integration's job is to project Odoo's structure onto the platform's.
Odoo product structure
product.template— the template (e.g., "T-Shirt"). Holds shared attributes: name, description, category, price.product.product— the variant (e.g., "T-Shirt / Red / M"). Holds variant-specific data: SKU (default_code), barcode, attribute values.product.attribute+product.attribute.value— the dimensions (Color: Red/Green/Blue; Size: S/M/L/XL).
A product without variants has one product.template and one product.product with the same effective data. A product with variants has one template and N products.
Mapping to platforms
| Platform | Maps to | Notes |
|---|---|---|
| Magento 2 | Configurable product (template) + simple products (variants) | Configurable attributes must exist in Magento before products are created. |
| Shopify | Product + variants | Max 100 variants per product. Plan for more = split into multiple products. |
| WooCommerce | Variable product (template) + variations (variants) | Attributes must be set as "Used for variations" in WC. |
| PrestaShop | Product + combinations | Combinations explode multiplicatively — set max combinations to enforce sanity. |
External IDs are non-negotiable
Every product synced to the platform should carry the Odoo ID as an external reference. In Magento it goes into a custom attribute (odoo_id); in Shopify a metafield; in WooCommerce a meta key; in PrestaShop a feature. On every subsequent sync, look up by external ID first, fall back to SKU. This prevents duplicates when SKUs change.
# Pseudo-code for the sync loop
for template in odoo.get_templates(modified_since=last_run):
variants = odoo.get_variants(template.id)
platform_id = platform.find_by_external_id(
external_id=f'odoo:{template.id}',
fallback_sku=template.default_code
)
payload = build_platform_payload(template, variants)
if platform_id:
platform.update_product(platform_id, payload)
else:
platform.create_product(payload, external_id=f'odoo:{template.id}')
log_sync('product', template.id, platform_id, status='ok')write_date field with a '>' filter and persist the last sync timestamp. Polling all products every run is correct for the first sync and a disaster for the hundredth.Sync flow 2: inventory — Odoo → e-commerce, in real time
Inventory is the data type with the lowest tolerance for staleness. A storefront showing "in stock" for a sold-out item produces a customer service incident every time. Three rules:
- Read sellable stock from
free_qty, Odoo's built-in reservation-aware field (defined asqty_available - reserved_quantity). Don't hand-roll the subtraction; use the field. Usevirtual_availableonly when you specifically want forecasted stock (qty_available + incoming - outgoing). For multi-warehouse setups, read the field with a warehouse context —product.with_context(warehouse=<id>).free_qty— or aggregate explicitly via a virtual location. - Push on every change. Hook into Odoo's
stock.quantwrites via a custom module that fires a webhook to your sync service when a quant changes. - Reconcile periodically. Every hour (or shorter on Black Friday), run a delta sync that compares all tracked products' quantities. Webhooks miss events under failure conditions; reconciliation closes the gap.
Odoo-side hook (Odoo.sh / on-premise only)
stock.quant.write requires Odoo.sh or on-premise. Odoo Online does not allow custom modules, so this pattern is unavailable there — fall back to scheduled reconciliation (next subsection) or platform-side polling.stock.quant.write fires on every receipt, picking validation, inventory adjustment, and quant merge — extremely high volume on busy days. Synchronous external calls block stock operations and couple inventory accuracy to your sync service's availability. Always enqueue work after the database commits, using self.env.cr.postcommit.add() or the OCA queue_job module.The hook itself
# addons/your_integration/models/stock_quant.py
from odoo import models
class StockQuant(models.Model):
_inherit = 'stock.quant'
def write(self, vals):
result = super().write(vals)
if 'quantity' in vals or 'reserved_quantity' in vals:
quant_ids = self.ids # capture before commit
# Run AFTER commit so a failure in our sync doesn't roll back stock writes.
self.env.cr.postcommit.add(
lambda: self.env['stock.quant']
.browse(quant_ids).exists()
._enqueue_inventory_notification()
)
return result
def _enqueue_inventory_notification(self):
# Hand off to a queue worker — never call the external system synchronously here.
products = self.mapped('product_id')
payload = [{'product_id': p.id,
'sku': p.default_code,
'free_qty': p.free_qty}
for p in products]
self.env['integration.webhook'].queue('inventory.update', payload)Reconciliation job
Schedule a cron (Odoo's ir.cron or external scheduler) that pulls current quantities for all platform-published products and pushes any that differ from the platform's last-known value. Run hourly during normal traffic, every 15 minutes during peak.
free_qty, not qty_available. qty_available is owned-on-hand stock; reservations are not subtracted. Syncing it raw causes the storefront to oversell while orders sit in the picking queue. free_qty is the field Odoo already computes for "sellable now" — use it. virtual_available is for forecasted/incoming-aware views, not for "can the customer buy this right now."Sync flow 3: orders — e-commerce → Odoo
Orders flow into Odoo. Each platform order becomes a sale.order record with attached sale.order.lines, a partner (res.partner), and a payment reference.
The contract
The order webhook from the platform should hit your sync service with a complete order payload. The sync service:
- Resolves the customer to a
res.partner(create if new — see customer flow below). - Resolves each line item's product by external ID →
product.product.id. - Creates the
sale.orderin Odoo, linking customer, lines, shipping address, billing address, and payment terms. - Optionally confirms the order (transitions from quotation to sales order) — your business rules decide whether all platform orders auto-confirm.
- Writes the platform order ID into a custom field on the Odoo order for backreference.
# Create the order via JSON-2
order_resp = client.post('/json/2/sale.order/create', json={
'partner_id': partner_id,
'partner_invoice_id': billing_partner_id,
'partner_shipping_id': shipping_partner_id,
'origin': f'shopify-{shopify_order_id}',
'client_order_ref': platform_order_number,
'x_platform_order_id': shopify_order_id, # custom field
'order_line': [(0, 0, {
'product_id': line.product_id,
'product_uom_qty': line.qty,
'price_unit': line.price,
'tax_id': [(6, 0, line.tax_ids)],
}) for line in order_lines],
})
order_resp.raise_for_status()
order_id = order_resp.json() # JSON-2 returns the new ID
# Auto-confirm if business rules allow
client.post('/json/2/sale.order/action_confirm', json={'ids': [order_id]})Idempotency
Webhooks retry. A platform that fires "order created" three times because the first two responses timed out should produce one Odoo order, not three. Implement this by:
- Recording the platform's order ID in your sync service before creating the Odoo order.
- If a duplicate webhook arrives with an order ID already in flight or completed, no-op and return 200.
- Use a database constraint (
UNIQUEon the platform order ID column) to make duplicates impossible at the storage layer.
tax_id and Odoo recomputes — your inputs need to match Odoo's expected tax model or your invoiced totals will diverge from what the customer paid.Post-order lifecycle: cancellations, refunds, credit notes
Orders are the easy part. The complications come after: a customer cancels before fulfillment, returns part of an order, asks for a partial refund, or the platform's payment processor reverses the charge. Each event has its own state transition in Odoo, its own accounting impact, and its own webhook delivery quirk.
The four post-order events that matter
| Event | Odoo trigger | Accounting impact | Inventory impact |
|---|---|---|---|
| Customer cancels pre-fulfillment | sale.order.action_cancel() | None (no invoice yet) | Reservations released |
| Customer cancels post-shipment | Return picking + refund | Credit note (account.move, move_type='out_refund') | Stock returned to inventory |
| Partial refund | Credit note for refunded lines only | Credit note linked to original invoice | Stock returned only for refunded lines |
| Payment processor reversal | Webhook from processor → credit note | Credit note + payment write-off | Depends on order state at reversal |
Every event has three non-negotiables: idempotent webhook handling (refund webhooks retry; do not create two credit notes for one refund), correct tax mapping (the credit note inherits taxes from the original invoice line — wrong tax handling produces invoiced totals that diverge from what the customer was actually refunded), and proper linkage (account.move.reversed_entry_id points to the original invoice — required for audit trail in EU jurisdictions and for the customer's VAT records).
Cancellation flow
A platform cancellation webhook arrives at your sync service. The handler:
- Look up the Odoo
sale.orderbyclient_order_refor your custom platform-order-ID field. - Check the order's current state — if already
cancel, return 200 immediately (idempotency: webhook retried). - If state is
sale(confirmed) and no shipments yet, callaction_cancel()via JSON-2. - If state is
done(shipped or invoiced), this is a return, not a cancellation — route to the refund flow below.
# Cancel a confirmed (pre-shipment) sale order via JSON-2
resp = client.post('/json/2/sale.order/action_cancel', json={
'ids': [odoo_order_id],
})
resp.raise_for_status()Refund flow with credit note
A platform refund (full or partial) generates a credit note in Odoo. The correct pattern uses Odoo's account.move.reversal wizard rather than creating a fresh account.move from scratch — the wizard handles tax inheritance, fiscal-position mapping, and the reversed_entry_id linkage that downstream reconciliation depends on.
- Find the original Odoo invoice from
sale.order.invoice_ids. - Create the
account.move.reversalwizard with the original invoice ID, refund date, and journal. - Call
refund_moves()on the wizard to generate the credit note (state: draft). - For partial refunds, edit the credit note's line items to reflect only the refunded quantities/amounts before posting.
- Post the credit note (state: posted), then reconcile against the customer's open balance or the payment refund.
# Generate a credit note from an existing invoice via JSON-2
resp = client.post('/json/2/account.move.reversal/create', json={
'move_ids': [(6, 0, [original_invoice_id])],
'date': refund_date, # ISO 8601
'reason': f'Refund for platform order {platform_ref}',
'journal_id': refund_journal_id,
})
wizard_id = resp.json()
# Generate and post the credit note
resp = client.post('/json/2/account.move.reversal/refund_moves', json={
'ids': [wizard_id],
})
credit_note = resp.json()account.move.reversal wizard and adjust line-level amounts; never create a free-standing out_refund move with hand-rolled tax calculations.Returns from the storefront
Returns differ from cancellations because they involve physical goods coming back. The flow has two halves — operational (warehouse must physically receive) and financial (credit note posts after the return is validated):
- Customer initiates the return on the storefront.
- Storefront fires a webhook with the return reason and items.
- Sync service creates a
stock.pickingof type Customer Returns via the standard return wizard, referencing the original outgoing picking. - Operator at the warehouse processes the return through the Barcode app — scans items back in, grades them (resellable, damaged, customer error), routes to the appropriate location.
- Once the return is validated, the sync service generates the refund credit note (per the refund flow above).
Payment processor refund lag
When the credit note posts in Odoo, the customer does not see money in their account immediately. Typical processor lag for refunds:
- Card refunds (Stripe, Adyen, Worldline): 3–5 business days back to the original card
- SEPA bank transfers: 5–7 business days
- SumUp, Mercado Pago: provider-specific, typically 2–7 days
- PayPal: usually within 1 business day for sender-balance refunds; longer for funded sources
The credit note is the accounting record; the actual money movement happens on the processor's schedule. Communicate the timing in the refund confirmation email — most support tickets in the refund-lag window come from customers expecting instant settlement.
Idempotency on refund webhooks
Refund webhooks retry on transient failure. Without idempotency, a retry creates a duplicate credit note, double-credits the customer, and produces a reconciliation incident. Two layers of defence:
- Storage-layer idempotency. Persist the platform refund ID in a custom field on the credit note (
x_platform_refund_id). Add a unique constraint at the database level. Duplicate webhooks fail the insert, return 200, and no second credit note is created. - Application-layer idempotency. Before calling the reversal wizard, search for an existing credit note with the platform refund ID. If found, return 200 with the existing ID; never re-run the wizard.
Both layers together produce a system that handles webhook retries, partial-failure replays, and operator-driven manual replays without ever creating a duplicate.
Accounting constraints: taxes, fiscal positions, payment capture
The accounting layer is where Odoo + e-commerce integrations most often diverge from each other and from what the customer actually paid. Three categories of constraint matter — and mismatched tax handling is the single biggest source of "the invoice total does not match what the customer was charged" tickets.
Tax mapping is platform-specific
Each platform expresses taxes differently. The integration's job is to translate the platform's tax model into Odoo's tax records on the sale.order.line — and to keep the resulting invoice totals matching the customer's charge to the cent.
| Platform | Tax representation | Mapping notes |
|---|---|---|
| Magento 2 | Tax amounts on the order total + per-line tax breakdown | Map per-line to sale.order.line.tax_id. Magento's rounding strategy is configurable — verify it matches Odoo's line-then-total rounding. |
| Shopify | Per-line tax with multiple tax lines per item (e.g., GST + state) | Aggregate the line's tax_lines into a list of Odoo tax IDs. Shopify's "tax_included" flag must match the matching Odoo tax's price_include setting. |
| WooCommerce | Per-line tax in line_items.taxes + cart-level tax_lines | Use the line-level tax data; the cart-level totals are derived. WC tax rounding can drift on multi-currency. |
| PrestaShop | Tax-inclusive prices by default; per-line tax breakdown via webservice | PrestaShop stores tax-inclusive amounts; convert to Odoo's expected representation based on the matching tax's price_include. |
Fiscal positions handle cross-border and exemption
An EU retailer shipping to a customer in another EU country has different VAT rules than the same retailer selling domestically. Odoo handles this with fiscal positions at Accounting › Configuration › Fiscal Positions. A fiscal position remaps the standard taxes to the correct ones based on customer location, customer type (B2B/B2C), or product category.
The integration should:
- Set the customer's default fiscal position when creating the
res.partner(property_account_position_idfield) based on shipping country and B2B status. - Verify the order's computed taxes match the platform's recorded taxes after fiscal-position remapping. Discrepancies usually mean the fiscal position needs an additional rule.
- For OSS (One Stop Shop) compliance, ensure the platform records the correct destination country on each line — Odoo's OSS reports rely on this for the quarterly filing.
Payment capture and reconciliation
Two patterns for reflecting customer payments in Odoo:
- Per-order payment record. Each platform order generates an
account.paymentrecord in Odoo, linked to the customer's invoice. Clean audit trail; one record per transaction. Right for low-volume B2B. - Settlement-batch reconciliation. The processor sends a daily settlement file (Stripe payouts, Adyen settlement reports). The integration imports the settlement, reconciles individual transactions against open invoices, and posts the net deposit to the bank journal. Right for high-volume retail; matches how the bank statement actually flows.
Most retailers running over ~50 orders per day want pattern 2 — per-order payment records become impractical at volume because the bank statement arrives as net settlements, not individual transactions.
account.move, move_type='out_refund') is the accounting record. A refund payment (account.payment, payment_type='outbound') is the money movement. Both are needed for a complete refund: the credit note offsets the original invoice on the customer's ledger; the payment matches the actual outflow when it appears on the bank statement. The post-order lifecycle section above covers the credit-note creation; the matching outbound payment is created when the processor settles the refund.Sync flow 4: customers — bidirectional with merge
Customers are the only flow where data legitimately moves both ways. New customers register on the storefront (storefront → Odoo). Existing B2B customers may be created in Odoo first (Odoo → storefront). The sync layer's job is to merge them based on stable identifiers.
Merge keys, in priority order
- External ID (e.g.,
shopify_customer_id): if a partner with this external ID exists, use it. - Email address: if no external ID match but an email match exists, link the records and write the missing external ID.
- Create new: if no match, create a new
res.partnerwith the external ID set.
Email-based matching has a known weakness: customers who change emails and customers who use the same email for multiple accounts. For B2C this is acceptable. For B2B, prefer VAT number as the merge key.
Updates after creation
Address changes, tax exemptions, and contact info should sync from the platform on update events. The Odoo side, however, often holds B2B-specific data (credit limits, payment terms, salesperson) that the storefront should not overwrite. Define the writable subset explicitly:
WRITABLE_BY_PLATFORM = {
'street', 'street2', 'city', 'zip', 'state_id', 'country_id',
'phone', 'mobile', 'email',
}
def update_partner_from_platform(partner_id, platform_payload):
update_vals = {k: v for k, v in platform_payload.items()
if k in WRITABLE_BY_PLATFORM}
if update_vals:
models.execute_kw(db, uid, password,
'res.partner', 'write', [[partner_id], update_vals]) Webhooks vs polling: when to use which
Both layers are needed. Webhooks deliver real-time updates; polling catches what webhooks miss.
| Use case | Mechanism | Why |
|---|---|---|
| Order created | Webhook | Customer-facing latency. Webhook fires within 1–2 seconds. |
| Inventory change in Odoo | Webhook (custom) | Storefront stock visibility. |
| Product attribute updates | Polling (every 5–15 min) | Not time-critical; webhook overhead not justified. |
| Customer profile updates | Webhook + nightly reconciliation | Webhook handles 95% of cases; nightly reconciliation catches missed events. |
| Refunds and returns | Webhook | Affects accounting and inventory; needs to flow through quickly. |
| Discontinued products | Polling on activate/deactivate | Low frequency. Polling is sufficient. |
Webhook reliability
Treat webhooks as best-effort. Every platform's webhook system has failure modes:
- Timeouts: your endpoint must respond within the platform's window (Shopify: 5s, Magento: configurable, WooCommerce: PHP timeout). Acknowledge fast, process async.
- Out-of-order delivery: an "order updated" webhook can arrive before the "order created" webhook on the platform's retry queue. Handle by always fetching the current order state, not trusting the webhook payload exclusively.
- Duplicate delivery: idempotency, as discussed above.
Platform-specific gotchas: Magento, Shopify, WooCommerce, PrestaShop
Magento 2
- Use the REST API or GraphQL for catalog/orders, not SOAP. Adobe Commerce / Magento 2.4.8+ positions GraphQL as the strategic API direction; SOAP remains functional but is no longer the recommended path. For a new integration, choose GraphQL for storefront-facing reads and REST for admin operations.
- Bulk operations exist. Use
/V1/bulk/endpoints for >50 records to avoid hammering the storefront. - Stock items are per-source. Magento 2.3+ uses MSI (Multi-Source Inventory). Your inventory sync must address the right source code, not just the SKU.
- Configurable products require attribute setup first. Create the configurable attribute (e.g., "color") in Magento, set it as Use for Configurable Products: Yes, then create products that reference it.
Shopify
- Use GraphQL Admin API for new integrations. Shopify now labels the REST Admin API as legacy. The GraphQL Admin API uses cost-based rate limiting — 100 points/second on standard plans, 1000 on Plus, 2000 on Enterprise via a leaky-bucket algorithm. Plan for backoff with exponential retry on
429responses regardless of which interface you use. - Variant max is 100. Hard cap. Products with more variants need to be split.
- Inventory levels are per-location. The InventoryLevel API requires a
location_id. Default location works for single-location stores; multi-location requires explicit mapping. - Webhook deliveries from Shopify are signed but require careful body handling. Read the raw request body before parsing JSON to verify HMAC.
WooCommerce
- WC's REST API is built on WordPress's. Performance scales with the WP infrastructure — expect to need caching and a beefy host for large catalogs.
- Variation attributes must be set globally first. Create attributes via the Products → Attributes menu before creating variable products via API.
- Order status workflows differ. WC has "processing," "on-hold," "completed," "cancelled." Map carefully to Odoo's
sale.order.state.
PrestaShop
- The webservice is XML-only by default. JSON support depends on configuration. Most production integrations stay with XML.
- Combinations vs products. What Odoo calls a variant, PrestaShop calls a combination. Combinations have their own
id_product_attributeseparate fromid_product. - Multi-shop adds complexity. If the PrestaShop install runs multi-shop mode, every entity has a shop association that needs to be set explicitly.
Performance: batching, async, and observability
Batch reads and writes
The Odoo External JSON-2 API supports batched operations natively — pass arrays of IDs to write/create/unlink for multi-record operations in a single round-trip. Legacy XML-RPC remains supported for existing integrations only. search_read with a list of IDs returns all records in one call. write with a list of IDs applies the same vals to all of them. Use these.
# BAD — one round trip per product
for product_id in product_ids:
models.execute_kw(db, uid, password,
'product.product', 'read', [product_id], {'fields': ['qty_available']})
# GOOD — one round trip total
results = models.execute_kw(db, uid, password,
'product.product', 'read', [product_ids],
{'fields': ['id', 'default_code', 'qty_available']})Async processing
Webhooks should respond fast (200 OK) and process the work asynchronously. Use a queue: Redis + a worker, RabbitMQ, AWS SQS, or whatever your stack supports. The webhook handler enqueues; the worker processes. This decouples the platform's retry behavior from your sync service's processing capacity.
Observability
Every sync operation should produce a log line with: timestamp, operation type, source ID, target ID, status, and duration. Aggregate these into a dashboard. The most important metrics:
- Sync lag: time between platform event and Odoo state update. Should be < 5 seconds for orders, < 30 seconds for inventory.
- Failure rate: percentage of sync operations that errored. Should be < 0.1%.
- Reconciliation drift: number of records the nightly reconciliation had to fix. Should trend toward zero.
- Queue depth: number of pending sync operations. Should be near zero except during bulk operations.
Security: webhook signatures, secrets, least-privilege
An integration that handles orders and refunds is sitting on the company's money. The security primitives below are the difference between an integration that survives a year and one that hosts an incident.
Webhook signature validation
Every platform signs webhook payloads. Validation is a per-call check that the request actually came from the platform and was not modified in transit. Platform conventions:
- Shopify: HMAC-SHA256 in the
X-Shopify-Hmac-Sha256header, base64-encoded over the raw request body using your shared secret. - Magento 2: HMAC-SHA256, header name configurable on the webhook integration.
- WooCommerce: HMAC-SHA256 in
X-WC-Webhook-Signature, base64-encoded over the raw body using the webhook secret. - PrestaShop: Module-dependent; default modules typically use shared-secret token validation rather than HMAC.
Two implementation rules: (1) compute the HMAC over the raw request body, before any JSON parsing or normalisation — re-serialising and hashing produces a different result. (2) Use a constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node) — straight string equality leaks timing information that lets an attacker progressively guess valid signatures.
Replay protection
A valid signed request can be replayed by anyone who captures it. Two layers of defence:
- Timestamp window. Most platforms include a timestamp in the webhook header. Reject requests older than ~5 minutes — the legitimate retry window is shorter than that anyway.
- Order/refund ID deduplication. Persist the platform's event ID and reject duplicates at the storage layer (database
UNIQUEconstraint). This also doubles as idempotency protection — covered in Post-order lifecycle for refunds.
IP allowlisting where supported
Some platforms publish their webhook source IP ranges (Shopify, Adyen, Stripe). When available, restrict your webhook endpoints to those ranges at the firewall or load-balancer layer. Combined with signature validation, this dramatically reduces attack surface — even a leaked secret cannot be exploited from outside the platform's IP range.
Secret rotation
Webhook secrets, API keys, and database credentials should rotate on a schedule and on-demand on suspected compromise. Practical defaults:
- API keys: rotate quarterly; store in a secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager) — never in plaintext env files on shared hosts.
- Webhook signing secrets: rotate when staff with access leave; rotate immediately on confirmed leak.
- Build the rotation flow once and exercise it quarterly — the time to find out the rotation is broken is not during an incident.
Least-privilege integration users
The integration should not run with admin or superuser privileges. Create separate Odoo users for each direction and grant the minimal access needed:
- Inbound user (orders flow platform → Odoo): permissions to create
sale.order,res.partner,account.movein the appropriate company; no settings or user-management access. - Outbound user (inventory/products flow Odoo → platform): read-only permissions on the relevant models. Cannot create or modify Odoo records.
- Reconciliation user (nightly cron): scheduled actions only; no interactive access.
Each user gets its own API key. Compromise of one direction doesn't grant the other.
Rate-limit your own endpoints
The platform's rate limits protect them; yours protect you. A flood of replay-attempted webhooks against an unrate-limited endpoint can fill your queue, exhaust workers, and effectively DoS the legitimate sync. Add a per-source-IP rate limit at the load balancer or web server layer with a generous threshold (e.g., 100 req/sec per IP) — legitimate webhook traffic rarely approaches it; abuse exceeds it immediately.
Error handling and conflict resolution
Two failure modes deserve specific handling:
Validation errors from Odoo
If the sync service tries to create a sale order with an invalid product ID, an unknown tax, or a partner missing required fields, Odoo returns a fault. The handler should:
- Log the full payload and the error.
- Tag the operation as requires_attention rather than retrying — it will fail again on retry.
- Surface the error in an operations dashboard for human review.
Never silently swallow validation errors. "It silently succeeds and silently fails" is the worst possible behavior because the data is missing and nobody knows.
Concurrent modification
If a customer's address is updated in Odoo and on the storefront within the same minute, which wins? The conflict resolution rule should be explicit:
- Last write wins: simplest, accepts that one update may be lost. Acceptable for low-stakes fields.
- Source of truth wins: the system designated as the owner always wins. Recommended for prices, costs, inventory.
- Manual resolution: queue the conflict for a human. Required for accounting-relevant fields where neither system can be safely overwritten.
Go-live checklist
What must be true before the integration runs against real customer traffic. Each item has a known recovery cost when missed; the cost of checking is minutes.
API and authentication
- Production API key generated, scoped to a dedicated integration user (not a personal account)
- API key stored in a secrets manager — never in plaintext env files on shared hosts, never in the repo
- Webhook signing secrets rotated to production values (not the staging set carried over)
- Bearer auth verified end-to-end with a live curl against
POST /json/2/res.partner/search_readon the production Odoo
Webhooks and reconciliation
- Platform-side webhook endpoints registered to production URLs and reachable from the platform's IP range (test with the platform's webhook-debug tool)
- Signature validation enabled in production; unsigned or invalid-sig requests return 401
- Reconciliation cron scheduled (hourly during normal traffic, every 15 minutes during peak)
- Dead-letter queue configured with an alert that fires on depth > 0
Staging parity
- Staging Odoo is a true production clone — comparable product count, customer count, order shape, fiscal positions
- End-to-end smoke test passed on staging in the last 7 days: order create, partial refund, customer merge, inventory webhook, reconciliation pass
Operational readiness
- Runbook documented: who responds, escalation path, rollback procedure
- On-call rota defined for the first week post-launch — at minimum one engineer with read access to both Odoo and the e-commerce platform
- Sync-lag and failure-rate dashboards live before traffic flows; not "we'll set them up next week"
- Legacy system kept available read-only for 30 days as a safety net
Reference notes
Sources verified against Odoo 19.0 documentation and standards bodies. Use these to confirm anything before applying it to your environment.
- External JSON-2 API — Odoo 19.0 documentation — canonical external API for new integrations on Odoo 19+
- External RPC API (legacy) — Odoo 19.0 documentation — XML-RPC and JSON-RPC reference, deprecated in Odoo 19, removed in Odoo 22
- Magento 2 REST API reference — product, order, and inventory (MSI) endpoints
- Shopify Admin API — product, order, inventory, webhook signing
- WooCommerce REST API — WC REST endpoints and authentication
- PrestaShop Webservice — PrestaShop XML-based webservice reference
- Shopify Admin API rate limits — cost-based GraphQL rate limit (100/sec standard; 1000 Plus; 2000 Enterprise); REST Admin labeled legacy
- Adobe Commerce GraphQL API reference — strategic API direction for Adobe Commerce / Magento 2.4.8+
Frequently Asked Questions
Should I use a pre-built connector or build a custom integration?
Pre-built connectors (the OCA's connector framework, commercial connectors like Webkul or Magnitude) are the right choice when your data model and workflows are standard. They cover 80% of cases at 20% of the cost. Build custom when you have non-standard products (configurable, bundled, subscription), B2B-specific workflows (negotiated pricing, approval flows, credit limits), or volume that breaks the pre-built connector's batching assumptions. The break-even is roughly 50,000 SKUs or 1,000 orders per day.
Should I still use XML-RPC or JSON-RPC for new Odoo integrations?
No. For new integrations on Odoo 19+, use the External JSON-2 API (POST /json/2/<model>/<method>, bearer API key auth). Both legacy XML-RPC (/xmlrpc/2/object) and the older JSON-RPC (/jsonrpc) endpoints are deprecated in Odoo 19. Per Odoo's deprecation notice, both are scheduled for removal in Odoo 22 (fall 2028) and Odoo Online 21.1 (winter 2027). XML-RPC and JSON-RPC remain functional today and are the right choice only if you are extending an existing integration that already uses them — and even then, plan a migration to JSON-2 before your Odoo 21 or 22 upgrade. See the External JSON-2 API documentation for the full reference.
Can I sync prices from Odoo to my e-commerce platform?
Yes — and you should. Odoo's pricelist system handles base price, customer-specific pricing, and quantity breaks. Sync the customer-facing list price (and pricelist if your platform supports tiered pricing) on every product update. The complication is multi-currency: most storefronts have a single currency, while Odoo can run multi-currency natively. Decide the currency authority before you start syncing prices.
How do I handle inventory across multiple Odoo warehouses?
Two patterns work. Pattern A: dedicated warehouse for online. One Odoo warehouse is the e-commerce stock, separate from physical store stock. Simple, but moves inventory between warehouses adds operational steps. Pattern B: aggregated virtual location. A virtual stock location aggregates real warehouses. Sync the aggregated quantity to the storefront. Complex to set up; matches retailers with omnichannel fulfillment. Magento 2's MSI and Shopify's multi-location are designed for this pattern.
What's the right retry strategy for failed webhook deliveries?
Exponential backoff with a cap. First retry after 30 seconds, then 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours. After ~6–8 retries, move the failed event to a dead letter queue and alert. Do not retry indefinitely — a permanently failing event indicates a bug or a data integrity issue, not a transient failure. Dead-letter queue items need human review.
How do I test an Odoo integration without breaking production?
Three environments minimum: a developer Odoo instance (Odoo.sh dev branch or local Docker), a staging Odoo paired with a staging instance of the e-commerce platform, and production. Run the full integration test suite against staging before promoting. The test suite should cover: product create/update, inventory increase/decrease, order create from platform, refund, and customer merge. Automate it. Manual testing produces gaps that show up in production.
How do I monitor an Odoo integration in production?
Three signals matter most. Sync lag (time between event and synced state) — alert if > 60 seconds for orders. Reconciliation drift (records the nightly reconciliation had to fix) — alert if > 10 per day for inventory. Dead letter queue depth — alert on any non-zero value. Build a dashboard showing all three; instrument the integration code to emit structured logs. Without observability, integration bugs are silent until a customer reports them.