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 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 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), with earlier removal on Odoo Online 21.1 (winter 2027).

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.
Important — plan availability. The External JSON-2 API requires an Odoo Custom pricing plan. One App Free and Standard plans do not include external API access (per Odoo 19 docs). Verify your subscription before architecting an integration around JSON-2 — discovering this constraint mid-build is the most expensive way to learn it.

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.

  1. 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.
  2. API key. Generate per-integration keys at Settings › Users › <User> › Account Security › New API Key. One key per integration; never reuse across environments.
  3. 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.
  4. 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.
  5. 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.

CapabilityOdoo Online (SaaS)Odoo.sh (PaaS)On-premise
External JSON-2 APIYes (Custom plan)YesYes
Custom Odoo modulesNoYes (Git-deployed)Yes
stock.quant.write hooksNo (no custom modules)YesYes
Custom fields via Odoo StudioYesYesYes
Scheduled actions (UI cron)YesYesYes
Queue workers (OCA queue_job, etc.)NoYesYes
Direct database / SSH accessNoLimited (logs, shell)Full
Note. If your integration relies on Odoo-side hooks, post-commit enqueuing, or queue workers, Odoo Online cannot host the code. Either move to Odoo.sh / on-premise, or run all integration logic on a separate sync service that consumes JSON-2 — which Odoo Online supports.
ChoiceJSON-2Legacy XML-RPC / JSON-RPCCustom REST controller
Status in Odoo 19Recommended for new integrationsDeprecated; removal in Odoo 22 (fall 2028) and Odoo Online 21.1 (winter 2027)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. Three rules:

  1. Read sellable stock from free_qty, Odoo's built-in reservation-aware field (defined as qty_available - reserved_quantity). Don't hand-roll the subtraction; use the field. Use virtual_available only 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.
  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 (Odoo.sh / on-premise only)

Important — deployment constraint. A custom module that overrides 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.
Important — never call external systems inside the write transaction. 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.

Important — sync 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:

  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.

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

EventOdoo triggerAccounting impactInventory impact
Customer cancels pre-fulfillmentsale.order.action_cancel()None (no invoice yet)Reservations released
Customer cancels post-shipmentReturn picking + refundCredit note (account.move, move_type='out_refund')Stock returned to inventory
Partial refundCredit note for refunded lines onlyCredit note linked to original invoiceStock returned only for refunded lines
Payment processor reversalWebhook from processor → credit noteCredit note + payment write-offDepends 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:

  1. Look up the Odoo sale.order by client_order_ref or your custom platform-order-ID field.
  2. Check the order's current state — if already cancel, return 200 immediately (idempotency: webhook retried).
  3. If state is sale (confirmed) and no shipments yet, call action_cancel() via JSON-2.
  4. 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.

  1. Find the original Odoo invoice from sale.order.invoice_ids.
  2. Create the account.move.reversal wizard with the original invoice ID, refund date, and journal.
  3. Call refund_moves() on the wizard to generate the credit note (state: draft).
  4. For partial refunds, edit the credit note's line items to reflect only the refunded quantities/amounts before posting.
  5. 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()
Important — tax handling on refunds. Credit notes inherit taxes from the original invoice lines. If the refund amount is computed independently (e.g. a 5% restocking fee deducted, or a "courtesy" partial refund), the tax positions and amounts must derive from the original invoice — not be re-computed. Always start from the 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):

  1. Customer initiates the return on the storefront.
  2. Storefront fires a webhook with the return reason and items.
  3. Sync service creates a stock.picking of type Customer Returns via the standard return wizard, referencing the original outgoing picking.
  4. 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.
  5. Once the return is validated, the sync service generates the refund credit note (per the refund flow above).
See also. The warehouse-side return workflow (grading, restocking, damage handling) is covered in Warehouse Management with Odoo.

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:

  1. 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.
  2. 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.

PlatformTax representationMapping notes
Magento 2Tax amounts on the order total + per-line tax breakdownMap per-line to sale.order.line.tax_id. Magento's rounding strategy is configurable — verify it matches Odoo's line-then-total rounding.
ShopifyPer-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.
WooCommercePer-line tax in line_items.taxes + cart-level tax_linesUse the line-level tax data; the cart-level totals are derived. WC tax rounding can drift on multi-currency.
PrestaShopTax-inclusive prices by default; per-line tax breakdown via webservicePrestaShop 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:

  1. Set the customer's default fiscal position when creating the res.partner (property_account_position_id field) based on shipping country and B2B status.
  2. 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.
  3. 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.payment record 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.

Important — credit notes vs refund payments. A credit note (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

  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, 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 429 responses 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_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

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

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-Sha256 header, 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:

  1. 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.
  2. Order/refund ID deduplication. Persist the platform's event ID and reject duplicates at the storage layer (database UNIQUE constraint). 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.move in 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:

  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.

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_read on 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
Important — schedule the go-live for a low-traffic window. Tuesday or Wednesday morning works for retail; never Friday afternoon, never the day before a major sales event. The hours saved by going live early are not worth the recovery cost if something fails.

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

Magendoo — commerce engineering team leading Odoo and Magento implementations for retail and e-commerce. About Magendoo. Verified against Odoo 19.0
Founder-led Senior consulting, no agency layers
Magento + Odoo Specialist focus on commerce systems
EU Based in Europe, Serving Europe
OSS Open Source Contributor
Get a Proposal • 24h response Call