of Truth
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.
Why the numbers never match
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.
Different inventory definitions per platform
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.
Asynchronous sync — orders outrun the update
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.
FBA in-transit inventory counted (or not)
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.
Multi-location confusion in the ERP
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.
Returns not reconciled in real time
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.
Bundle / kit explosion inconsistency
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.
Platform-by-platform breakdown
Amazon — the most complex inventory model
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 — deceptively simple, with a catch
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 — accurate but slow and location-aware
NetSuite is usually the most accurate ledger of inventory — when it's kept up to date. The key integration traps:
- Location-level sync: Never push the company-total quantity to a single-location channel. Query by location (
inventoryLocation) and push only the correct location'squantityAvailable. - Committed quantity: NetSuite's
quantityOnHandincludes committed stock. UsequantityAvailable(on hand minus committed) for channel updates. - Saved search latency: NetSuite's SuiteScript or REST API reads can have 5–15 minute lag on saved searches during peak hours. Build your sync to handle this gracefully.
Odoo — flexible but requires location discipline
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
How to fix it — the sync strategy
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.
Define your single source of truth — and enforce 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.
Establish your safety buffer per channel
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 |
Build event-driven updates alongside scheduled sync
A sync job that runs every 15 minutes is not enough for high-velocity SKUs. You need two complementary mechanisms:
- Scheduled pull: Every 15 minutes, pull fresh quantities from your ERP and push to all channels. This is your baseline.
- Event-driven push: When an order is placed on any channel, immediately decrement a running "committed" counter and push the updated available quantity to all other channels. This closes the 15-minute gap.
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)
Handle FBA inventory separately from FBM and other channels
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:
- FBA fulfillable qty is read-only from your perspective — Amazon manages it
- FBA inventory should feed into your ERP as a separate location (e.g., "Amazon FBA — US East")
- Never push your ERP's FBA location qty back to Amazon — you'll create a circular loop
- Only FBM (Fulfilled by Merchant) listings on Amazon need your inventory push
- FBA returns become
unfulfillable— create a daily reconciliation job to identify units that have transitioned from unfulfillable back to fulfillable after Amazon's inspection
Build a daily reconciliation report
No 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)
Sync patterns that create more problems
Bidirectional sync
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.
Using Amazon total qty instead of fulfillable
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.
Syncing all ERP locations as one number
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.
No buffer on fast-moving SKUs
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.
Ignoring bundle component depletion
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.
Treating sync failures as silent non-events
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.
Inventory sync health checklist
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.
🏗️ Architecture
- Single system designated as master inventory — ERP if you have one, otherwise one Shopify location
- Sync is strictly unidirectional: master → channels only. No channel writes back to master.
- FBA inventory tracked as a separate location in the ERP — not included in sellable qty for non-FBA channels
- Bundle / kit SKUs have explicit component depletion logic — every bundle sale reduces component availability across all channels
- Multi-warehouse ERP: sync pushes per-location qty, not company total
⚡ Sync Configuration
- Scheduled sync runs at least every 15 minutes for any SKU with > 5 units/day velocity
- Event-driven update fires within 60 seconds of any order webhook from any channel
- Safety buffer defined and enforced per SKU (never push 100% of available qty)
- Amazon: using
fulfillablequantity only — nottotal - NetSuite: using
quantityAvailable— notquantityOnHand - Odoo: using
qty_on_hand − reserved_quantityscoped to the selling warehouse - Shopify "continue selling when out of stock" is disabled for all SKUs where you can't honor oversells
🔔 Monitoring
- Alert fires when any sync update fails — not just logged, actually alerted (Slack, PagerDuty, email)
- Daily reconciliation report running and reviewed by ops team
- Threshold for reconciliation alert defined (> 5% variance triggers review)
- Amazon unfulfillable inventory reviewed weekly — units may be restorable after inspection
- Rate limit handling implemented — sync retries with exponential backoff, does not silently drop failed updates
🔁 Returns & Adjustments
- Amazon returns process defined: unfulfillable → inspection → fulfillable or write-off, with ERP adjustment at each stage
- Shopify returns automatically trigger ERP inventory adjustment via webhook, not manual entry
- Manual inventory adjustments in any channel are logged and the ERP is updated within 24 hours
- Shrinkage, damage, and cycle count adjustments originate in the ERP and flow out to channels — not the reverse
Inventory drift is fixable
Every 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.
Molten Logistics — Your trusted partner in Logistics & E-commerce
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 →