# Shopify ERP and inventory sync in Python

One source of truth, idempotent publishing with inventorySetQuantities and nightly reconciliation.

## Overview {#overview}

Overselling, phantom stock and weekend reconciliation marathons are architecture problems, not data problems. The sync that avoids them rests on three decisions, made once:

- **The ERP is the single source of truth for stock** - Shopify only displays the quantities that the ERP sets
- **Publishing to Shopify is idempotent** - you state absolute quantities, so re-running any sync after a failure is always safe
- **A nightly reconciliation compares the two systems in full** and corrects discrepancies, because discrepancies occur no matter how reliable the live path is

The failure modes this prevents are the classics of every ERP-Shopify thread: overselling, where Shopify shows stock that the ERP already committed to a B2B order; phantom stock, where a processed return takes days to appear on the storefront; and the manual reconciliation marathon, where someone compares spreadsheets every week because nobody trusts either system.

Zato connects the two as the integration layer - the ERP side uses its own protocol ([OData](https://zato.io/docs/dev/odata/index.html) for SAP and Dynamics 365, REST or SQL for others), the Shopify side is the [GraphQL Admin API](https://zato.io/docs/dev/shopify/graphql-admin-api-python.html), and Python services between the two perform the mapping.

## The live path {#the-live-path}

Two flows, one per direction, both thin.

| Flow | Trigger | Mechanism |
| --- | --- | --- |
| Stock levels, ERP to Shopify | Stock movement in the ERP | inventorySetQuantities with absolute numbers |
| Orders, Shopify to ERP | orders/create webhook | REST channel, dedup, async processing into the ERP |
| Catalog changes, ERP to Shopify | Product data changes in ERP or PIM | productSet upserts - see products and variants |

ERP to Shopify: when stock changes in the ERP - a goods receipt, an adjustment, a B2B commitment - a service publishes the new absolute quantity with inventorySetQuantities. Absolute is the operative word: publishing "SKU HAT-001-S has 42 available at location X" is idempotent, while publishing deltas like "add 3" corrupts state on every retry or duplicate.

```python
# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class PublishInventory(Service):
    name = 'shopify.publish-inventory'

    input = 'quantities'

    def handle(self):

        conn = self.out.graphql['Shopify']

        # setQuantities states the absolute target -
        # publishing the same numbers twice is a no-op
        mutation = """
            mutation Publish($input: InventorySetQuantitiesInput!) {
                inventorySetQuantities(input: $input) {
                    userErrors { field message }
                }
            }
        """

        params = {'input': {
            'name': 'available',
            'reason': 'correction',
            'quantities': self.request.input.quantities,
        }}

        result = conn.execute(mutation, params=params)

        user_errors = result['inventorySetQuantities']['userErrors']

        if user_errors:
            raise Exception(f'Publish failed: {user_errors}')
```

Shopify to ERP: [webhooks](https://zato.io/docs/dev/shopify/webhooks-python.html) for orders/create feed sales into the ERP as they happen, deduplicated and processed asynchronously. Everything slow or batchy stays out of this path - the live flows are limited to moving single facts quickly.

## Nightly reconciliation {#nightly-reconciliation}

A [scheduler](https://zato.io/docs/dev/examples/scheduler.html) job runs while traffic is low: read all quantities from the ERP, read all quantities from Shopify with a [bulk operation](https://zato.io/docs/dev/shopify/bulk-operations.html), compare per SKU and location, publish corrections for every mismatch and log each one. The corrections reuse the same idempotent publishing service as the live path - reconciliation is not a second write mechanism, it is the same one fed by a comparison.

```python
# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class ReconcileInventory(Service):
    """ Invoked by a scheduler job nightly, e.g. at 02:00.
    """
    name = 'shopify.reconcile-inventory'

    def handle(self):

        # Absolute quantities per SKU and location, from the ERP ..
        erp = self.invoke('erp.get-inventory-snapshot')

        # .. and from Shopify, exported earlier tonight in bulk -
        # each entry includes its quantity and inventory item GID
        shopify = self.invoke('shopify.get-inventory-snapshot')

        corrections = []

        for key, erp_quantity in erp.items():

            item = shopify[key]

            if item['quantity'] != erp_quantity:
                sku, location_id = key
                self.logger.warning('Drift for %s at %s: shopify=%s erp=%s',
                    sku, location_id, item['quantity'], erp_quantity)
                corrections.append({
                    'inventoryItemId': item['inventory_item_id'],
                    'locationId': location_id,
                    'quantity': erp_quantity,
                })

        if corrections:
            self.invoke('shopify.publish-inventory', {'quantities': corrections})
```

The log of mismatches is the health metric of the whole integration. A handful per night is normal and results from timing windows. A growing count names the SKUs and the direction - exactly where the live path misses updates - and if manual spreadsheet checks still consume hours each week, the reconciliation is not doing its job.

## Pitfalls that break real syncs {#pitfalls-that-break-real-syncs}

| Pitfall | What happens | What to do |
| --- | --- | --- |
| SKU matching is exact | HAT-001 and hat-001, or a trailing space, silently become two different products | Normalize SKUs - case, whitespace - in one shared function used by every service on both sides |
| Multi-location routing | Shopify routes fulfillment by location priority, not by distance or stock depth | Publish per-location quantities deliberately and review the location priority list in the shop |
| Bundles and kits | Shopify sells one line, the ERP consumes components, the warehouse picks a kit | Take bundles out of the live path - recompute bundle availability from components as a batch job |
| The 50,000 quantities limit | productVariantsBulkCreate and productVariantsBulkUpdate reject inputs above 50,000 inventory quantities with INVENTORY\_QUANTITIES\_LIMIT\_EXCEEDED | Chunk large writes - the constant belongs in one config value next to the publishing service |
| Fulfillment model | Order fulfillment code written against per-order endpoints misses the current model | Fulfillments hang off fulfillment orders - each order lists fulfillmentOrders to act on |

## When the design changes {#when-the-design-changes}

The architecture above remains valid as volumes grow - what changes is how each part handles volume. Catalog-wide publishes move from loops of mutations to [bulk operations](https://zato.io/docs/dev/shopify/bulk-operations.html). Rate-limit pressure on the live path gets the backoff pattern from [rate limits and backoff](https://zato.io/docs/dev/shopify/rate-limits-and-backoff.html), applied in the one publishing service everything shares. And when oversell incidents recur or reconciliation keeps finding the same mismatches, the answer is architectural - a tighter live path or a second reconciliation window - never a human comparing counts by hand.

## Learn more {#learn-more}

- [Shopify integration overview](https://zato.io/integrations/shopify/) - Start here to see how teams build Shopify integrations
- [The GraphQL Admin API](https://zato.io/docs/dev/shopify/graphql-admin-api-python.html) - Connections, access tokens, API versions, queries and mutations
- [Products and variants](https://zato.io/docs/dev/shopify/products-and-variants.html) - productSet, productVariantsBulkUpdate and the 2048-variant model
- [Rate limits and backoff](https://zato.io/docs/dev/shopify/rate-limits-and-backoff.html) - The cost-based leaky bucket, the 1,000-point ceiling and THROTTLED
- [Reliable webhooks](https://zato.io/docs/dev/shopify/webhooks-python.html) - HMAC verification, fast acknowledgments and deduplication
- [GraphQL error handling](https://zato.io/docs/dev/shopify/graphql-error-handling.html) - Why 200 OK does not mean success - errors and userErrors
- [Bulk operations](https://zato.io/docs/dev/shopify/bulk-operations.html) - Full-catalog reads and writes, polled by the scheduler
