← Blog
odoowoocommercewordpressintegrationecommercejson-rpc

Odoo WooCommerce Integration: Build It with the API

Sync WooCommerce products, orders, and stock into Odoo through the External API — the models, the JSON-RPC calls, webhooks, and idempotency patterns that hold up.

ODXProxy Team · Jul 13, 2026 · 9 min read

Odoo WooCommerce Integration: Build It with the API — ODXProxy blog cover

There are packaged Odoo WooCommerce connectors on the Odoo App Store, and for a plain catalog one of them may be all you need. But WooCommerce is a WordPress plugin, not a closed platform — stores accumulate custom product types, ACF fields, multi-warehouse stock, and bespoke order logic fast. The moment your requirements outgrow a packaged connector, you build the integration yourself against the API. This guide is the developer's version: which Odoo models to touch, the exact External API calls to make, how to drive it from WooCommerce webhooks, and the idempotency patterns that keep a two-way sync from creating duplicates. Every Odoo call below runs over JSON-RPC through a single endpoint.

Two APIs, one integration

A custom WooCommerce–Odoo integration is a small service that speaks to both sides:

  • WooCommerce REST API — WordPress-hosted, authenticated with a consumer key/secret pair you generate under WooCommerce → Settings → Advanced → REST API. It exposes products, orders, customers, and more at /wp-json/wc/v3/….
  • Odoo External API — the ERP side, reached here through the ODXProxy JSON-RPC endpoint.

Your service is the thing in the middle. It reads from one, writes to the other, and keeps a mapping between their record ids. Nothing about the WooCommerce half is exotic; the interesting engineering is on the Odoo side, which is where this guide spends its time.

Decide the direction of each sync first

"Sync WooCommerce with Odoo" is really three separate flows, and each needs an owner. Deciding who is the source of truth for each object is the most important design step — get it wrong and the two systems overwrite each other:

DataSource of truthDirection
Products & pricesOdoo (usually)Odoo → WooCommerce
Inventory levelsOdoo (usually)Odoo → WooCommerce
Orders & customersWooCommerceWooCommerce → Odoo
Fulfilment / trackingOdooOdoo → WooCommerce

The rest of this guide follows the two highest-value flows: pushing products and stock out to WooCommerce, and pulling orders in to Odoo.

The Odoo models you'll touch

A WooCommerce integration maps onto a small set of Odoo models. Knowing these upfront saves a lot of fields_get spelunking:

  • product.template / product.product — the catalog. A template is the abstract product; a product.product is a concrete variant, which is what an order line actually references.
  • stock.quant — on-hand quantities per location; the real inventory figure.
  • res.partner — customers, plus their delivery and invoice addresses as child contacts.
  • sale.order / sale.order.line — the order and its lines.
  • stock.picking — the delivery, where fulfilment and tracking numbers live.

You reach all of them the same way: through the nine actions of the External API. If the models here are unfamiliar, Odoo search_read, in depth and create, write, unlink cover the read and write mechanics used below.

One endpoint, two secrets

Every Odoo call in this guide goes to the same route through the proxy:

POST /api/odoo/execute

Your service talks to that one endpoint; the proxy forwards to the right Odoo instance. Keep the two secrets straight — conflating them is the usual cause of a stray 401:

  • x-api-key — the proxy's key, an HTTP header authenticating your integration to the proxy.
  • odoo_instance.api_key — the Odoo user's key, sent per request in the body, authenticating the ERP call itself.

Neither of these is the WooCommerce consumer key — that lives entirely on the WordPress side. The full credential setup for the proxy is in How to authenticate to the Odoo API.

Flow 1 — match a WooCommerce product to its Odoo record

Whichever way your catalog syncs, you constantly need to answer "does this WooCommerce product already exist in Odoo?" The robust way is an external identifier — store the WooCommerce product id on the Odoo record (a dedicated field like x_woo_id, or Odoo's ir.model.data external IDs) and look it up before you decide to create or update. That single habit is what makes a sync idempotent.

Look up first with search_read:

{
  "id": "find-product-1",
  "action": "search_read",
  "model_id": "product.product",
  "params": [[["x_woo_id", "=", 8123]]],
  "keyword": { "fields": ["id", "name"], "limit": 1 },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

If the result array is empty, create the product; if it has a row, write to the returned id. That branch — read, then create-or-update — is the backbone of every sync direction. See the domain syntax used in that filter in Odoo domain filters explained.

Flow 2 — turn a WooCommerce order into an Odoo sale order

When WooCommerce fires an order.created webhook, your integration builds an Odoo sale.order. Do it in three steps, each idempotent.

Step 1 — upsert the customer. Search res.partner by email; create if missing. The result of a create is the new record's id, which you keep for the order:

{
  "id": "create-partner-1",
  "action": "create",
  "model_id": "res.partner",
  "params": [{ "name": "Dana Lee", "email": "dana@example.com", "customer_rank": 1 }],
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

Step 2 — create the order with its lines in one call. Odoo builds order lines through the one2many command triple [0, 0, values], which means "create a new linked record." Pass the whole order and its lines together so you never end up with a half-built order:

{
  "id": "create-order-1",
  "action": "create",
  "model_id": "sale.order",
  "params": [{
    "partner_id": 57,
    "client_order_ref": "woo-1001",
    "order_line": [
      [0, 0, { "product_id": 41, "product_uom_qty": 2 }],
      [0, 0, { "product_id": 63, "product_uom_qty": 1 }]
    ]
  }],
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

Storing the WooCommerce order number in client_order_ref gives you the same idempotency lever as before: prior to creating, search_count on client_order_ref and skip if the order already landed. The [0, 0, {…}] command syntax and the other one2many operations are detailed in create, write, unlink.

Step 3 — confirm the order. Confirming a draft quotation into a real sales order isn't one of the eight CRUD actions — it's a model method, so you reach it through call_method with a non-empty fn_name:

{
  "id": "confirm-order-1",
  "action": "call_method",
  "model_id": "sale.order",
  "fn_name": "action_confirm",
  "params": [[104]],
  "keyword": {},
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

call_method is the escape hatch that makes the whole workflow reachable — anything Odoo's own buttons do, you can trigger. Its mechanics are covered in Calling Odoo model methods with call_method.

Driving it from WooCommerce webhooks

Polling WooCommerce for changes is slow and wasteful; the right trigger is WooCommerce's own webhooks, configured under WooCommerce → Settings → Advanced → Webhooks. Your integration exposes an HTTP endpoint that WooCommerce calls on events like order.created, product.updated, and customer.created. Each webhook maps to one of the flows above.

Two rules keep this reliable:

  • Verify the signature on every incoming WooCommerce webhook before acting on it. WooCommerce signs the raw body with HMAC-SHA256 using the webhook secret and sends it in the X-WC-Webhook-Signature header — recompute it and compare before you trust the payload.
  • Treat webhooks as at-least-once. WooCommerce retries deliveries, so the same event can arrive more than once. Your handler must be idempotent — which is exactly why every flow above looks up an external id before it creates. Idempotent handlers turn duplicate deliveries into harmless no-ops.
A 200 from the proxy does not mean Odoo accepted the write. Odoo logic errors — a missing required field, a blocked state transition — come back with HTTP 200 and a populated error object. Check the HTTP status first, then check the body's error field before you treat a webhook as processed.

The full two-layer error model — proxy failures versus Odoo logic errors, and how to retry each — is in Odoo API error handling. Getting it right matters more in a webhook handler than anywhere else, because a mis-read error is a silently dropped order.

Keeping inventory in sync

Inventory is the flow most likely to drift, because it changes on both sides. The pragmatic pattern: make Odoo authoritative, read on-hand quantities from stock.quant, and push them to WooCommerce whenever a stock.quant changes (via an Odoo automation or a short poll). Read a variant's on-hand quantity with search_read:

{
  "id": "read-stock-1",
  "action": "search_read",
  "model_id": "stock.quant",
  "params": [[["product_id", "=", 41], ["location_id.usage", "=", "internal"]]],
  "keyword": { "fields": ["quantity", "reserved_quantity", "location_id"] },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

Push the available figure (quantity minus reserved_quantity) to the WooCommerce product's stock_quantity, and let the external-id mapping tie the Odoo variant to the WooCommerce product. If a product sells in variations, map each Odoo product.product to its WooCommerce variation id, not just the parent.

A realistic scope note

This is a build-it-yourself pattern, not a turnkey app. For a standard store, evaluate a packaged Odoo WooCommerce connector first — it handles the common cases without code. Reach for a custom API integration when your requirements outgrow it: bespoke product mapping, multi-warehouse allocation, custom order routing, or syncing alongside systems the packaged connector doesn't touch. When you do, the External API gives you the full ERP surface, and the patterns above — external-id idempotency, upsert-before-create, call_method for state transitions, and the two-step error check — are what keep the sync trustworthy.

Where to go next