I have lost count of the number of times a client has told me their ERP integration is “basically done” and then handed me a spreadsheet macro emailing CSVs at midnight. Off-the-shelf ERP connectors cover 70% of use cases. The other 30% — custom fields, complex pricing rules, multi-warehouse logic, approval workflows — is where the money and the mess both live, and it is exactly the 30% no packaged connector will ever cover for your business. That is when you build your own.
Here is how I would build one that is reliable, maintainable, and production-ready — not a clever prototype that works in the demo and pages you at 3 a.m. six weeks later. The parts that matter are unglamorous on purpose. Get them right once and the connector outlives the ERP it was written for.
Architecture
A custom connector is a middleware service that sits between WooCommerce and your ERP:
┌─────────────┐ ┌──────────────────┐ ┌──────────┐
│ WooCommerce │────>│ Middleware │────>│ ERP │
│ REST API │<────│ (Node.js/Python) │<────│ API │
└─────────────┘ │ │ └──────────┘
│ ┌────────────┐ │
│ │ Queue │ │
│ │ (Redis) │ │
│ └────────────┘ │
│ ┌────────────┐ │
│ │ DB │ │
│ │ (mapping) │ │
│ └────────────┘ │
└──────────────────┘
The Sync Engine
The core of your connector is a sync engine that processes data in both directions:
class SyncEngine {
constructor(wooClient, erpClient, db) {
this.woo = wooClient;
this.erp = erpClient;
this.db = db;
this.queue = new Queue('sync-jobs');
}
// Product sync: ERP → WooCommerce
async syncProducts() {
const lastSync = await this.db.getLastSync('products');
const erpProducts = await this.erp.getModifiedProducts(lastSync);
for (const product of erpProducts) {
await this.queue.add('sync-product', {
erpId: product.id,
data: product
});
}
}
// Order sync: WooCommerce → ERP
async syncOrders() {
const lastSync = await this.db.getLastSync('orders');
const wcOrders = await this.woo.getOrdersSince(lastSync);
for (const order of wcOrders) {
await this.queue.add('sync-order', {
wcOrderId: order.id,
data: order
});
}
}
}
Data Mapping Layer
This is the component I would fight hardest to keep clean. Every ERP models the world slightly differently from WooCommerce, and the mapping layer is where you absorb that difference in one place instead of letting it leak into fifty. Keep the mapping configuration separate from your sync logic — the day the ERP renames a field, you want to edit one object, not hunt through the engine:
// mapping.js
const PRODUCT_MAPPING = {
// ERP field → WooCommerce field
'item_code': 'sku',
'item_name': 'name',
'description': { field: 'description', transform: 'htmlEncode' },
'unit_price': { field: 'regular_price', transform: 'toString' },
'stock_qty': { field: 'stock_quantity', transform: 'parseInt' },
'is_active': { field: 'status', transform: (val) => val ? 'publish' : 'draft' },
'weight_kg': { field: 'weight', transform: 'toString' },
'category_code': { field: 'categories', transform: 'mapCategory' }
};
const ORDER_MAPPING = {
// WooCommerce field → ERP field
'id': 'external_ref',
'billing.email': 'customer_email',
'billing.first_name': { field: 'customer_name', transform: 'combineName' },
'line_items': { field: 'order_lines', transform: 'mapLineItems' },
'total': { field: 'order_total', transform: 'parseFloat' }
};
function applyMapping(source, mapping) {
const result = {};
for (const [sourceKey, target] of Object.entries(mapping)) {
const value = getNestedValue(source, sourceKey);
if (typeof target === 'string') {
result[target] = value;
} else {
const transformed = transforms[target.transform]
? transforms[target.transform](value, source)
: value;
result[target.field] = transformed;
}
}
return result;
}
Queue-Based Processing
Never process syncs inline. I mean never — the first time an ERP API times out mid-request and takes a customer’s checkout down with it, you will understand why. A job queue decouples the two systems so that when one is slow or down, the other keeps serving. Retries, backoff, and a dead-letter path are not luxuries here; they are the difference between a blip and an incident:
const Queue = require('bull');
const syncQueue = new Queue('erp-sync', {
redis: { host: '127.0.0.1', port: 6379 },
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 100,
removeOnFail: 500
}
});
// Process product sync jobs
syncQueue.process('sync-product', async (job) => {
const { erpId, data } = job.data;
// Check if product exists in WooCommerce
const mapping = await db.getMapping('product', erpId);
if (mapping) {
// Update existing product
await wooClient.updateProduct(mapping.wc_id, applyMapping(data, PRODUCT_MAPPING));
} else {
// Create new product
const wcProduct = await wooClient.createProduct(applyMapping(data, PRODUCT_MAPPING));
await db.createMapping('product', erpId, wcProduct.id);
}
return { success: true, erpId };
});
// Handle failures
syncQueue.on('failed', (job, err) => {
console.error(Job ${job.id} failed: ${err.message});
if (job.attemptsMade >= job.opts.attempts) {
alertTeam(Sync failed permanently: ${job.data.erpId} - ${err.message});
}
});
ID Mapping Table
WooCommerce IDs and ERP IDs are different. Maintain a mapping table:
CREATE TABLE entity_mapping (
id INT AUTO_INCREMENT PRIMARY KEY,
entity_type ENUM('product', 'customer', 'order', 'category') NOT NULL,
erp_id VARCHAR(100) NOT NULL,
wc_id INT NOT NULL,
last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sync_hash VARCHAR(64), -- Hash of data to detect changes
UNIQUE KEY (entity_type, erp_id),
INDEX (entity_type, wc_id)
);
The sync_hash field prevents unnecessary updates. If the ERP data hasn’t changed since the last sync, skip the WooCommerce API call.
function hasChanged(currentData, storedHash) {
const currentHash = crypto.createHash('md5')
.update(JSON.stringify(currentData))
.digest('hex');
return currentHash !== storedHash;
}
Webhook Receiver
For real-time order sync, receive WooCommerce webhooks:
app.post('/webhooks/woocommerce/order-created', (req, res) => {
// Verify webhook signature
const signature = req.headers['x-wc-webhook-signature'];
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('base64');
if (signature !== expected) {
return res.status(401).send('Invalid signature');
}
// Queue the order for processing
syncQueue.add('sync-order', {
wcOrderId: req.body.id,
data: req.body
});
res.status(200).send('OK');
});
Monitoring Dashboard
Build a simple dashboard to track sync health:
app.get('/api/sync/status', async (req, res) => {
const [productCount] = await db.query('SELECT COUNT(*) as count FROM entity_mapping WHERE entity_type = "product"');
const [orderCount] = await db.query('SELECT COUNT(*) as count FROM entity_mapping WHERE entity_type = "order"');
const [recentFails] = await db.query('SELECT * FROM sync_log WHERE status = "failed" AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)');
const queueStats = await syncQueue.getJobCounts();
res.json({
mappedProducts: productCount[0].count,
mappedOrders: orderCount[0].count,
queue: queueStats,
recentFailures: recentFails.length,
lastProductSync: await db.getLastSync('products'),
lastOrderSync: await db.getLastSync('orders')
});
});
Where These Connectors Actually Break
The architecture above is the easy 80%. Here is the 20% that has bitten every integration I have shipped, so you can design for it up front instead of discovering it in production.
- Clock and timezone drift. Your “modified since last sync” query is only as trustworthy as the two clocks feeding it. If the ERP stamps records in local time and you query in UTC, you will silently skip an hour of changes every sync. Store timestamps in UTC on both ends and be paranoid about it.
- Partial failures inside a batch. A run of 500 products where product 237 has a malformed price should not abort the other 499. Process each item as its own job, isolate the failure, and keep the good ones flowing.
- The two-way write loop. The moment both systems can write the same field, a WooCommerce update triggers an ERP sync that triggers a WooCommerce update. Decide which system owns which field, and never let both claim the same one.
- Idempotency. Networks retry. Queues re-deliver. Every write to either system has to be safe to run twice, or a webhook redelivery becomes a duplicate order. The
sync_hashand the mapping table are your defence here — lean on them. - Rate limits. WooCommerce and most ERPs will throttle you under a bulk sync. Respect the limit deliberately in your queue concurrency rather than discovering it as a wall of 429s during the first full import.
None of these show up in a demo. All of them show up in month two. Building for them from the start is the entire reason a custom connector is worth the investment over a cron job and hope.
Deployment
Deploy your connector as a standalone service:
- Docker for consistent environments
- PM2 or systemd for process management
- Health check endpoint for uptime monitoring
- Log aggregation via Winston + CloudWatch or ELK
Conclusion
A custom WooCommerce-ERP connector is a significant engineering investment, but it pays off the moment your integration requirements exceed what off-the-shelf solutions offer — and for the business logic that actually differentiates a store, they always do eventually. The key components — data mapping, queue processing, ID mapping, error handling, and monitoring — are universal regardless of which ERP you’re connecting to. That is the point I would leave you with: build these five well once, treat the ERP as a detail you can swap, and you have a foundation that adapts to changing business requirements instead of one you rewrite every time the back office changes vendors. The connector you regret is always the one that skipped the boring parts.
Last modified: August 2, 2026
United States / English
Slovensko / Slovenčina
Canada / Français
Türkiye / Türkçe