WooCommerce ERP Integration: Architecture and Failure Modes
WooCommerce ERP integration explained: what to sync, who owns each field, connector vs middleware vs custom, HPOS, webhooks and stock without overselling.
Long Nguyen
Lập trình viên Fullstack · Kỹ sư AI · Nhà nghiên cứu
What a WooCommerce ERP integration actually moves
WooCommerce ERP integration is the automated exchange of orders, stock levels, product data, prices, customers and financial documents between a WooCommerce store and an ERP, so nobody re-types an order or corrects a stock count by hand. The useful way to think about it is as a set of separate data flows, each with its own direction, owner and trigger, rather than one big sync.
| Data | Usual direction | System of record | Typical trigger |
|---|---|---|---|
| Orders | WooCommerce → ERP | WooCommerce until the ERP accepts the order, then the ERP | Order paid or moved to processing |
| Order status and tracking | ERP → WooCommerce | ERP (fulfilment) | Shipment validated in the ERP |
| Stock levels | ERP → WooCommerce | ERP | Stock movement, plus a scheduled check |
| Product master data (SKU, cost, weight, variants) | ERP → WooCommerce | ERP | Product created or changed |
| Product content (descriptions, images, SEO copy) | Stays in WooCommerce | WooCommerce | Not synced |
| Prices | ERP → WooCommerce | ERP for list price, WooCommerce for campaign discounts | Scheduled or on price-list change |
| Customers | WooCommerce → ERP | ERP for the billing partner, WooCommerce for the login | First order |
| Refunds and credit notes | Both ways | ERP for accounting | Refund created |
| Invoices and documents | ERP → WooCommerce | ERP | Invoice posted |
Two rows deserve a warning. Product content should normally stay in WooCommerce: an ERP holds accounting-grade data, not merchandising copy, and pushing descriptions through it only slows every edit. Customers are the messiest row, because WooCommerce holds a login, the ERP holds a billing partner, and one real person can be both, neither, or three duplicate records.
Decide who owns each field before writing code
Almost every integration that fails in production fails for the same reason: two systems both believe they own a field. Write the ownership rule down for each row of the table above, then make the non-owner unable to overwrite it.
- SKU is the join key. Match products on SKU (in Odoo, the internal reference), never on product name or on WooCommerce's numeric product ID. Keep the other system's ID as a secondary reference for fast lookups.
- Stamp every order in both directions. Put the WooCommerce order number on the ERP order as an external reference, and write the ERP document number back to the WooCommerce order. That single habit gives you idempotency (skip what is already imported) and lets support trace a customer question in one lookup.
- Variations carry the stock. For variable products, SKU and stock live on the variation, so map ERP variants to WooCommerce variation IDs, not to the parent product.
- Pick the hand-off point. WooCommerce owns an order until payment clears and the ERP accepts it; after that the ERP owns fulfilment and WooCommerce only displays status. Edits after the hand-off (address changes, cancellations, partial refunds) need an explicit rule, because that is where the two copies drift apart.
Connector, middleware or custom integration: how to choose
There are three ways to connect the two systems, and none is universally right.
| Approach | Fits when | Stops fitting when | Who carries maintenance |
|---|---|---|---|
| Off-the-shelf connector (plugin or ERP vendor module) | Standard order-to-cash, one warehouse, clean SKUs | You need customer-specific price lists, bundles, split shipments or a field the connector does not map | The plugin vendor, but you carry the upgrade risk when WooCommerce or the ERP changes |
| Middleware or iPaaS (a hosted workflow tool between the two) | Several systems to connect, non-developers editing flows, moderate volume | Many are priced per task, so cost follows order volume; complex conditional logic turns into unreadable flow diagrams | You maintain the flows; the vendor maintains the platform |
| Custom integration service | Unusual rules, high peak volume, several channels feeding one ERP, or a need for audit-grade logs | Nobody can maintain code, or requirements are still unclear | You or your developer: code, monitoring and upgrades |
Our rule of thumb: start with a connector when your ERP vendor supports WooCommerce and your process is standard order-to-cash. Move to custom the moment a business rule lives in someone's head, such as customer-specific pricing, kits sold as single SKUs, split shipments, or a tax treatment the connector cannot express. The real cost of a connector is rarely the licence; it is the workaround you build around its missing rule.
If you land on custom, the work is mostly the unglamorous middle: idempotent order import, field mapping, retries and monitoring. Netalith builds these against the WooCommerce REST API and ERP APIs; see WooCommerce and store platform API integration for how that work is scoped.
The plumbing: REST API, webhooks and a reconciliation job
WooCommerce exposes orders, products, customers and coupons through its REST API (version wc/v3) and can notify you of changes through webhooks. A sound integration uses both, for different jobs.
- Authentication. Generate a key pair under WooCommerce › Settings › Advanced › REST API, choose the user the key acts as, and send the consumer key and secret as the basic-auth username and password. HTTPS is recommended. Use a dedicated user for the integration so its permissions and audit trail are separate from any person.
- Pagination. List requests return 10 items by default. Read the X-WP-TotalPages response header and loop, otherwise your import silently sees only the first page. You can raise per_page, up to 100 on WordPress.
- Incremental reads. Filter with modified_after instead of re-reading everything, and store the newest modified date you actually processed rather than the time the job started.
- Webhooks. Topics cover created, updated and deleted events for orders, products, coupons and customers. Give each webhook a secret: WooCommerce uses it to sign the payload (the X-WC-Webhook-Signature header) so your receiver can verify the request really came from your store.
The rule that bites is in WooCommerce's webhook documentation: a webhook is disabled automatically after more than five consecutive delivery failures, and any response that is not a 2xx, 301 or 302 counts as a failure. If your receiver is down during a deploy, or returns errors while its database is busy, a short run of failed deliveries switches the webhook off. Orders keep arriving in WooCommerce, nothing reaches the ERP, and no one is told.
Design around that with four habits:
- Keep the receiver dumb. It verifies the signature, writes the payload to a queue and returns 200. All ERP work happens in a worker, so a slow ERP never causes failed deliveries.
- Run a reconciliation job. Every few minutes, pull orders modified since the last run and import anything the webhook missed. Because the import is idempotent, overlap is harmless.
- Watch webhook status. Read each webhook's status through the REST API and alert if it is anything other than active.
- Read the delivery log. WooCommerce › Status › Logs, filtered to the webhooks-delivery source, shows what your endpoint answered.
Treat the webhook as a hint that something changed, and the reconciliation job as the source of correctness.
Syncing inventory without overselling
Overselling is what the business notices first, and it is almost always a design error rather than a bug. Four rules prevent most of it.
- Publish available-to-sell, not on-hand. WooCommerce holds one quantity per product or variation; the ERP has several (on hand, reserved, incoming, per warehouse). Decide which combination the storefront should show, subtract a safety buffer for fast movers, and calculate it in the integration rather than in the store.
- Push absolute quantities, never deltas. A delta such as sold 2 applied twice after a retry corrupts stock for good; an absolute value such as 12 available applied twice is harmless.
- Mind the ordering. WooCommerce lowers stock itself as orders arrive, so for a moment it is ahead of the ERP. An absolute push calculated before a just-placed order reached the ERP resurrects stock that is already sold. Either import and reserve orders before publishing stock, or subtract orders still waiting to be imported.
- Batch and throttle. Use the batch endpoints instead of one request per SKU, keep batches small enough to finish inside the server's PHP time limit, and send only products whose quantity actually changed.
HPOS: why integrations must stop reading order tables directly
Since WooCommerce 8.2 (October 2023), High-Performance Order Storage is the default for new stores. Orders live in dedicated tables (wp_wc_orders, wp_wc_order_addresses, wp_wc_order_operational_data and wp_wc_orders_meta) instead of wp_posts and wp_postmeta. For an ERP integration that changes three things.
- Do not query orders from the database. A script or connector that reads shop_order rows from wp_posts is reading, at best, the backup copy: WooCommerce's HPOS documentation describes the posts tables as the backup tables, which only receive data while synchronisation is on. Go through the REST API, or in PHP through wc_get_order and wc_get_orders, which work under either storage mode.
- Check plugin compatibility. A plugin that does not declare HPOS support disables the HPOS option under Settings › Advanced › Features. The documented fallback is switching back to posts storage with compatibility mode on, which is a stopgap. Ask the connector's vendor for an HPOS-compatible release instead of freezing the store on legacy storage.
- Write order meta through the order object. Custom fields your integration stores, such as the ERP document number, should be written with the order's own meta methods so they land in the right table.
Example: pushing WooCommerce orders into Odoo over JSON-2
Odoo is a good worked example because its external API changed recently. On Odoo 19 and later the supported route is the External JSON-2 API: you POST a JSON body to /json/2/<model>/<method> with an API key as the bearer token. It does not exist on Odoo 16 to 18, where integrations still use XML-RPC or JSON-RPC. Four properties of Odoo's External JSON-2 API reference shape how you write the integration:
- Named arguments only. JSON-2 has no positional arguments; everything goes in the JSON body, plus optional ids and context.
- One call, one transaction. Each call commits or rolls back on its own, and you cannot chain several calls into one transaction. Odoo's advice is to call a single method that does the related work together, which is why the sketch below creates an order and its lines in one create call.
- Permissions follow the API user. Access rights and record rules of the key's user apply. For integrations, Odoo recommends a dedicated bot user with minimum permissions rather than a person's account.
- Keys expire. Odoo requires a duration when a key is created, and long-lived keys cannot last more than three months. Put key rotation on a calendar or the integration will start returning 401 on a day nobody chose.
Here is the shape of an order import, as a sketch rather than production code:
import requests
from woocommerce import API
wc = API(url='https://shop.example.com', consumer_key=WC_KEY, consumer_secret=WC_SECRET, wp_api=True, version='wc/v3', timeout=30)
ODOO = 'https://erp.example.com/json/2'
HEADERS = {'Authorization': 'bearer ' + ODOO_API_KEY, 'X-Odoo-Database': 'mycompany'}
def odoo(model, method, **body):
r = requests.post(f'{ODOO}/{model}/{method}', headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json()
def orders_since(since):
page = 1
while True:
resp = wc.get('orders', params={'status': 'processing', 'modified_after': since, 'per_page': 100, 'page': page})
resp.raise_for_status()
yield from resp.json()
if page >= int(resp.headers['X-WP-TotalPages']):
return
page += 1
def import_order(o):
ref = 'WC-' + str(o['id'])
if odoo('sale.order', 'search', domain=[['client_order_ref', '=', ref]], limit=1):
return # already imported
email = o['billing']['email']
found = odoo('res.partner', 'search', domain=[['email', '=', email]], limit=1)
name = o['billing']['first_name'] + ' ' + o['billing']['last_name']
partner = found[0] if found else odoo('res.partner', 'create', vals_list=[{'name': name, 'email': email}])[0]
lines = []
for item in o['line_items']:
product = odoo('product.product', 'search', domain=[['default_code', '=', item['sku']]], limit=1)
if not product:
raise LookupError('Unmapped SKU: ' + item['sku'])
lines.append([0, 0, {'product_id': product[0], 'product_uom_qty': item['quantity'], 'price_unit': float(item['price'])}])
order_id = odoo('sale.order', 'create', vals_list=[{'partner_id': partner, 'client_order_ref': ref, 'order_line': lines}])[0]
odoo('sale.order', 'action_confirm', ids=[order_id])
What the sketch leaves out is where the real work lives: taxes, discounts, shipping lines, payment registration, refunds, retries, and a dead-letter path for unmapped SKUs. It also has a gap that the one-transaction rule predicts. The duplicate check and the create are separate transactions, so two workers could import the same order at once. Run a single worker per order stream, or enforce uniqueness of the client reference in the database with a small custom module.
One date is worth getting right. Older versions of Odoo's documentation scheduled removal of the XML-RPC and JSON-RPC endpoints for Odoo 20 (fall 2026). The current 19.0 documentation says both are scheduled for removal in Odoo 22 (fall 2028), and Odoo's External RPC reference adds Odoo Online 21.1 (winter 2027) for hosted databases, with JSON-2 as the replacement. Odoo has moved this date once already, so check the live page before you commit a migration budget. Also note that on Odoo's hosted plans, external API access is only available on Custom plans, not on One App Free or Standard.
Failure modes that keep recurring in production
| Symptom | Usual cause | Fix |
|---|---|---|
| Orders stop reaching the ERP while the store looks healthy | Webhook auto-disabled after more than five consecutive failed deliveries | Fast-acknowledge receiver with a queue; alert on webhook status; reconciliation job |
| Only the newest 10 orders import | Pagination ignored; lists default to 10 items per page | Loop on X-WP-TotalPages; request per_page up to 100 |
| Duplicate sales orders in the ERP | Retried webhook or overlapping reconciliation run without idempotency | External-reference check, plus a uniqueness constraint in the ERP database |
| Stock drifts by a few units every week | Delta pushes, or an absolute push calculated before a new order was imported | Push absolute available-to-sell; import and reserve orders before publishing stock |
| Totals differ by a few cents between store and ERP | Tax and rounding calculated independently in both systems (WooCommerce returns money as strings with two decimals) | Decide which system owns tax; import WooCommerce totals as the truth or reproduce its rounding rule; reconcile per order |
| Integration suddenly returns 401 | Expired ERP API key, or a revoked WooCommerce key or user | Key-rotation calendar; dedicated bot users; alert on any 401 |
| Order reports go empty after enabling HPOS | Direct SQL against wp_posts | Use the REST API or wc_get_orders |
| One bad SKU blocks the whole queue | Endless retry on an unmapped product | Dead-letter queue with an alert; carry on with the remaining orders |
Seven questions to answer before you build or buy
- Which system owns each field in the data-flow table, and who resolves a conflict?
- What is the SKU strategy, including variations and bundles?
- At which order status does the ERP take over, and what happens to edits, cancellations and refunds afterwards?
- What order volume do you expect at peak (a sale or a campaign), not on an average day?
- Which tax, price-list and warehouse rules exist only in someone's head?
- Who is alerted when the integration stops, and how fast do they need to know?
- Will your ERP version and its API stay supported for the life of the project (for example, Odoo 19 or later for JSON-2)?
If you can answer most of these, you can scope an integration. If you cannot, working them out is the useful first deliverable. Describe your WooCommerce store, your ERP and the workflow on Netalith's free quote form, which covers all our services, costs nothing and needs no account.
CÂU HỎI THƯỜNG GẶP
Câu hỏi thường gặp
What is WooCommerce ERP integration?
It is the automated exchange of orders, stock levels, product data, prices, customers and financial documents between a WooCommerce store and an ERP, so orders and stock are never re-entered by hand. In practice it is several separate data flows, each with its own direction, owner and trigger.
Which data should sync between WooCommerce and an ERP?
Orders normally flow from WooCommerce to the ERP. Stock levels, product master data, prices, shipment tracking and invoices flow back from the ERP. Product content such as descriptions, images and SEO copy usually stays in WooCommerce. Write down which system owns each field before you build anything.
Should I use a plugin, middleware or a custom integration?
Use an off-the-shelf connector for standard order-to-cash with one warehouse and clean SKUs. Consider middleware when several systems are involved and non-developers will edit the flows. Choose a custom integration when you have unusual pricing, bundles, split shipments, high peak volume or a need for audit-grade logs.
Should WooCommerce and the ERP sync in real time or on a schedule?
Use both. Webhooks give you near-real-time updates, but WooCommerce disables a webhook after more than five consecutive failed deliveries, so a scheduled reconciliation job that pulls recently modified orders is what guarantees nothing is missed.
Does HPOS affect my ERP integration?
Yes, if anything reads orders directly from the database. Since WooCommerce 8.2 HPOS is the default for new stores and orders live in dedicated tables rather than wp_posts. Integrations should use the REST API or WooCommerce order functions such as wc_get_order instead of querying tables.
Can WooCommerce integrate with Odoo, and is XML-RPC still safe to use?
Yes. Orders can be read through the WooCommerce REST API and written to Odoo through its external API. On Odoo 19 and later use the JSON-2 API; XML-RPC and JSON-RPC are deprecated, and Odoo's current documentation schedules their removal for Odoo 22 (fall 2028) and Odoo Online 21.1 (winter 2027). Odoo 16 to 18 still use the RPC endpoints, so check the live documentation for your version.