Integration For Developers 15 min read Updated May 6, 2026

Odoo E-commerce Integration: JSON-2 API, Inventory Sync, Orders, and Webhooks

How to connect Odoo to Magento, Shopify, WooCommerce, or PrestaShop — architecture, code, and the failure modes you only learn about in production.

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 typeTypical ownerWhy
Product catalogOdooSKU master data, costs, supplier links live in the back office. The storefront publishes a subset.
Inventory levelsOdooInventory is a function of receipts, sales, and adjustments — Odoo is the only system that sees all three.
OrdersE-commerce platformOrders originate at the storefront. Odoo receives them as sale.order records.
CustomersBidirectionalNew 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.

Important. The exception some teams try to make: "prices should be editable on both sides." This is the most expensive integration decision you can make. Choose one system as the price authority and enforce it in code.

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).

Important — what this means in practice. If you are starting a new integration today, build it on JSON-2. If you have a working XML-RPC integration on Odoo 17 or 18, plan a migration before your Odoo 19 upgrade — not after. The deprecation timeline is firm enough to plan against, not loose enough to ignore.
ChoiceJSON-2Legacy XML-RPC / JSON-RPCCustom REST controller
Status in Odoo 19Recommended for new integrationsDeprecated; removal scheduled in Odoo 22 (earlier on Odoo Online 19.1)Stable but adds maintenance surface
AuthAPI key (bearer)API key or password via uidWhatever you implement
Endpoint shapePOST /json/2/<model>/<method>/xmlrpc/2/object + execute_kw — or /jsonrpcCustom path on http.controller
When to chooseDefault for any integration starting in 2026+Only to extend an existing system already on it; plan migration before next Odoo upgradeWhen 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()
Note — JSON-2 is named-arguments only. Unlike 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.

See also: External JSON-2 API — Odoo 19.0 documentation for the full spec including pagination, error responses, and per-method conventions.

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

PlatformMaps toNotes
Magento 2Configurable product (template) + simple products (variants)Configurable attributes must exist in Magento before products are created.
ShopifyProduct + variantsMax 100 variants per product. Plan for more = split into multiple products.
WooCommerceVariable product (template) + variations (variants)Attributes must be set as "Used for variations" in WC.
PrestaShopProduct + combinationsCombinations 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')
Note — modified_since matters. Use Odoo's 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:

  1. 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.
  2. Push on every change. Hook into Odoo's stock.quant writes via a custom module that fires a webhook to your sync service when a quant changes.
  3. 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.

Important — reservation-aware quantities only. If you sync raw 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:

  1. Resolves the customer to a res.partner (create if new — see customer flow below).
  2. Resolves each line item's product by external ID → product.product.id.
  3. Creates the sale.order in Odoo, linking customer, lines, shipping address, billing address, and payment terms.
  4. Optionally confirms the order (transitions from quotation to sales order) — your business rules decide whether all platform orders auto-confirm.
  5. 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:

  1. Recording the platform's order ID in your sync service before creating the Odoo order.
  2. If a duplicate webhook arrives with an order ID already in flight or completed, no-op and return 200.
  3. Use a database constraint (UNIQUE on the platform order ID column) to make duplicates impossible at the storage layer.
Tip — taxes are the most platform-specific part. Magento sends taxes as line totals. Shopify sends per-line tax. WooCommerce sends both. Map carefully: in Odoo, taxes attach to lines via 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

  1. External ID (e.g., shopify_customer_id): if a partner with this external ID exists, use it.
  2. Email address: if no external ID match but an email match exists, link the records and write the missing external ID.
  3. Create new: if no match, create a new res.partner with 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 caseMechanismWhy
Order createdWebhookCustomer-facing latency. Webhook fires within 1–2 seconds.
Inventory change in OdooWebhook (custom)Storefront stock visibility.
Product attribute updatesPolling (every 5–15 min)Not time-critical; webhook overhead not justified.
Customer profile updatesWebhook + nightly reconciliationWebhook handles 95% of cases; nightly reconciliation catches missed events.
Refunds and returnsWebhookAffects accounting and inventory; needs to flow through quickly.
Discontinued productsPolling on activate/deactivateLow 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.
Note. Platform-specific webhook signatures: Shopify uses HMAC-SHA256, Magento uses HMAC-SHA256, WooCommerce uses HMAC-SHA256 with a custom secret, PrestaShop's webhook layer is module-dependent. Validate signatures on every call. Unsigned webhooks are an open vulnerability.

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_attribute separate from id_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.
See also. If you're choosing between maintaining your own Odoo + e-commerce integration and using a managed service, our Odoo E-commerce Integration service page covers what we deliver.

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.
Tip. Log to a structured store (Elasticsearch, OpenSearch, BigQuery), not just to flat files. Querying "why did this specific order fail to sync at 2am last Tuesday" is impossible without structured logs.

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:

  1. Log the full payload and the error.
  2. Tag the operation as requires_attention rather than retrying — it will fail again on retry.
  3. 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.

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.

Florinel Chis — commerce engineer leading Odoo and Magento implementations for retail and e-commerce. About Magendoo. Verified against Odoo 19.0
22+ Years in Commerce Engineering
50+ Enterprise Magento Projects
EU Based in Europe, Serving Europe
OSS Open Source Contributor
Get a Proposal • 24h response Call