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

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:
| Data | Source of truth | Direction |
|---|---|---|
| Products & prices | Odoo (usually) | Odoo → WooCommerce |
| Inventory levels | Odoo (usually) | Odoo → WooCommerce |
| Orders & customers | WooCommerce | WooCommerce → Odoo |
| Fulfilment / tracking | Odoo | Odoo → 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; aproduct.productis 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/executeYour 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-Signatureheader — 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.
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
- Nail the reads and writes with Odoo search_read, in depth and create, write, unlink.
- Trigger state changes with call_method.
- Make every webhook handler robust with Odoo API error handling.
- Compare the pattern with a different storefront in Building a Shopify–Odoo integration, and see the reusable engine in Building a two-way Odoo data sync.
- Read the full request and response contract in the API reference, or skip the envelope boilerplate with the Python SDK.