Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

The Double-Sell Problem: Keeping One Item in Sync Across Two Marketplaces

Ask a seller who lists on more than one marketplace what keeps them up at night, and you'll hear the same answer: selling the same item twice. A limited-edition pair of sneakers, the only one the seller has, is listed o

The Double-Sell Problem: Keeping One Item in Sync Across Two Marketplaces

Ask a seller who lists on more than one marketplace what keeps them up at night, and you'll hear the same answer: selling the same item twice.

A limited-edition pair of sneakers, the only one the seller has, is listed on eBay and on a second marketplace. Two buyers check out within a minute of each other. One gets the sneakers. The other gets a cancellation, and the seller gets a defect on their account.

From the outside, the fix looks trivial: when it sells in one place, remove it from the other. In practice, that one sentence hides most of the classic problems of distributed systems: unreliable delivery, ordering, retries, concurrency and drift. This is a walkthrough of why cross-listing breaks, and the patterns that make it reliable.

Why sellers cross-list in the first place

Selling on more than one channel has become normal for regular sellers: a large marketplace for reach, plus another marketplace or the seller's own store. This piece is about the plumbing underneath that setup.

The moment one physical item is visible in two places, you have two systems that both believe they can sell it. Everything else follows from that.

Problem 1: there is no shared source of truth

Each marketplace keeps its own idea of how many units you have. On eBay's Inventory API, quantity lives on an inventory item identified by a seller-defined SKU, and is referenced again by each offer that turns that item into a live listing. A second marketplace has its own record. Neither knows about the other.

The first design decision, and the most important one, is to stop treating any marketplace as the source of truth. The seller's own inventory record, keyed by SKU, is the master. Marketplaces are projections of it.

// The master record. Marketplaces are views of this, not the other way round.
const item = {
  sku: "SNK-0193",
  available: 1,
  version: 7,           // increments on every change
  channels: {
    ebay:   { listingId: "…", syncedVersion: 7 },
    second: { listingId: "…", syncedVersion: 6 }  // behind: needs a push
  }
};

That syncedVersion field does a lot of work later. It lets you see, at any moment, which channel is out of date.

Problem 2: "it sold" arrives late, twice, or not at all

The fastest way to learn about a sale is a webhook: the marketplace calls your endpoint when an order is created. eBay offers this through its Notification API, with subscriptions to event topics and public keys to verify that a message really came from eBay. Shopify offers webhooks too, signed with an X-Shopify-Hmac-Sha256 header.

Webhooks are fast, but they are not a guarantee. Shopify's own webhook documentation is refreshingly blunt about it:

  • delivery "isn't always guaranteed," and your app can miss events through handler failures or downtime;
  • Shopify "doesn't guarantee ordering within a topic, or across different topics for the same resource";
  • duplicate deliveries happen, which is why each one carries an X-Shopify-Webhook-Id you can use to ignore repeats.

So a sync engine that relies only on webhooks will, eventually, miss a sale. And a missed sale on one channel is exactly how the double-sell happens.

Pattern 1: verify, deduplicate, then act

Every incoming event goes through the same three steps before it touches inventory:

async function handleOrderWebhook(req) {
  if (!verifySignature(req)) return reject(401);      // 1. is it genuine?

  const eventId = req.headers["x-shopify-webhook-id"];
  if (await seen(eventId)) return ok();               // 2. have we processed it?

  await applySale(req.body);                          // 3. act exactly once
  await markSeen(eventId);
  return ok();
}

Step 2 is what makes the handler idempotent: processing the same event twice has the same effect as processing it once. It's the same idea Stripe applies to API calls with idempotency keys, so a retried request never creates a second charge.

In practice, markSeen and applySale should commit in the same database transaction. If the process crashes between them, you either lose the sale or apply it twice on retry.

Pattern 2: decrement first, then delist everywhere else

When a sale is confirmed on one channel, the order of operations matters:

  1. Decrement the master record in a single atomic update, guarded by the current version.
  2. Queue a delist (or quantity update) for every other channel that still shows the item.
  3. Push those updates with retries, marking each channel's syncedVersion when it succeeds.
UPDATE inventory
SET available = available - 1, version = version + 1
WHERE sku = 'SNK-0193' AND available > 0 AND version = 7;
-- 0 rows updated means someone else got there first.

The available > 0 guard and the version check turn a race into a clean failure. It's the same principle behind flash-sale systems that don't oversell, except here the competing buyers arrive through different companies' systems instead of one. If two sales arrive at once, only one decrement succeeds. The other is detected immediately, so the seller can cancel and refund it at once rather than discovering the problem when they go to pack the item.

Step 2 is a classic dual-write problem: you need to update your database and reliably trigger calls to external APIs. Writing the "delist this on channel X" jobs into an outbox table in the same transaction, then publishing them separately, avoids losing them if the process dies mid-way. This is the classic use case for the transactional outbox pattern.

Pattern 3: reconcile, because webhooks will miss things

Even with perfect handlers, you'll miss events: deploys, outages, expired tokens, a marketplace having a bad day. Shopify's documentation says it directly: your app "shouldn't rely on receiving data from Shopify webhooks," and it recommends reconciliation jobs that periodically fetch data updated since the last run.

A reconciliation loop is the safety net under everything else:

// Runs every few minutes, per channel.
async function reconcile(channel) {
  const since = await lastReconciledAt(channel);
  const orders = await channel.fetchOrdersUpdatedSince(since);
  for (const order of orders) {
    await applySaleIfNew(order);   // same idempotent path as webhooks
  }
  await checkDrift(channel);       // compare channel quantities to master
  await setLastReconciledAt(channel, now());
}

Two details make this work:

  • Reuse the idempotent sale handler. A sale found by reconciliation goes through exactly the same code as a webhook, so it doesn't matter which one sees it first.
  • Check drift, not just orders. Compare each channel's listed quantity against the master record. If a listing shows 1 and the master says 0, push a correction. That catches the case where a delist call quietly failed.

Pattern 4: close the gap at checkout, where the platform allows it

Even a fast pipeline leaves a window of seconds between a sale on one channel and a delist on the other. For one-of-a-kind items, some platforms let you close that window at checkout instead.

eBay's Inventory API documentation describes a Real-time Inventory Check, where "eBay will make a call to this endpoint at checkout" to confirm enough quantity exists before the purchase completes. If your master record says the item is gone, the second buyer is stopped before paying, not after.

Not every marketplace offers a hook like this, but where one exists, it's the strongest protection against a double-sell, because it moves the check from after the sale to during it.

Problem 3: the same item isn't the same data

Inventory is only half the job. Getting an item listed on a second channel at all means mapping one data model onto another:

  • Categories don't line up. One marketplace's "Cameras & Photo > Film Cameras" is another's "Electronics > Cameras". You need a category map, and a fallback for items that don't fit.
  • Attributes differ. eBay uses item specifics; other platforms use their own fields, or none. Brand, model, size and condition need mapping, and condition labels rarely match one to one.
  • Editing rules differ. eBay notes that listings created through its Inventory API can't then be edited in Seller Hub or other listing tools; revisions have to go through the API. If a seller edits by hand somewhere else, your system and the marketplace disagree.

This is why import tools matter so much to sellers. A marketplace that can take existing listings from another platform does this mapping once, instead of making the seller retype every item. TrueGether, one of the eBay alternatives built for independent sellers, imports listings from eBay and Shopify, and the hard part of any importer like that isn't fetching the data. It's deciding what to do with the fields that don't map cleanly.

A checklist for anyone building cross-channel sync

description: "Selling on eBay and an eBay alternative? Why one item can sell twice, and the fixes: idempotent webhooks, atomic stock updates, an outbox and reconciliation."

  1. Own the source of truth. Keep a master inventory record keyed by SKU, with a version number. Marketplaces are projections.
  2. Treat every event as untrusted and repeatable. Verify signatures, deduplicate by event ID, and make the handler idempotent.
  3. Decrement atomically. Guard updates with available > 0 and a version check so races fail cleanly.
  4. Don't lose side effects. Use an outbox for delist and update calls to other channels.
  5. Reconcile on a schedule. Assume webhooks will be missed, and compare quantities across channels to catch drift.
  6. Use checkout-time checks where they exist. They're the only way to stop a double-sell before the buyer pays.
  7. Map data deliberately. Categories, attributes and condition labels need explicit rules and a fallback.

The takeaway

Cross-listing looks like a sales tactic, but it's really a consistency problem: one physical object, several systems that each think they can sell it, and networks that don't promise delivery or order. The sellers who get it right, or the tools they rely on, aren't the ones with the fastest webhooks. They're the ones who assume events will be late, duplicated or missing, and design for that from the start.

If you're building or choosing a cross-listing setup, test it against the seven-point checklist above before trusting it with a one-of-a-kind item.

FAQ

Why do cross-listed items sometimes sell twice?

Because each marketplace keeps its own stock count, and the "item sold" signal from one channel can arrive late, twice or not at all. Until the other listing is updated, both channels still show the item as available.

Are webhooks enough to keep inventory in sync?

No. Webhooks are fast but not guaranteed, and Shopify's documentation recommends reconciliation jobs alongside them. Use webhooks for speed and a scheduled reconciliation for correctness.

What does idempotent mean for a webhook handler?

Processing the same event twice has the same effect as processing it once. In practice: deduplicate by event ID and record the event in the same transaction that applies the sale.

Can I move my eBay listings to another marketplace without retyping them?

Often, yes. Some marketplaces can import existing listings directly or accept an inventory file. TrueGether, for example, imports listings from eBay and Shopify and accepts inventory file uploads. Whichever platform you use, check the imported categories, item specifics and condition labels before going live, because those are the fields that rarely map one to one.

Platform behaviour described here comes from eBay's Inventory and Notification API documentation and Shopify's webhook documentation, checked October 2026. APIs change, so always confirm against current docs before building.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.