DEV Community

Nventory
Nventory

Posted on

We built 40+ integrations. Here's what nobody tells you.

Everyone talks about building integrations like it's a solved problem.

Connect to the API. Map the fields. Ship it.

We've built 40+ integrations at Nventory - Amazon, Shopify, Flipkart, eBay, WooCommerce, TikTok Shop, FedEx, DHL, QuickBooks, and dozens more. Here's what actually happens when you do this at scale.

  1. Every API has a personality

And some of them have serious personality disorders.

Shopify's API is clean, well-documented, and mostly behaves the way you expect. Amazon's SP-API is powerful but has the documentation of a legal contract written in 2003. Flipkart's API works perfectly until it doesn't, with error messages that tell you nothing useful.

javascript
// Shopify error — actually helpful
{
"errors": {
"inventory_item_id": ["can't be blank"]
}
}

// Amazon SP-API error — less helpful
{
"code": "InvalidInput",
"message": "Request has invalid parameters",
"details": ""
}

// Flipkart error — chaotic neutral
{
"status": "SUCCESS",
"statusCode": "200",
"error": "ITEM_NOT_FOUND"
}

That last one is real. Status 200. Error: ITEM_NOT_FOUND. We spent two days on that.

The lesson: Never trust the HTTP status code alone. Parse the response body. Every time. Without exception.

  1. Rate limits will destroy you if you're not careful

Every API has rate limits. Most APIs don't tell you when you're approaching them, they just start failing.

Amazon SP-API uses a token bucket algorithm. Each endpoint has its own rate limit, restore rate, and burst limit. Getting this wrong means your sync stops working during peak trading periods — exactly when you need it most.

javascript
// Naive approach will get you rate limited
async function syncAllProducts(products) {
await Promise.all(products.map(p => amazonAPI.updateInventory(p)));
// 500 products = 500 simultaneous requests
// Rate limit hit in seconds
}

// Better approach — token bucket with backoff
class RateLimiter {
constructor(tokensPerSecond, burst) {
this.tokens = burst;
this.maxTokens = burst;
this.tokensPerSecond = tokensPerSecond;
this.lastRefill = Date.now();
}

async acquire() {
this.refill();

if (this.tokens < 1) {
  const waitTime = (1 / this.tokensPerSecond) * 1000;
  await sleep(waitTime);
  return this.acquire();
}

this.tokens -= 1;
Enter fullscreen mode Exit fullscreen mode

}

refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(
this.maxTokens,
this.tokens + elapsed * this.tokensPerSecond
);
this.lastRefill = now;
}
}

// Each API gets its own rate limiter
const amazonLimiter = new RateLimiter(2, 10); // 2 per second, burst of 10
const shopifyLimiter = new RateLimiter(4, 40); // 4 per second, burst of 40

The lesson: Build rate limiting before you need it. Retrofitting it after you've been blocked is significantly more painful.

  1. Webhook delivery is not guaranteed

This one cost us more debugging hours than anything else.

Every platform says they deliver webhooks reliably. What they mean is they attempt delivery reliably. Network issues, server restarts, brief downtime — any of these can cause a webhook to fail silently.

The naive implementation:

javascript
// What most people build first
app.post('/webhooks/shopify/orders', async (req, res) => {
await processOrder(req.body);
res.status(200).send('OK');
});

// Problem: if processOrder fails or times out,
// Shopify retries and you process the same order twice

The production implementation:

javascript
// What you actually need
app.post('/webhooks/shopify/orders', async (req, res) => {
// Acknowledge immediately before processing
res.status(200).send('OK');

const webhookId = req.headers['x-shopify-webhook-id'];

// Idempotency check - have we processed this before?
if (await idempotencyStore.exists(webhookId)) {
return; // Duplicate delivery - safe to ignore
}

try {
await processOrder(req.body);
await idempotencyStore.mark(webhookId);
} catch (error) {
// Failed processing goes to dead letter queue
// Never silently dropped
await deadLetterQueue.push({
webhookId,
payload: req.body,
error: error.message,
retryAt: Date.now() + backoffMs(0)
});
}
});

Three things here:

Acknowledge immediately - slow processing causes retries
Idempotency keys - duplicate deliveries are handled safely
Dead letter queue - failed processing never gets silently dropped

The lesson: Assume every webhook will be delivered at least twice. Build accordingly from day one.

  1. Field mapping is where integrations go to die

Every platform has its own data model. SKUs that are strings on Shopify are integers on some marketplaces. Product titles have different character limits. Variant attributes that map cleanly on one platform have no equivalent on another.

javascript
// Shopify product variant
{
id: 123456789,
sku: "HOODIE-BLK-M",
inventory_quantity: 47,
option1: "Black",
option2: "Medium",
option3: null
}

// Amazon equivalent - completely different model
{
ASIN: "B08XYZ123",
SellerSKU: "HOODIE-BLK-M",
Quantity: 47,
// Colour and size live in a completely different API call
// Under a different authentication scope
// With a different rate limit
}

// eBay equivalent - different again
{
ItemID: "123456789012",
SKU: "HOODIE-BLK-M",
Quantity: 47,
Variations: {
VariationSpecifics: [
{ Name: "Colour", Value: "Black" },
{ Name: "Size", Value: "Medium" }
]
}
}

We built a normalisation layer that maps every platform's data model to our internal schema. Every integration writes to and reads from the normalised model not directly to each other.

javascript
// Internal normalised schema
const internalProduct = {
internalId: "prod_abc123",
sku: "HOODIE-BLK-M",
quantity: 47,
attributes: {
color: "Black",
size: "Medium"
},
channelIds: {
shopify: "123456789",
amazon: "B08XYZ123",
ebay: "123456789012"
}
};

// Each integration maps to/from this schema
// Never directly to each other

The lesson: Build a canonical internal data model before writing your first integration. Retrofitting one after you have 10 integrations is a multi-month project.

  1. Silent failures are the worst failures

A broken integration that throws errors is easy to debug. An integration that silently succeeds while doing the wrong thing is a nightmare.

We had a bug in our eBay integration for three weeks where inventory updates were returning 200 OK but not actually updating the listing quantity. No error. No log entry. Just wrong data silently propagating.

javascript
// The bug
async function updateEbayInventory(sku, qty) {
const response = await ebayAPI.reviseInventoryStatus({
ItemID: await getEbayItemId(sku),
Quantity: qty
});

if (response.Ack === 'Success') {
return true; // Assumed success
}
// Didn't check response.Errors
// eBay returns Ack: 'Success' with errors in some cases
}

// The fix
async function updateEbayInventory(sku, qty) {
const response = await ebayAPI.reviseInventoryStatus({
ItemID: await getEbayItemId(sku),
Quantity: qty
});

if (response.Errors && response.Errors.length > 0) {
throw new EbayAPIError(response.Errors);
}

// Verify the update actually took effect
const verification = await ebayAPI.getItem(await getEbayItemId(sku));
if (verification.Quantity !== qty) {
throw new InventoryMismatchError(sku, qty, verification.Quantity);
}

return true;
}

The lesson: Verify, don't trust. After every write operation — read back and confirm the state changed correctly.

  1. Every API changes without warning

We've had integrations break overnight because a platform deprecated a field, changed authentication, or updated their data model without announcing it.

Build monitoring that detects integration drift before your users do:

javascript
// Integration health checks — run every 5 minutes
async function checkIntegrationHealth(channel) {
try {
// Test authentication
await channel.authenticate();

// Test read operation
const testProduct = await channel.getProduct(channel.testProductId);
if (!testProduct) throw new Error('Test product not found');

// Test write operation (with rollback)
const originalQty = testProduct.quantity;
await channel.updateInventory(channel.testProductId, originalQty);

metrics.record('integration_health', { channel: channel.id, status: 'healthy' });
Enter fullscreen mode Exit fullscreen mode

} catch (error) {
metrics.record('integration_health', { channel: channel.id, status: 'degraded' });
alerting.warn(Integration degraded: ${channel.id}, { error: error.message });
}
}

The lesson: Monitor your integrations actively. Don't wait for a seller to tell you something is broken.

What 40+ integrations taught us

The technical patterns are learnable. The real lessons are cultural:

→ Assume every external API is unreliable until proven otherwise
→ Build idempotency before you need it — retrofitting is painful
→ Verify every write — trust nothing
→ Silent failures are worse than loud ones
→ Your canonical data model is your most important architectural decision
→ Rate limits will bite you at the worst possible moment

The integrations that work in production aren't the ones written fastest. They're the ones written with the most paranoia.

We're building Nventory on these patterns — event-driven sync across 40+ channels, with idempotency, verification, and active health monitoring built in throughout.

Top comments (0)