Most Shopify ERP integrations fail not because of bad code. They fail because of three architectural decisions made before a single line was written: which system owns which data, how conflicts get resolved, and how partial failures recover without duplication.
Here is the complete production architecture covering all three.
The Non-Negotiable Design Decisions
Before writing any integration code, define these per data domain:
| Data Domain | Source of Truth | Sync Direction | Sync Frequency |
|---|---|---|---|
| Inventory levels | ERP | ERP to Shopify | Every 5-15 minutes |
| Pricing | ERP | ERP to Shopify | Nightly or on change |
| Product master | ERP | ERP to Shopify | On change |
| Orders | Shopify | Shopify to ERP | Real-time webhook |
| Refunds | Shopify | Shopify to ERP | Real-time webhook |
| Customer addresses | Timestamp | Bidirectional | On change, last write wins |
Enforce this in middleware. Do not leave it as an informal convention.
- The Three-Layer Middleware Model Direct Shopify-to-ERP integration creates tight coupling. Three explicit middleware tiers prevent it:
js// Layer 1: Ingestion — validates and enqueues in under 50ms
app.post('/webhooks/orders', express.raw({ type: '*/*' }), async (req, res) => {
if (!verifyShopifyHmac(req.body, req.headers['x-shopify-hmac-sha256'])) {
return res.status(401).send('Unauthorized');
}
await ingestionQueue.add('shopify.order.created', {
raw: req.body.toString(),
shop: req.headers['x-shopify-shop-domain'],
webhookId: req.headers['x-shopify-webhook-id'],
});
res.status(200).send('OK');
});
// Layer 2: Transformation — maps Shopify schema to ERP schema
const transformWorker = new Worker('ingestion', async (job) => {
const shopifyOrder = JSON.parse(job.data.raw);
const erpOrder = transformShopifyOrderToERP(shopifyOrder);
await deliveryQueue.add('erp.order.create', {
erpPayload: erpOrder,
shopifyOrderId: shopifyOrder.id,
idempotencyKey: `order:${shopifyOrder.id}:${job.data.shop}`,
});
}, { connection, concurrency: 10 });
// Layer 3: Delivery — idempotent ERP submission with retry
const deliveryWorker = new Worker('delivery', async (job) => {
const acquired = await redis.set(
`erp:submitted:${job.data.idempotencyKey}`,
'1', { NX: true, EX: 86400 }
);
if (!acquired) return { status: 'duplicate' };
await submitToERP(job.data.erpPayload);
}, { connection, concurrency: 5 });
Keep these tiers separate. A field mapping change in Layer 2 should never touch Layer 1. A rate limit change in Layer 3 should never force re-running Layer 2.
- Delta Inventory Sync with Watermark Never query all SKUs on every sync cycle. Query the ERP change journal for movements since the last watermark:
jsasync function syncInventoryFromERP(shop) {
const lastSyncedAt = await redis.get(`erp:inventory:watermark:${shop}`)
|| '1970-01-01T00:00:00Z';
const changedItems = await erpClient.getInventoryChanges({
since: lastSyncedAt,
warehouseId: process.env.ERP_WAREHOUSE_ID,
});
if (changedItems.length === 0) return { synced: 0 };
const updates = (await Promise.all(
changedItems.map(async (item) => {
const shopifyItemId = await lookupShopifyInventoryItemId(shop, item.erpItemCode);
if (!shopifyItemId) { await logUnmappedItem(shop, item.erpItemCode); return null; }
return { inventory_item_id: shopifyItemId, available: item.availableQuantity };
})
)).filter(Boolean);
// Batch submit: max 100 items per Shopify API call
for (let i = 0; i < updates.length; i += 100) {
await shopifyAdmin.post('/inventory_bulk_adjust_quantity_at_location.json', {
location_id: process.env.SHOPIFY_LOCATION_ID,
changes: updates.slice(i, i + 100),
});
await new Promise(r => setTimeout(r, 500)); // Rate limit buffer
}
// Advance watermark AFTER successful sync — never before
await redis.set(`erp:inventory:watermark:${shop}`, new Date().toISOString());
return { synced: updates.length };
}
Advance the watermark only after a successful sync. Advancing before processing means a mid-sync crash silently skips records.
- Preventing Sync Loops Bidirectional sync creates infinite loops. Your middleware writes a price to Shopify. Shopify fires products/update. Your middleware ingests it and syncs back to the ERP. Repeat forever. The fix is one field:
js// Tag every middleware-originated write to Shopify
await shopifyAdmin.put(`/products/${productId}.json`, {
product: {
id: productId,
metafields: [{
namespace: 'integration',
key: 'source',
value: 'erp_middleware',
type: 'single_line_text_field',
}]
}
});
// Check the tag in every webhook handler before routing to ERP
async function handleProductUpdate(shop, payload) {
const source = payload.metafields?.find(
m => m.namespace === 'integration' && m.key === 'source'
)?.value;
if (source === 'erp_middleware') {
return { status: 'skipped', reason: 'middleware-originated write' };
}
await routeToERP(shop, payload);
}
-
Error Classification That Prevents Incidents
Every ERP integration error falls into one of three categories. Handle each differently:Error Type HTTP Codes Action Alert? Transient 408, 429, 500, 502, 503, 504 Retry with exponential backoff Only on retry exhaustion Data error 400, 404, 422 Route to DLQ immediately, no retry Yes — data team System error 401, 403 Pause queue, halt processing Yes — ops team, P0 Retrying a data error wastes resources and delays the fix. Not retrying a transient error causes unnecessary data loss. Pausing on a system error prevents a queue backlog that compounds when the system recovers.
-
SAP Interface Layer Selection
Interface Protocol Recommended? When to Use SAP OData REST / HTTP Yes — preferred All new integrations SAP IDoc Async file/message With adapter When OData unavailable SAP BAPI via RFC Proprietary RFC No — avoid Existing integrations only SAP Integration Suite Pre-built flows Evaluate first S/4HANA Cloud
The Integration Architecture Checklist
Before go-live, validate every item:
Source of truth defined per data domain in a shared document
SKU-to-ERP item code cross-reference table built and validated
Idempotency keys on every delivery worker call
Sync loop prevention via integration_source metafield tagging
Watermark advanced only after successful sync, never before
Error classification routing: transient retry, data error DLQ, system error queue pause
Dead-letter queue includes full payload context and all retry timestamps
Shopify API rate limit respected between batch inventory writes
Full guide with complete field mapping code, delta sync implementation, SAP interface selection, pricing sync to Shopify B2B price lists, and conflict resolution patterns:
Top comments (0)