Running an e-commerce business on Shopify while managing back-office operations in Odoo Community Edition is a common setup — and a powerful one. Shopify handles the storefront, payments, and customer-facing experience. Odoo handles inventory, purchasing, accounting, and fulfillment. The problem is getting these two systems to talk to each other reliably, without paying for an expensive middleware connector that may not fit your exact workflow.
In this guide, we walk through building a production-grade integration between Odoo 17 CE and Shopify using their respective REST APIs and a lightweight Python synchronization service. We have deployed this pattern for multiple Laniakea clients, and the approach scales well from a few hundred SKUs to tens of thousands.
Why Build a Custom Integration?
There are off-the-shelf Odoo-Shopify connectors available — some paid Odoo apps, some third-party SaaS platforms. They work well for straightforward setups. But we consistently see three scenarios where a custom integration is worth the investment.
First, non-standard product mappings. If your Odoo product variants don't map one-to-one with Shopify variants — for example, you sell bundles in Shopify that correspond to multiple BOMs in Odoo — off-the-shelf connectors break down. Second, inventory source complexity. If you have multiple warehouses in Odoo feeding a single Shopify store, or a dropship workflow where certain products never touch your warehouse, the sync logic gets specific to your business. Third, order routing rules. When incoming Shopify orders need to be routed to different Odoo sales teams, companies, or fulfillment workflows based on tags, shipping region, or product category, you need custom logic.
Key insight: The best Odoo-Shopify integrations aren't bidirectional mirrors — they're event-driven pipelines where each system owns specific data. Shopify owns orders and customer data. Odoo owns inventory levels, pricing rules, and fulfillment status. Defining clear data ownership up front prevents the sync conflicts that plague most integrations.
Architecture Overview
The integration consists of three components: a webhook receiver that listens for Shopify events, a scheduled sync service that pushes Odoo data to Shopify on a cadence, and a reconciliation job that catches anything the other two miss.
We deploy this as a small Python service running on AWS ECS Fargate (or a simple EC2 instance for smaller clients). It connects to Odoo via XML-RPC and to Shopify via their Admin REST API. Here is the data flow for the two most critical sync paths:
Shopify → Odoo: Order Ingestion
When a customer places an order on Shopify, a webhook fires and hits our receiver. The service creates a sales order in Odoo, maps the Shopify line items to Odoo products using a SKU lookup table, and attaches shipping and customer information. If the customer already exists in Odoo (matched by email), we link to the existing partner record rather than creating a duplicate.
Odoo → Shopify: Inventory and Product Sync
Every 15 minutes, a scheduled job queries Odoo for products whose write_date has changed since the last sync. It pushes updated quantities to the Shopify Inventory API and updates product titles, descriptions, and prices if they have diverged. This cadence is configurable — some clients need near-real-time inventory updates and run it every 2 minutes; others are fine with hourly syncs.
Setting Up the Shopify API Connection
Start by creating a custom app in your Shopify admin panel under Settings → Apps and sales channels → Develop apps. Grant it the following scopes: read_products, write_products, read_orders, write_inventory, read_inventory, and read_locations. Note the Admin API access token — you will need this for every API call.
Here is a minimal Python client for Shopify's REST API:
import requests
class ShopifyClient:
def __init__(self, shop_domain, access_token, api_version="2025-01"):
self.base_url = f"https://{shop_domain}/admin/api/{api_version}"
self.headers = {
"X-Shopify-Access-Token": access_token,
"Content-Type": "application/json",
}
def get_products(self, since_id=0, limit=250):
"""Fetch products with cursor-based pagination."""
url = f"{self.base_url}/products.json"
params = {"limit": limit, "since_id": since_id}
resp = requests.get(url, headers=self.headers, params=params)
resp.raise_for_status()
return resp.json()["products"]
def update_inventory_level(self, inventory_item_id, location_id, available):
"""Set absolute inventory quantity at a location."""
url = f"{self.base_url}/inventory_levels/set.json"
payload = {
"location_id": location_id,
"inventory_item_id": inventory_item_id,
"available": available,
}
resp = requests.post(url, headers=self.headers, json=payload)
resp.raise_for_status()
return resp.json()
Connecting to Odoo via XML-RPC
Odoo Community Edition exposes an XML-RPC interface at /xmlrpc/2/common (for authentication) and /xmlrpc/2/object (for CRUD operations). This is stable, well-documented, and works with every Odoo CE version from 14 through 17.
import xmlrpc.client
class OdooClient:
def __init__(self, url, db, username, password):
self.url = url
self.db = db
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
self.uid = common.authenticate(db, username, password, {})
self.models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
self.password = password
def search_read(self, model, domain, fields, limit=0):
return self.models.execute_kw(
self.db, self.uid, self.password,
model, "search_read",
[domain],
{"fields": fields, "limit": limit},
)
def create_sale_order(self, partner_id, order_lines, shopify_ref):
"""Create an SO with line items mapped from Shopify."""
order_id = self.models.execute_kw(
self.db, self.uid, self.password,
"sale.order", "create",
[{
"partner_id": partner_id,
"client_order_ref": shopify_ref,
"order_line": [
(0, 0, {
"product_id": line["product_id"],
"product_uom_qty": line["quantity"],
"price_unit": line["price"],
})
for line in order_lines
],
}],
)
return order_id
The SKU Mapping Table
The single most important piece of this integration is the mapping between Shopify variant IDs and Odoo product IDs. We maintain this as a simple database table (or even a CSV file for very small catalogs) with three columns: shopify_variant_id, odoo_product_id, and sku.
When a new product is created in Odoo, the sync service checks if a matching SKU exists in Shopify. If it does, the mapping is created automatically. If not, it either creates the product in Shopify or flags it for manual review, depending on your configuration. We strongly recommend using Odoo's default_code field as the canonical SKU and ensuring it matches the Shopify variant SKU exactly. This avoids an entire category of sync bugs.
Handling Webhooks Reliably
Shopify webhooks are not guaranteed to arrive exactly once — they can be duplicated or delayed. Your webhook receiver needs to be idempotent. The pattern we use is straightforward: hash the Shopify order ID, check if we have already processed it, and skip if so.
from flask import Flask, request, jsonify
import hashlib, hmac
app = Flask(__name__)
SHOPIFY_WEBHOOK_SECRET = "your_webhook_secret"
processed_orders = set() # In production, use Redis or a DB table
def verify_webhook(data, hmac_header):
digest = hmac.new(
SHOPIFY_WEBHOOK_SECRET.encode("utf-8"),
data,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(digest, hmac_header)
@app.route("/webhooks/orders/create", methods=["POST"])
def order_created():
hmac_header = request.headers.get("X-Shopify-Hmac-Sha256", "")
if not verify_webhook(request.data, hmac_header):
return jsonify({"error": "unauthorized"}), 401
order = request.json
order_id = str(order["id"])
if order_id in processed_orders:
return jsonify({"status": "duplicate"}), 200
processed_orders.add(order_id)
# Queue for async processing — don't block the webhook response
process_order_async(order)
return jsonify({"status": "accepted"}), 200
Production tip: Never do heavy processing inside the webhook handler itself. Accept the payload, validate the HMAC signature, store the raw event in a queue (SQS, Redis, or even a database table), and return a 200 immediately. Process the event asynchronously. Shopify will retry webhooks that don't return 2xx within 5 seconds, and you do not want duplicate order creation because your handler was slow.
Inventory Sync: Getting the Numbers Right
Inventory sync is where most integrations get into trouble. The core issue is that Shopify and Odoo compute "available" inventory differently. Odoo's qty_available includes stock that might be reserved for existing sales orders. What you usually want to push to Shopify is the free quantity — stock on hand minus outgoing reservations.
In Odoo 17, the field you want is free_qty on the product.product model. For multi-warehouse setups, query the stock.quant model filtered by location to get per-warehouse availability, then sum across the locations that feed your Shopify store.
# Fetch free quantity for all products with a Shopify mapping
products = odoo.search_read(
"product.product",
[("default_code", "!=", False), ("free_qty", ">", -1)],
["id", "default_code", "free_qty"],
)
for product in products:
mapping = sku_map.get(product["default_code"])
if mapping:
shopify.update_inventory_level(
inventory_item_id=mapping["shopify_inventory_item_id"],
location_id=SHOPIFY_LOCATION_ID,
available=max(0, int(product["free_qty"])),
)
The max(0, ...) is important. Odoo can report negative free quantities when stock is oversold or when incoming shipments haven't been received yet. Shopify does not accept negative inventory levels, and pushing a negative number will cause API errors.
Error Handling and Reconciliation
No integration is perfect. Network blips, API rate limits, and transient errors will happen. Build a reconciliation job that runs daily (we schedule ours at 2 AM) and compares the full product catalog and open order list between both systems. Any discrepancies get logged to a Slack channel or email alert so the operations team can investigate.
For Shopify API rate limits, implement exponential backoff with a maximum of 3 retries. Shopify's REST API allows 2 requests per second for most endpoints on standard plans. If you are syncing thousands of products, batch your updates and respect the X-Shopify-Shop-Api-Call-Limit header.
Deployment and Monitoring
We deploy the sync service as a Docker container on ECS Fargate with the following structure: one container runs the Flask webhook receiver behind an ALB, and a second container runs the scheduled sync jobs using APScheduler or a simple cron inside the container. Both containers share the same codebase and configuration, pulled from AWS Secrets Manager.
For monitoring, we track four key metrics: orders synced per hour, inventory updates pushed per cycle, error rate by endpoint, and sync lag (time between a Shopify event and the corresponding Odoo record creation). CloudWatch alarms fire if the error rate exceeds 2% or sync lag exceeds 10 minutes.
Lessons from Production
After running this pattern across several client deployments, here are the lessons that keep coming up. Test with real order volumes. A sync that works with 10 test orders can fall apart at 500 orders per day due to API rate limits you never hit in staging. Log everything. Every API call, both request and response, should be logged with a correlation ID that ties the Shopify event to the Odoo record. When something goes wrong three days later, these logs are the only way to reconstruct what happened. Version your SKU mapping. When products change in either system, keep a history of which Shopify variant mapped to which Odoo product at what time. This is essential for debugging order discrepancies after the fact.
The Odoo-Shopify integration is not a set-it-and-forget-it project. Business rules change, new product categories get added, and Shopify periodically updates their API versions. Budget time for ongoing maintenance — typically 4 to 8 hours per month for a mid-size catalog.