Building a Shopify–Odoo Integration with the API
Sync Shopify orders, products, and inventory into Odoo through the External API — the models, the calls, webhooks, and idempotency patterns that hold up.
ODXProxy Team · Jul 8, 2026 · 8 min read

Odoo ships an official Shopify connector, and for a plain store it may be all you need. But the moment your catalog, pricing, or fulfilment logic gets specific — multi-warehouse stock, custom order routing, a product model that doesn't map one-to-one — you end up building a custom Shopify–Odoo integration 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 Shopify webhooks, and the idempotency patterns that keep a two-way sync from creating duplicates. Everything below runs over Odoo's JSON-RPC External API through a single endpoint.
Decide the direction of each sync first
"Sync Shopify with Odoo" is really three separate flows, and each has 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 fight each other:
| Data | Source of truth | Direction |
|---|---|---|
| Products & prices | Odoo (usually) | Odoo → Shopify |
| Inventory levels | Odoo (usually) | Odoo → Shopify |
| Orders & customers | Shopify | Shopify → Odoo |
| Fulfilment / tracking | Odoo | Odoo → Shopify |
The rest of this guide follows the two highest-value flows: pushing products/inventory out to Shopify, and pulling orders in to Odoo.
The Odoo models you'll touch
A Shopify 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 (the thing an order line actually references).stock.quant— on-hand quantities per location; the real inventory figure.res.partner— customers (and their delivery/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 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.
The full credential setup is in How to authenticate to the Odoo API.
Flow 1 — push a product to Shopify's Odoo record
Whichever way your catalog syncs, you'll constantly need to answer "does this Shopify product already
exist in Odoo?" The robust way to do that is an external identifier — store the Shopify product id
on the Odoo record (a dedicated field like x_shopify_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_shopify_id", "=", "gid://shopify/Product/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 Shopify order into an Odoo sale order
When Shopify fires an orders/create 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": "shopify-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 Shopify order number in client_order_ref gives you the same idempotency lever as before:
before 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 Shopify webhooks
Polling Shopify for changes is slow and wasteful; the right trigger is Shopify's own webhooks. Your
integration exposes an HTTP endpoint that Shopify calls on events like orders/create,
products/update, and inventory_levels/update. Each webhook maps to one of the flows above.
Two rules keep this reliable:
- Verify the HMAC signature on every incoming Shopify webhook before acting on it, so you only process genuine events.
- Treat webhooks as at-least-once. Shopify can deliver the same event more than once, so 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 Shopify 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 Shopify's inventory API, and let
the external-id mapping tie the Odoo variant to the Shopify inventory item.
A realistic scope note
This is a build-it-yourself pattern, not a turnkey app. For a standard store, evaluate Odoo's official
Shopify 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 with 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.
- For event-driven flows out of Odoo, see The Odoo webhook API.
- Read the full request and response contract in the API reference, or skip the envelope boilerplate with the Python SDK.