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 patterns are platform-agnostic; the platform-specific sections cover the parts that aren't. All code examples target Odoo 17 / 18 / 19 (XML-RPC and ORM API are stable across these versions).
Architecture: deciding who owns what
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), and earlier on Odoo Online (19.1).
| Choice | JSON-2 | Legacy XML-RPC / JSON-RPC | Custom REST controller |
|---|---|---|---|
| Status in Odoo 19 | Recommended for new integrations | Deprecated; removal scheduled in Odoo 22 (earlier on Odoo Online 19.1) | 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. The pattern that works:
- Compute available quantity in Odoo as
qty_available - qty_reserved(the "free" stock). For multi-warehouse setups, decide whether the storefront sees one warehouse, multiple, or a virtual aggregate. - 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
# 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:
self._notify_inventory_change()
return result
def _notify_inventory_change(self):
# Group by product, send batch webhook
products = self.mapped('product_id')
payload = [{'product_id': p.id,
'sku': p.default_code,
'qty_available': p.qty_available - p.qty_reserved}
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.
qty_available without subtracting qty_reserved, the storefront will oversell while orders are in the picking queue. The free stock is what's actually available to sell.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.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 2
- Use the REST API for catalog/orders, not SOAP. The SOAP API is deprecated.
- 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
- Rate limits are strict. 2 requests/second on REST, more on GraphQL. Plan for backoff with exponential retry.
- 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
Odoo's XML-RPC handles batched operations natively. 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.
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.
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
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), with earlier removal on Odoo Online (19.1). 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.