You sell the same product on Amazon, Shopify, and Mercado Libre. Your ERP — whether it's NetSuite or Odoo — is supposed to be the source of truth. But log into each system and you'll see five different numbers for the same SKU. One says 138, another says 155, your ERP says 161. Which one do you trust? And more importantly — which one is Amazon about to delist you for?
This is one of the most common operational problems we solve at Molten Logistics. It's not a bug. It's not a coincidence. It's the predictable result of how each platform counts inventory — and the mismatched sync patterns between them. This guide explains exactly why it happens, platform by platform, and gives you a concrete fix strategy.
Each platform has its own definition of "inventory on hand" — and they're genuinely different, not just synced poorly. Before fixing the problem, you need to understand what each number actually means.
All three numbers can be simultaneously correct — within each platform's own definition. The problem is that your team, your 3PL, and your customers are all seeing different numbers and acting on them differently.
Amazon's "fulfillable" count excludes units in FC transfer, stranded inventory, and reserved stock. Shopify's "available" count excludes committed (in open orders). NetSuite's "on hand" includes every unit in every location regardless of reservation status. They're measuring different things with the same word.
A customer buys on Amazon at 2:04 PM. Your sync job runs every 15 minutes. At 2:05 PM your Shopify still shows the pre-sale quantity. During that 10-minute window, another customer buys the same unit on Shopify. You've oversold. The sync wasn't wrong — it was just slow.
When you send a replenishment shipment to Amazon FBA, those units are "in transit" to the FC. Amazon's API returns them as unavailable. Your ERP may have already deducted them from warehouse stock. Now both systems show fewer units than you actually own — total inventory appears to vanish for days.
NetSuite and Odoo track inventory by location. If you have units at your warehouse, at a 3PL, and at Amazon FBA, the ERP total is the sum of all locations. But only the warehouse and 3PL units are actually available for non-FBA channels. Using the total ERP number to update Shopify leads to immediate overselling.
Amazon processes a return. The unit goes to "unfulfillable" — not back to fulfillable — until Amazon inspects it (which can take 10–45 days). Your ERP may credit the return immediately. Now the ERP shows more units than Amazon will ever sell. Meanwhile the unit may be damaged and never return to stock.
You sell a bundle of 3 units on Shopify. Each component is also sold individually on Amazon. Your ERP tracks components; Amazon and Shopify track finished bundles. Unless your sync layer explicitly handles kit explosion and implosion, each sale of the bundle on Shopify silently reduces component availability without updating Amazon.
Amazon's inventory API returns multiple quantity types that must be understood before you can build a sync:
| Amazon Quantity Type | What it means | Include in sync? |
|---|---|---|
fulfillable | Units Amazon can ship right now | ✓ Yes — this is your sellable stock |
inbound_working | Shipments you've created but not shipped | ✗ Not available yet |
inbound_shipped | Shipments in transit to FC | ✗ Not available yet |
inbound_receiving | At the FC, being checked in | ✗ Not available yet |
reserved_customerorders | Sold but not yet shipped | ✗ Already committed |
reserved_fc-transfers | Moving between FCs | ✗ Temporarily unavailable |
unfulfillable | Damaged, expired, or pending inspection | ✗ Do not count |
researching | Lost or being investigated | ✗ Do not count |
total (the sum of all types) to update your ERP or other channels. This will inflate your available count by hundreds of units and guarantee overselling. Always use fulfillable only for your sellable quantity.
Shopify's inventory model is cleaner but has its own trap. Each SKU has an inventory_quantity (what's shown) and a incoming count (on the way). The inventory_quantity Shopify shows in-store is already net of committed units for open orders — but only if you have "continue selling when out of stock" disabled.
If that setting is enabled, Shopify will happily sell into negative inventory and never fire a webhook to warn you. You find out when you can't fulfill.
NetSuite is usually the most accurate ledger of inventory — when it's kept up to date. The key integration traps:
inventoryLocation) and push only the correct location's quantityAvailable.quantityOnHand includes committed stock. Use quantityAvailable (on hand minus committed) for channel updates.Odoo's inventory tracks qty_on_hand, virtual_available (on hand + incoming − outgoing), and qty_available (immediately available). For channel sync, use qty_available filtered by the specific warehouse location you're selling from.
def get_odoo_available_qty(sku: str, warehouse_id: int) -> float: models, uid = odoo_connect() # Get the product first products = models.execute_kw(ODOO_DB, uid, ODOO_KEY, "product.product", "search_read", [[("default_code", "=", sku)]], {"fields": ["id", "name"], "limit": 1} ) if not products: raise ValueError(f"SKU not found in Odoo: {sku}") product_id = products[0]["id"] # Read qty_available at the specific warehouse location # Use with_context to scope to the warehouse location quant = models.execute_kw(ODOO_DB, uid, ODOO_KEY, "stock.quant", "search_read", [[ ("product_id", "=", product_id), ("location_id.usage", "=", "internal"), ("location_id.warehouse_id", "=", warehouse_id), ]], {"fields": ["quantity", "reserved_quantity", "location_id"]} ) total_on_hand = sum(q["quantity"] for q in quant) total_reserved = sum(q["reserved_quantity"] for q in quant) return max(0, total_on_hand - total_reserved) # never push negative
The solution is a single source of truth with a unidirectional sync pattern. Your ERP (NetSuite or Odoo) is the master. Channels (Amazon, Shopify, Mercado Libre) are subscribers. Nothing updates the ERP except warehouse operations. Everything else reads from it.
Pick one system to own inventory. For operations with an ERP, that's always your ERP. Your ERP is updated by warehouse receiving, sales orders, adjustments, and returns. No channel should ever write inventory back to the ERP — they should only read from it.
The only exception: Amazon FBA returns that need to be reconciled as adjustments, and this should go through a manual or semi-automated review step, not an automatic write-back.
Never push your full available quantity to any channel. Always reserve a safety buffer — a percentage or fixed unit count — that absorbs sync lag. The right buffer depends on your sales velocity and sync frequency:
| Sync frequency | Daily sales velocity | Recommended buffer |
|---|---|---|
| Every 15 min | < 10 units/day | 2–3 units or 5% |
| Every 15 min | 10–50 units/day | 5–8 units or 8% |
| Every 15 min | > 50 units/day | 10–15 units or 10% |
| Every 60 min | Any velocity | Minimum 15% — consider faster sync |
| Daily batch | Any velocity | Not recommended for live channels |
A sync job that runs every 15 minutes is not enough for high-velocity SKUs. You need two complementary mechanisms:
import threading from dataclasses import dataclass # In-memory committed counter — backed by Redis in production committed_qty: dict[str, int] = {} committed_lock = threading.Lock() def on_order_placed(sku: str, qty_sold: int, source_channel: str): """ Called immediately when any channel fires an order webhook. Decrements local committed counter and pushes to all other channels. """ with committed_lock: committed_qty[sku] = committed_qty.get(sku, 0) + qty_sold # Get current ERP available qty erp_available = get_odoo_available_qty(sku, warehouse_id=1) # Subtract committed (sales not yet reflected in ERP) effective_qty = max(0, erp_available - committed_qty.get(sku, 0)) # Apply safety buffer SAFETY_BUFFER = get_safety_buffer(sku) push_qty = max(0, effective_qty - SAFETY_BUFFER) # Push to all channels except the one that just sold channels = {"amazon", "shopify", "mercadolibre"} - {source_channel} for channel in channels: update_channel_inventory(channel=channel, sku=sku, qty=push_qty) def update_channel_inventory(channel: str, sku: str, qty: int): if channel == "shopify": shopify_set_inventory(sku=sku, qty=qty) elif channel == "amazon": # Amazon FBA quantity is managed by Amazon — only update FBM if is_fbm_sku(sku): amazon_update_fbm_qty(sku=sku, qty=qty) elif channel == "mercadolibre": meli_update_stock(sku=sku, qty=qty)
This is the most misunderstood aspect of multi-channel sync. Amazon FBA inventory is physically at Amazon's fulfillment centers — you cannot add to it or subtract from it the way you can with your own warehouse. The rules:
unfulfillable — create a daily reconciliation job to identify units that have transitioned from unfulfillable back to fulfillable after Amazon's inspectionNo real-time sync is perfect. You need a daily job that compares your ERP's authoritative count against every channel's reported quantity and flags deltas above your threshold. This is your early warning system — it catches drift before it becomes an oversell incident.
def daily_reconciliation_report() -> list[dict]: """ Compares ERP qty against each channel for every active SKU. Flags SKUs where any channel diverges by more than tolerance %. """ TOLERANCE_PCT = 0.05 # 5% variance is acceptable drift discrepancies = [] active_skus = get_all_active_skus() # from your ERP for sku in active_skus: erp_qty = get_odoo_available_qty(sku, warehouse_id=1) shopify_qty = shopify_get_inventory(sku) amazon_qty = amazon_get_fulfillable(sku) # FBM only meli_qty = meli_get_stock(sku) checks = { "shopify": shopify_qty, "amazon_fbm": amazon_qty, "mercadolibre":meli_qty, } for channel, channel_qty in checks.items(): if erp_qty == 0: continue variance = abs(erp_qty - channel_qty) / erp_qty if variance > TOLERANCE_PCT: discrepancies.append({ "sku": sku, "channel": channel, "erp_qty": erp_qty, "channel_qty": channel_qty, "variance_pct": round(variance * 100, 1), "delta": channel_qty - erp_qty, }) # Sort by severity (largest absolute delta first) return sorted(discrepancies, key=lambda x: abs(x["delta"]), reverse=True)
Letting channels write inventory back to the ERP. A return on Amazon updates the ERP, the ERP updates Shopify, Shopify fires a webhook that updates the ERP again. You end up with phantom inventory that multiplies with every sync cycle.
Pulling total from the Amazon inventory API and pushing it everywhere. This includes units in transit, reserved, and under investigation. Your actual sellable count is fulfillable only.
Summing inventory across all NetSuite or Odoo locations and pushing the total to Shopify. Units at your closed warehouse, a bonded warehouse, or in-transit to FBA are not available to ship from your main DC.
Pushing your exact available quantity to every channel simultaneously. A flash sale or a viral moment on any one channel will oversell you across all others before the next sync cycle runs.
A bundle of 3 units sells on Shopify. The sync updates the bundle SKU but not the component SKUs. Amazon is still showing full availability for the components, which are now overstated by 3 units each bundle sale.
A rate limit from Amazon's API causes a sync update to fail silently. The channel keeps its last-known quantity for hours. No alert fires. You discover the problem when customer service reports oversells the next morning.
Run through this list for every SKU that sells on more than one channel. If you can't check every box, you have an active oversell risk.
fulfillable quantity only — not totalquantityAvailable — not quantityOnHandqty_on_hand − reserved_quantity scoped to the selling warehouseEvery discrepancy in this post has a technical solution. The reason most operations don't fix it isn't that the engineering is too hard — it's that there's always a fire burning somewhere else. The inventory count problem stays in the background, quietly causing oversells, Amazon suspensions, and customer refunds, until it becomes the fire.
A properly implemented unidirectional sync with event-driven updates, per-channel safety buffers, and a daily reconciliation report will eliminate 95% of the discrepancy problems described in this guide. The remaining 5% — FBA return reconciliation, bundle kitting edge cases, and rate limit resilience — are operational refinements you add as you stabilize the core.
We've built inventory sync systems connecting Amazon, Shopify, Mercado Libre, eBay, NetSuite, and Odoo for operations across Mexico and LATAM. We've processed over 100,000 shipments with automated inventory management — and we know exactly where the edge cases live. Luis Alba and the team are ready to audit your current sync architecture and close the gaps.
Schedule a free consultation →