MODRACXKENNETH D'SILVA

← Archive & Insights

Depop & Omnichannel Marketplace Synchronization

A Manchester vintage reseller oversold 137 items in one Saturday. The sync tool was not broken — it polled every fifteen minutes, and on Black Friday that was forty times too slow.

By Kenneth D'SilvaReading Time: 23 min readCategory: Integrations & Systems

1. The Saturday That Cost £4,200

A vintage clothing reseller in Manchester ran a Black Friday promotion in November 2023 across their Shopify store, eBay, Depop and a small Vinted presence. They had a sync tool. It had worked for eighteen months. On the Saturday of that weekend they oversold 137 items.

Not 137 units — 137 items, most of which were one-of-one vintage pieces where an oversell means somebody definitively does not get the thing they paid for. Refunds, apology credits, a Depop rating that went from 4.9 to 4.6 in a week, and roughly £4,200 in direct cost once they'd counted the postage on the goodwill gifts they sent out.

The sync tool polled every fifteen minutes. Under normal traffic that was fine, because two people rarely bought the same unique item within the same quarter-hour window. Under Black Friday traffic it was catastrophic, because the interval hadn't changed but the arrival rate had gone up by a factor of forty.

Nothing was broken. The system did exactly what it had been built to do. It had simply been built on an assumption — that inventory changes slowly relative to the polling interval — that stopped being true on the one day of the year when it mattered most.

That's the shape of almost every marketplace sync failure I've been called in on. Not a bug. An assumption about timing that held until it didn't. This article is about building the thing so that it doesn't depend on that assumption.

2. Why This Is Harder Than It Looks

Selling one catalogue on four channels sounds like a data replication problem. It is not. It's a distributed consensus problem wearing a data replication problem's clothes, and the reason is that you don't control most of the participants.

You have one physical stock pool in one warehouse — or, worse, in three bins across a spare bedroom and a self-storage unit. You have four systems each holding a number that claims to represent that pool. Each of those systems will accept an order against its own number without asking you first. There is no lock you can take across all four. There is no transaction. eBay does not phone you before it lets someone buy.

So every marketplace integration is fundamentally optimistic concurrency with no rollback, and the entire engineering problem is minimising the window during which the four numbers disagree, plus deciding what to do when they inevitably do.

Four properties make it worse than the equivalent problem inside your own systems.

Latency you don't control. When you push a stock update to a marketplace, the API accepts it and returns 200. That does not mean the change is live on the listing page. On eBay, revised quantity typically becomes visible in seconds but has taken minutes under load. On Amazon, feed-based inventory updates are asynchronous by design and a submitted feed can take anywhere from thirty seconds to several minutes to process. Your "success" response is an acknowledgement of receipt, not of effect.

Different consistency models per channel. Some marketplaces will hold an item in a cart. Some won't. Some allow overselling and expect you to cancel. Some penalise cancellation severely enough that overselling is worse than losing the sale.

Rate limits that bite exactly when you need throughput. The moment a big promotion drives a hundred orders in ten minutes is the moment you want to push a hundred stock updates, and it is also the moment you hit your daily call allowance.

Partial failure is the normal case. Four channels, four network calls, and the realistic outcome of any broadcast is that three succeed and one times out. Your architecture has to have an answer for that which isn't "retry the whole thing".

3. Depop's Particular Awkwardness

Depop deserves its own section because it does not behave like the others and teams routinely design for eBay and then discover Depop doesn't fit the model.

First, and most importantly for anyone planning an integration: Depop does not offer an open public seller API. Access is partner-gated. You apply, you explain what you're building, and you may or may not be approved. This is not a documentation gap you can engineer around — it's an access decision made by a company that has historically been protective of its peer-to-peer character and not especially interested in enabling bulk operations.

The practical consequence is that most sellers reach Depop through an approved integrator rather than directly, and the choice of integrator therefore constrains your architecture more than it would elsewhere. I've had two clients build a beautiful channel-agnostic sync layer and then bolt Depop on through a third party's webhook feed, which works, but means Depop's data arrives on a different shape and a different schedule to everything else.

Second, the inventory model. Depop grew up as a one-of-one secondhand marketplace. A listing is a photograph of a specific garment. Multi-quantity listings exist now for brand and boutique sellers, but the platform's centre of gravity is still single-item, and that changes the maths completely.

With a quantity of one, there is no buffer strategy. You cannot hold back two units of a thing there is one of. Every single sale is a race between channels, and the only lever you have is how fast you can delist everywhere else.

ChannelTypical inventory modelBuffer viable?Oversell cost
Depop (secondhand)Quantity 1, per-listingNoVery high — rating and refund
Depop (boutique)Multi-quantity, variantsPartiallyHigh
eBayMulti-quantity, variation groupsYesDefect rate, seller standing
AmazonMulti-quantity per SKU/FNSKUYesSevere — order defect rate
Own Shopify storeFull control, real reservationsNot neededLow — you can apologise directly

Third, Depop's fulfilment and messaging culture is personal. Buyers expect a reply, expect the seller to be an individual, and treat a cancellation as a broken promise rather than a stock discrepancy. An oversell on Amazon is a metric. An oversell on Depop is a bad review that says "seller cancelled on me" and sits at the top of your profile for months.

So if you're building a sync layer that includes Depop, design for the one-of-one case first and treat the multi-quantity channels as the easy variant. Doing it the other way round produces a system that works everywhere except the place where failure hurts most.

4. Three Architectures, And Which One I'd Build

There are essentially three ways to keep channels in step, and they're not equally good despite what integrator marketing pages suggest.

Scheduled polling

A cron job every N minutes reads current stock and pushes it to every channel. This is what the Manchester reseller had.

It is simple, it is easy to reason about, and it fails in exactly one predictable way: the window. Between the last push and the next, every channel's number is stale by however much sold in that period. Halving the interval halves the exposure and doubles the API calls, and you run out of API calls long before you run out of exposure.

I would not build this as the primary mechanism. I would absolutely keep it as a safety net, running hourly, correcting drift that the event path missed.

Event-driven push

Every stock-affecting event — an order, a cancellation, a stock adjustment, a return going back on the shelf — immediately publishes to a queue, and a worker fans it out to every channel.

This is what I build. The window shrinks from minutes to whatever your queue latency plus the marketplace's propagation delay is, typically two to ten seconds. It's an order of magnitude better and it costs an order of magnitude fewer API calls, because you only push when something changed.

// One event in, N channel updates out. The queue is doing the
// durability work; the worker only has to be idempotent.
async function onInventoryChanged(event) {
  // event: { sku, availableAfter, reason, sourceChannel, occurredAt, version }
  const channels = await listingsForSku(event.sku);   // where is this listed?

  await Promise.allSettled(
    channels
      .filter(c => c.channel !== event.sourceChannel)  // don't echo back
      .map(c => queue.publish('channel.inventory.push', {
        channel: c.channel,
        listingId: c.externalId,
        sku: event.sku,
        quantity: applyBuffer(c.channel, event.availableAfter),
        version: event.version,     // for stale-write rejection at the worker
        idempotencyKey: `${c.channel}:${event.sku}:${event.version}`
      }))
  );
}

The version field is the piece people leave out and then spend a fortnight debugging. Without it, two updates for the same SKU can be processed out of order by two workers and the older number wins. More on that below.

Central inventory service with reservations

The most correct and the most expensive. One service owns availability. Nothing writes stock directly. Every channel's number is derived, and every order takes a reservation against the central pool before it's confirmed.

The catch is that marketplaces don't ask permission. You can't make eBay call your reservation service before completing a checkout. So the reservation model only gives you true safety on channels you control — your own storefront — and degrades to optimistic push everywhere else.

It's still worth building the ledger even if only one channel can reserve against it, because it gives you one authoritative number and a full audit trail of why it changed. When someone asks "why did this show as available when it wasn't", a ledger answers in thirty seconds and a spreadsheet of last-known quantities answers never.

What I'd actually build, for a seller with four channels and real volume: event-driven push as the primary path, a reservation ledger as the source of truth, and a scheduled reconciliation as the backstop. All three, because they fail in different ways.

5. The Reservation Ledger

Availability should be a computed number, not a stored one. The moment you store a quantity and mutate it, you have lost the ability to answer "how did it get to this value", and every marketplace sync eventually needs that answer.

An append-only ledger of movements, with availability derived by summation, costs slightly more in query time and saves you weeks.

CREATE TABLE inventory_movement (
  id            BIGSERIAL PRIMARY KEY,
  sku           TEXT        NOT NULL,
  location_id   TEXT        NOT NULL,
  delta         INTEGER     NOT NULL,   -- +5 receipt, -1 sale, +1 return
  kind          TEXT        NOT NULL,   -- receipt|sale|reservation|release|adjustment
  channel       TEXT,                   -- null for warehouse movements
  external_ref  TEXT,                   -- marketplace order id
  expires_at    TIMESTAMPTZ,            -- reservations only
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  -- One movement per external reference per kind. This single constraint
  -- makes the whole webhook pipeline safe to replay.
  UNIQUE (channel, external_ref, kind)
);

CREATE INDEX ON inventory_movement (sku, location_id) INCLUDE (delta);
CREATE INDEX ON inventory_movement (expires_at) WHERE expires_at IS NOT NULL;

Availability is then a sum, with expired reservations excluded:

-- On hand minus live reservations. Expired holds fall out automatically.
SELECT sku,
       SUM(delta) FILTER (
         WHERE expires_at IS NULL OR expires_at > now()
       ) AS available
FROM inventory_movement
WHERE sku = $1
GROUP BY sku;

For a catalogue of any size you'd materialise this into a summary table updated by trigger or by the same worker that writes the movement, and keep the ledger for audit. But start with the sum. Premature materialisation of an inventory number is how you end up with two sources of truth that disagree, which is worse than one slow query.

The UNIQUE (channel, external_ref, kind) constraint is the most valuable line in that schema. Marketplaces resend webhooks. eBay will deliver the same order notification twice if your endpoint was slow to acknowledge. Amazon's notifications are explicitly at-least-once. With that constraint, a replayed webhook produces a unique violation, which you catch and ignore. Without it, you decrement stock twice and now your numbers are wrong in a way that reconciliation will paper over and nobody will ever explain.

6. Buffers: The Blunt Instrument That Works

A buffer means telling a channel you have fewer units than you do. Ten in the warehouse, publish seven to eBay, keep three as absorption for the propagation window.

It is crude, it costs you sales, and it is the single most effective oversell prevention available for multi-quantity channels. I recommend it constantly and I'm always slightly embarrassed doing so, because it's admitting the sync isn't fast enough rather than making it fast enough. But the sync is never fast enough, because the propagation delay isn't yours to fix.

The buffer should not be a flat number. A flat buffer of three is enormous on a SKU with four units and negligible on one with four hundred.

// Buffer scales with how fast the SKU sells and how slow the channel is.
// velocity = units sold per hour over the trailing 7 days
function bufferFor(channel, sku, onHand, velocity) {
  // How long, realistically, between us learning of a sale and the
  // other channels reflecting it. Measured, not guessed.
  const propagationHours = {
    shopify: 0.02,   // ~1 minute, we control it
    ebay:    0.08,   // ~5 minutes p95 observed
    amazon:  0.25,   // ~15 minutes p95 for feed processing
    depop:   0.17    // ~10 minutes via our integrator
  }[channel] ?? 0.25;

  // Expected sales during the blind window, rounded up.
  const exposure = Math.ceil(velocity * propagationHours);

  // Never buffer a one-of-one into oblivion, and never buffer more
  // than a fifth of the pool away from a healthy SKU.
  if (onHand <= 2) return 0;
  return Math.min(exposure, Math.floor(onHand * 0.2), 10);
}

Note the onHand <= 2 escape. Buffering a SKU with two units down to one, or one down to zero, converts a small oversell risk into a guaranteed lost sale. For the one-of-one case — most of Depop — buffers are simply not a tool you have.

The honest tradeoff: on a client selling roughly 900 units a week across three multi-quantity channels, a velocity-scaled buffer took oversells from about eleven a month to under one, at a cost of somewhere between fifteen and twenty-five units a month of stock that showed as unavailable while sitting on a shelf. They considered that an excellent trade. A seller with thinner margins might not.

7. The Race Condition You Cannot Design Away

Two buyers, one item, two channels, same second. This is the case that has no clean solution, and being clear-eyed about that is more useful than pretending otherwise.

What you can do is make the window small and make the losing case handled gracefully.

Small window: event-driven push, and delisting rather than zeroing where the channel supports it. Setting quantity to zero on eBay leaves the listing findable and, on some listing types, buyable again the moment your next sync gets confused. Ending the listing is unambiguous.

// For one-of-one stock, don't set quantity 0 — end the listing.
// Fire all channels in parallel; a sequential loop means the fourth
// channel learns about the sale four round trips late.
async function itemSold(sku, winningChannel) {
  const listings = await activeListings(sku);
  const others = listings.filter(l => l.channel !== winningChannel);

  const results = await Promise.allSettled(
    others.map(l => withTimeout(endListing(l), 4000))
  );

  // Anything that failed goes to a retry queue with backoff. It does
  // NOT block the response — the sale already happened.
  results.forEach((r, i) => {
    if (r.status === 'rejected') {
      queue.publish('channel.delist.retry', {
        listing: others[i],
        attempt: 1,
        reason: String(r.reason)
      });
    }
  });
}

Promise.allSettled rather than Promise.all is deliberate. With all, one channel timing out means you never find out whether the other two succeeded, and you retry all three. With allSettled you retry only what failed.

Graceful losing case: when you do oversell, the response matters more than the incident. Detect it within minutes, not at the next day's picking round. Contact the buyer before they contact you. Offer something concrete rather than an apology. On Depop specifically, a message within the hour that offers a refund plus a discount on anything else in the shop converts a one-star review into no review at all a meaningful proportion of the time. That's not engineering, but it's the part that protects the account, and the engineering exists to make it possible.

8. Listing Sync Is A Different Problem To Inventory Sync

Teams conflate these and then build one pipeline that does both badly.

Inventory sync is high-frequency, low-payload, latency-critical and idempotent. One number, pushed often, where being late is the only real failure.

Listing sync is low-frequency, high-payload, latency-tolerant and full of channel-specific transformation. Title limits, category taxonomies, image count and aspect ratio rules, required attributes that differ per category, prohibited-word lists.

Run them as separate pipelines with separate queues and separate rate-limit budgets. If they share a queue, a bulk listing update of 4,000 products will starve your inventory pushes for twenty minutes, which is precisely the failure mode you built the event system to avoid.

// Separate transport, separate priority, separate budget.
const queues = {
  inventory: new Queue('inv',  { concurrency: 8, rateLimit: 'high' }),
  listings:  new Queue('list', { concurrency: 2, rateLimit: 'low'  }),
  orders:    new Queue('ord',  { concurrency: 4, rateLimit: 'high' })
};

// Listing pushes yield to inventory pushes when the shared API
// budget gets tight. Inventory correctness beats catalogue freshness.
queues.listings.pause = () => rateLimiter.remaining('ebay') < 200;

That last rule — listings yield to inventory when the budget is low — is one I'd hold firmly. A stale product description costs nothing. A stale quantity costs a refund and a rating.

9. Taxonomy And Attribute Mapping

This is the unglamorous work that consumes sixty percent of the project timeline and appears in none of the estimates.

Your internal catalogue says a product is a "Women's Denim Jacket, Size 12, Blue, Levi's, 1990s". eBay wants a leaf category id, a set of item specifics with names it dictates, and a condition code. Amazon wants a browse node, a product type definition with dozens of required and conditional attributes, and probably a GTIN you don't have for a vintage item. Depop wants a much simpler set — category, size, brand, condition — but its size taxonomy doesn't map cleanly to anyone else's because it mixes UK sizes, US sizes and letter sizes in one list.

Build the mapping as data, not code. A table per channel, versioned, editable by the merchandising team without a deploy.

CREATE TABLE channel_attribute_map (
  channel        TEXT NOT NULL,
  internal_field TEXT NOT NULL,   -- 'size', 'colour', 'condition'
  internal_value TEXT NOT NULL,   -- 'UK 12'
  external_field TEXT NOT NULL,   -- 'Size' / 'size_name'
  external_value TEXT NOT NULL,   -- 'UK 12' / '12' / 'M'
  category_scope TEXT,            -- some maps only apply within a category
  PRIMARY KEY (channel, internal_field, internal_value, category_scope)
);

-- The query that matters: which mappings are missing?
-- Run this before every bulk publish, not after it fails.
SELECT DISTINCT p.size
FROM product p
LEFT JOIN channel_attribute_map m
  ON m.channel = 'depop'
 AND m.internal_field = 'size'
 AND m.internal_value = p.size
WHERE m.external_value IS NULL
  AND p.status = 'active';

That last query is the difference between a controlled rollout and a Monday morning of 400 listing rejections. Validate mappings exist before you publish. Marketplaces reject listings asynchronously and often with error text that means nothing, and tracing "error 21916584" back to a missing size mapping is an afternoon you don't need to spend.

10. Rate Limits, Backoff, And The Budget You Should Be Watching

Every marketplace meters you differently and none of them meter you generously.

eBay allocates daily call quotas per API and per application, with the useful property that you can query your remaining allowance. Amazon's Selling Partner API uses a token bucket per operation with a restore rate, and returns the state in response headers. Depop, through an integrator, gives you whatever the integrator gives you, which is usually less than you'd like and rarely documented.

Track spend as a first-class metric. Not as a log line — as a gauge on a dashboard with an alert at seventy percent.

// Token bucket that reads the marketplace's own headers rather than
// guessing. Guessing is how you get banned during a promotion.
class ChannelLimiter {
  constructor(channel, floor = 100) {
    this.channel = channel;
    this.remaining = Infinity;
    this.resetAt = 0;
    this.floor = floor;          // reserve for order ingestion
  }

  observe(headers) {
    const r = headers.get('x-ratelimit-remaining');
    if (r != null) this.remaining = Number(r);
    const reset = headers.get('x-ratelimit-reset');
    if (reset) this.resetAt = Number(reset) * 1000;
    metrics.gauge('marketplace.quota.remaining',
                  this.remaining, { channel: this.channel });
  }

  // Low-priority work stops well before the quota is actually gone.
  canSpend(priority) {
    if (priority === 'critical') return this.remaining > 0;
    return this.remaining > this.floor;
  }
}

On backoff: retry with exponential delay and jitter, and treat 429 differently to 5xx. A 429 with a Retry-After header is an instruction, and ignoring it to retry after your own computed delay is how an application gets throttled harder. A 5xx is the marketplace having a bad time and you should back off aggressively because so is everyone else, which is exactly when a thundering herd of synchronised retries makes it worse. Jitter is not optional.

11. Order Ingestion, Deduplication And Ordering

Orders flow the other way, and they bring their own problems.

Every channel will deliver you the same order more than once eventually. Webhook retries, a resync after an outage, an operator clicking "reimport". Your ingestion has to be idempotent on the marketplace's order id, and that has to be enforced at the database level rather than by an if (exists) check, because two workers will run that check simultaneously and both will find nothing.

-- Enforced in the schema. Application-level checks race; this doesn't.
CREATE TABLE marketplace_order (
  id              BIGSERIAL PRIMARY KEY,
  channel         TEXT NOT NULL,
  external_id     TEXT NOT NULL,
  external_status TEXT NOT NULL,
  -- Monotonic sequence from the channel where one exists. Used to
  -- reject out-of-order status updates instead of applying them.
  external_seq    BIGINT,
  payload         JSONB NOT NULL,
  received_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (channel, external_id)
);
// Insert-or-ignore, then only advance status if the update is newer.
async function ingestOrder(channel, order) {
  const res = await db.query(`
    INSERT INTO marketplace_order (channel, external_id, external_status,
                                   external_seq, payload)
    VALUES ($1, $2, $3, $4, $5)
    ON CONFLICT (channel, external_id) DO UPDATE
      SET external_status = EXCLUDED.external_status,
          payload         = EXCLUDED.payload
      -- The guard: a replayed older webhook changes nothing.
      WHERE marketplace_order.external_seq IS NULL
         OR EXCLUDED.external_seq > marketplace_order.external_seq
    RETURNING (xmax = 0) AS inserted`,
    [channel, order.id, order.status, order.seq ?? null, order]);

  // Only decrement stock when the row was genuinely new.
  if (res.rows[0]?.inserted) {
    await recordSale(channel, order);
  }
}

The xmax = 0 trick tells you whether Postgres inserted or updated, which is what you need to decide whether to touch inventory. It's obscure and it works.

Out-of-order status updates are the subtler problem. A "shipped" webhook arriving before the "paid" webhook it supersedes is common when a marketplace retries the earlier one after a delay. Without a sequence guard you'll flip an order back from shipped to paid and confuse your fulfilment team, who will then ship it twice.

12. Returns, Cancellations And Restocking

The reverse flow gets built last and tested least, and it's responsible for more slow-burning inventory drift than the forward flow.

A cancellation before dispatch should return stock to the pool immediately. A return after dispatch should not — the item is in a van, or a sorting office, or a customer's hallway, and putting it back into available stock creates a phantom unit that will be oversold. Restock on physical receipt and inspection, not on the return being authorised.

That sounds obvious and roughly half the integrations I've reviewed get it wrong, usually because the marketplace's return webhook fires at authorisation and the developer wired the obvious event to the obvious action.

// Returns move through states. Only one of them touches availability.
const RETURN_TRANSITIONS = {
  requested:  () => {},                       // nothing
  authorised: () => {},                       // still nothing
  in_transit: () => {},                       // still nothing
  received:   (r) => markAwaitingInspection(r),
  // Grading matters: a damaged vintage item does not go back on Depop.
  graded_sellable:   (r) => restock(r.sku, 1, 'return-sellable'),
  graded_damaged:    (r) => writeOff(r.sku, 1, 'return-damaged'),
  graded_secondgrade:(r) => restock(r.sku, 1, 'return-b-grade', 'outlet')
};

The last line matters for apparel resellers specifically. A returned garment often can't go back to the same listing, because the listing photographed a specific item in a specific condition. It goes to an outlet channel or gets relisted with new photos. Modelling that as "restock to a different location" rather than "restock" keeps the numbers honest.

13. Reconciliation: The Nightly Truth

However good the event pipeline, the numbers drift. A webhook was lost. A worker crashed mid-flight. Someone edited a quantity in a marketplace's own admin UI, which people absolutely do and which generates no event you'll see.

So you reconcile. Every night, pull every channel's current view of quantity, compare with yours, and produce a report of disagreements.

What matters is what you do with the disagreements, and the answer is not "automatically correct them all". An automatic correction that pushes your number to a channel is fine when your number is higher. When the channel's number is lower than yours, that often means a sale you haven't ingested, and overwriting it upward will cause an oversell. Direction matters.

async function reconcile(channel) {
  const [theirs, ours] = await Promise.all([
    fetchAllChannelQuantities(channel),   // paginated, slow, run off-peak
    internalAvailability()
  ]);

  const report = [];
  for (const [sku, theirQty] of theirs) {
    const ourQty = ours.get(sku) ?? 0;
    if (theirQty === ourQty) continue;

    if (theirQty > ourQty) {
      // They think there's more than there is. Always safe to correct
      // downward, and this is the oversell-causing direction.
      await pushQuantity(channel, sku, ourQty);
      report.push({ sku, theirQty, ourQty, action: 'corrected-down' });
    } else {
      // They think there's less. Could be an unlogged sale. Do NOT
      // push up automatically — flag it for a human.
      report.push({ sku, theirQty, ourQty, action: 'flagged-review' });
    }
  }
  await emailReport(channel, report);
  metrics.gauge('reconcile.drift', report.length, { channel });
  return report;
}

Track the count of discrepancies as a metric over time. It's the single best health indicator for a sync system. A steady two or three a night is normal noise. A jump to forty means something broke yesterday and you have until tomorrow's promotion to find it.

14. Build Or Buy

I'll give you my actual opinion rather than a balanced table, because the balanced table is what every vendor comparison page already is.

If you sell on three or fewer channels, under roughly 500 orders a month, with a fairly standard catalogue: buy. Linnworks, Codisto, Sellbrite, ChannelAdvisor at the enterprise end. The engineering cost of building this properly is three to five months of a good developer, and the maintenance cost is permanent because marketplace APIs change under you without asking. That is not worth it to avoid a few hundred pounds a month.

If you have unusual inventory semantics — one-of-one items, made-to-order, bundles that decompose into components, serialised stock, multi-location allocation — build the inventory core yourself and use connectors for transport. The off-the-shelf tools are built around a quantity-per-SKU model and every one of those semantics fights it. I've watched a client spend nine months trying to express a bundle-decomposition rule in a channel manager's configuration UI before accepting that the tool could not represent the thing.

If you have volume and margin — thousands of orders a month, and enough gross profit that a two percent oversell rate is real money — build. At that point you want the control, and you can afford the team.

The middle path that I've seen work best: buy the connectors, own the truth. Let a third party handle the grim reality of eBay's Trading API and Amazon's feed processing, but keep the availability ledger, the allocation logic and the reconciliation in your own system. You get out of the API-maintenance business without giving up the ability to express what your business actually does. The general shape of that argument — own the parts that encode your business rules, rent the parts that are commodity plumbing — applies well beyond marketplaces, and shows up again in ERP integration work.

15. Allocation: Which Channel Gets The Last One

Once stock is scarce, publishing the same number everywhere is not the only option, and often not the best one.

Allocation means deciding, per SKU, how the available pool is divided between channels. Twelve units, four to eBay, four to Depop, four to your own store. It eliminates cross-channel contention entirely at the cost of stranding stock in a channel that isn't selling it.

It is worth doing when your channels have genuinely different margins — and they do. A sale on your own Shopify store keeps close to full margin. eBay takes a final value fee in the region of twelve to fifteen percent depending on category. Depop's fee structure has changed more than once and is worth checking against current rates rather than what you remember. Amazon's referral fee plus FBA costs can be a third of the sale price.

So a naive equal split is leaving money on the table. A margin-weighted allocation with a floor for each channel does better.

// Weight by contribution margin, floor at 1 so no channel goes dark,
// and hold back a reserve for the direct store where margin is best.
function allocate(onHand, channels) {
  if (onHand <= channels.length) {
    // Too few to split. Give them all to the highest-margin channel
    // and delist elsewhere; contention is worse than concentration.
    const best = channels.reduce((a, b) => a.margin > b.margin ? a : b);
    return Object.fromEntries(
      channels.map(c => [c.name, c === best ? onHand : 0])
    );
  }

  const totalWeight = channels.reduce((s, c) => s + c.margin * c.velocity, 0);
  const alloc = {};
  let assigned = 0;

  for (const c of channels) {
    const share = Math.max(1, Math.floor(
      onHand * (c.margin * c.velocity) / totalWeight));
    alloc[c.name] = share;
    assigned += share;
  }
  // Remainder to the best channel rather than round-robin.
  const best = channels.reduce((a, b) => a.margin > b.margin ? a : b);
  alloc[best.name] += onHand - assigned;
  return alloc;
}

I would not start here. Allocation adds a whole layer of policy that someone has to own and tune, and for most sellers a shared pool with buffers outperforms a badly-tuned allocation. Reach for it when you have evidence that cross-channel contention is costing you more than stranded stock would.

16. Observability: What To Put On The Wall

A sync system that fails silently is worse than no sync system, because people trust it. Four numbers I'd want visible.

Propagation latency, per channel, p95. Time from an inventory event being published to the channel's API confirming the write. This is the number your buffer sizes depend on, and it moves — marketplaces get slower under seasonal load, which is exactly when your buffers need to be bigger.

Oversell count, daily. Defined as orders accepted for a SKU whose available quantity was already zero or negative. This is the metric the business cares about and the only one worth waking someone up for.

Reconciliation drift, nightly. Count of SKUs where your number and the channel's number disagreed. Trend it. A rising baseline means the event path is degrading before it fails outright.

API quota headroom, per channel. Percentage of daily allowance remaining at each hour. Alert at seventy percent consumed, not at ninety-five, because by ninety-five you have no room to react.

// The alert that actually matters. Everything else is a dashboard.
async function checkOversellRate() {
  const { rows } = await db.query(`
    SELECT channel, COUNT(*) AS n
    FROM marketplace_order o
    JOIN oversell_flag f ON f.order_id = o.id
    WHERE o.received_at > now() - interval '1 hour'
    GROUP BY channel`);

  for (const r of rows) {
    // One is noise. Three in an hour means the pipeline is broken.
    if (r.n >= 3) {
      await page('marketplace-sync',
        `${r.n} oversells on ${r.channel} in the last hour`);
    }
  }
}

17. A Worked Example

Same Manchester reseller, six months after the Black Friday incident. Roughly 6,000 active listings, 78% one-of-one vintage, four channels, about 1,400 orders a month.

Starting point. Fifteen-minute polling from a channel manager. Oversell rate 2.9% of orders — around forty a month. Depop rating 4.6. Two staff spending an estimated six hours a week manually cross-checking listings.

What we built. A Postgres ledger as the source of truth. Shopify webhooks and the channel manager's order feed both writing movements. An event bus fanning out to delist workers. Nightly reconciliation. Roughly eleven weeks of work for one developer plus my time.

Month one after launch. Oversell rate 0.7%. Propagation p95 down from a fifteen-minute worst case to 9 seconds on Shopify, 46 seconds on eBay, and — this was the disappointment — 4 minutes 20 on Depop, because the integrator batched delist calls on a two-minute cycle we couldn't influence.

Month three. Oversell rate 0.3%. Depop rating back to 4.8. The manual cross-checking stopped entirely, which the owner valued more than the oversell reduction because it was six hours a week of a person's actual life.

What went wrong along the way. Two things worth reporting honestly.

We introduced a regression in week six that was worse than the original problem. The delist worker treated a 404 from eBay as a retryable error. eBay returns 404 for a listing that has already ended — which is the normal case when two of our workers both handled the same sale. So every concurrent sale generated a retry storm, and we burned through 80% of the daily eBay quota by 10am on a Tuesday. Order ingestion then failed for four hours because it had no quota left, which is how we learned that the quota floor for critical operations needed to be reserved rather than shared. That reserved floor in the limiter above exists because of that Tuesday.

And the Depop latency never got better. We tried three integrators and the best of them was two minutes at p95. For a one-of-one catalogue, two minutes is a real exposure window and we could not close it. The mitigation was operational rather than technical: they stopped listing their highest-value pieces on more than one channel at a time. About 400 items, listed exclusively on whichever channel historically sold that category best. Oversell risk on those went to zero because contention went to zero. I would have preferred an engineering answer. Sometimes the answer is that the constraint is real and you route around it.

18. What Goes Wrong

Treating an API 200 as confirmation the listing changed. It confirms receipt. Poll back, or subscribe to the channel's own change notification, if the difference matters.

Echo loops. Channel A reports a sale, you push to B, B's integration reports a change, you push back to A. Tag every event with its origin channel and never fan out to the source.

Application-level idempotency checks. "Select then insert" races. Use a unique constraint.

Restocking on return authorisation. The item isn't back. Restock on receipt and grading.

Sharing a rate-limit budget between bulk listing work and inventory pushes. The bulk work will starve the urgent work at the worst possible moment.

Reconciling by overwriting in both directions. Correct downward automatically, flag upward for a human. The upward case is usually an order you haven't seen.

No dead letter queue. Failed channel updates that vanish are invisible drift. Every failure should land somewhere a person eventually looks.

Testing only the happy path. The interesting cases are duplicate webhooks, out-of-order status updates, a channel returning 503 for twenty minutes, and two sales in the same second. If your test suite has none of those, it tests nothing that will actually happen.

19. Questions That Come Up

"How fast does sync actually need to be?" Work backwards from your arrival rate. If a SKU sells four times an hour, a five-minute window gives roughly a one-in-three chance of a contested sale during any given window. If it sells four times a month, fifteen minutes is fine. Compute exposure per SKU rather than picking an interval that feels responsive.

"Can I just use a channel manager and skip all this?" For a straightforward catalogue, yes, and you probably should. The argument for building starts when your inventory semantics don't fit a quantity-per-SKU model, or when the volume makes the oversell rate expensive enough to fund a team.

"What's an acceptable oversell rate?" Under 0.5% of orders is achievable with event-driven sync and buffers. Under 0.1% needs either allocation or single-channel exclusivity on contested items. Zero is not achievable across channels you don't control, and any vendor claiming otherwise is describing a buffer and calling it magic.

"Should stock live in the ERP or the ecommerce platform?" Wherever the physical count is maintained, which is usually the ERP or WMS. What matters more is that exactly one system owns it and everything else derives. Two systems that both think they own stock will disagree, and the disagreement will surface as an oversell.

"Does this change with fulfilment by the marketplace?" Substantially. If units are physically in FBA, that's a separate location the marketplace controls and reports on, not part of your sellable pool. Model it as its own location with its own movements. Trying to treat FBA stock as part of a shared pool is a recurring source of phantom availability.

"How do I test this without risking real listings?" eBay and Amazon both have sandboxes, and both sandboxes behave differently to production in ways that will surprise you. Use them for shape and contract testing. For behaviour, use a small set of genuinely listed low-value items in production and accept that as a cost. Depop, via an integrator, will typically give you a test account only if you ask specifically.

"Is webhook or polling better for order ingestion?" Both. Webhooks for latency, a polling sweep every fifteen minutes for the ones the webhook lost. Marketplaces lose webhooks and none of them will tell you they did.

20. What I'd Do First

If you're staring at a channel setup that oversells and you don't know where to start, this is the order.

Measure your actual oversell rate for the last ninety days. Orders where available stock was already zero, divided by total orders, split by channel. Most sellers do not know this number, and it decides whether any of the rest is worth doing. Under half a percent and your time is better spent elsewhere.

Then measure propagation latency per channel. Change a quantity, poll the listing until it reflects, record the delta. Do it twenty times per channel at different times of day. That distribution is the input to every other decision here, and it takes an afternoon.

Then put a unique constraint on your order ingestion if there isn't one. It is one migration and it eliminates an entire category of silent corruption.

Then switch from polling to event-driven push on the highest-velocity channel only. Not all of them. One, so you can attribute the change, and so the blast radius of getting it wrong is one channel rather than four.

Then add nightly reconciliation with the downward-only auto-correction. This gives you the drift metric, and the drift metric is what tells you whether everything else is working.

Then, if you still have contention on specific items, consider exclusivity before you consider allocation. Listing a one-of-one piece on a single channel is a five-minute decision that removes the race entirely, and for high-value items it's frequently the right answer even though it feels like a retreat.

The thing I'd want you to take from the Manchester story isn't the architecture. It's that their system worked perfectly for eighteen months and then failed on the day that mattered, because the assumption underneath it was about timing and nobody had written the assumption down. Every sync system has one of those. Find yours, write it on the wall, and check once a quarter whether it's still true.

Suggested & Related Reading

Explore related engineering guides from Kenneth D'Silva: