Shopify and eBay Inventory Sync Without Overselling: Build a Source-of-Truth Ledger
What happens when Shopify and eBay sell the last unit together?
You have one unit left.
At 10:03:14, a Shopify customer completes checkout. At 10:03:15, an eBay buyer completes checkout for the same physical item. Your connector has not pushed the Shopify decrement to eBay yet. Both channels accepted an order that looked valid at the moment each channel checked its own quantity.
By the time your sync catches up, both platforms show zero. The dashboard looks correct. The warehouse still has one unit and two paid customers.
This is where most “real-time inventory sync” claims become useless. The final displayed quantities can match perfectly after the oversell has already happened. The failure exists in the interval between reservation decisions, not in the eventual number shown on each product page.
I have worked enough marketplace operations to distrust any design whose whole logic is “when Shopify quantity changes, copy it to eBay, and when eBay quantity changes, copy it to Shopify.” That is not one inventory system. It is two editable counters chasing each other over APIs.
The architecture that holds up is less glamorous. You create one allocation authority, record every reservation and release, publish channel quantities as projections, and reconcile those projections against Shopify and eBay. Webhooks make it faster. They do not make it true.
The open loop is the second buyer. Even with a central ledger, an external marketplace can accept an order before your zero-quantity update reaches it. The design can reduce and contain that risk. It cannot repeal network delay.
[REAL NUMBER] Insert the measured interval between receiving a paid order on one channel and the other channel confirming the reduced quantity, calculated from your own event and API logs.
What does one oversold order really cost?
The unit cost is not the item price.
An oversell creates a decision your system should have made before payment. Now a person has to choose which customer gets the item, whether replacement stock can arrive in time, whether to cancel, what message to send, and how to repair the marketplace record.
On eBay, seller performance and buyer experience matter. A cancellation caused by unavailable stock is not equivalent to a normal order-management event. On Shopify, the merchant may refund, split fulfillment, offer a substitute, or delay shipment. Each choice has a different operational and customer cost.
Then inventory gets more confusing.
Suppose operations cancels the Shopify order and restocks the unit there. The eBay order remains valid. A two-way connector sees Shopify increase from zero to one and publishes one back to eBay before the eBay order has been allocated in the central warehouse system. You have restored sellable quantity that does not exist.
The oversell becomes a second oversell.
A proper incident record separates physical stock, reservations, channel projections, and customer resolution.
create table inventory_incidents (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
canonical_sku_id uuid not null,
incident_type text not null,
detected_at timestamptz not null default now(),
resolved_at timestamptz,
physical_on_hand numeric,
reserved_quantity numeric,
available_to_sell numeric,
shopify_reported_quantity numeric,
ebay_reported_quantity numeric,
affected_order_keys text[] not null default '{}',
customer_resolution text,
root_cause text,
repair_actions jsonb not null default '[]'
);The cost normally appears in six places:
refund or replacement expense
support and warehouse labor
shipping changes or expedited procurement
marketplace account risk
lost buyer trust
inventory cleanup across future ordersDo not measure oversells only by cancelled-order count. Some teams save the order by buying stock elsewhere, splitting a bundle, or delaying shipment. That still represents allocation failure and should be counted.
A useful metric is reservation conflicts per accepted paid order. Another is channel exposure time, the duration a channel advertised more stock than the allocation authority considered sellable.
[REAL NUMBER] Insert the full cost of one resolved oversell, including staff time, customer credit, replacement sourcing, shipping, and marketplace fees that were not recovered.
Why does two-way inventory sync break?
Two-way sync breaks because quantity is not a self-explanatory field.
A number can mean on hand, available, reserved, incoming, damaged, committed to another channel, or intentionally withheld as a safety buffer. Shopify exposes multiple inventory quantity concepts and locations. eBay has inventory-item and offer-level availability behavior. A connector that copies one “quantity” field into another has already made assumptions.
The second problem is source ambiguity.
If Shopify changes from five to four, did a Shopify order reserve one unit? Did a staff member correct a count? Did your own connector write four after an eBay order? Did a return add one and a separate damage adjustment subtract two? The final number does not preserve the reason.
The third problem is feedback.
eBay order reduces eBay
connector writes lower value to Shopify
Shopify webhook fires
connector interprets webhook as new Shopify change
connector writes value back to eBayA careful connector may notice no numeric difference and stop. A less careful one can create repeated writes, API usage, stale overwrites, or oscillation when location rounding and buffers differ.
The fourth problem is concurrent writers. Staff can edit Shopify. Staff can revise eBay. A warehouse system can update stock. A returns process can add quantity. Your connector is not the only actor.
The fifth problem is eventual delivery. Shopify and eBay events can arrive late, repeat, or fail. Your worker can crash after an API update succeeds. The API can return a timeout after accepting the write. A later event can carry an older state.
A concrete failure looks like this:
10:00 source ledger says 5
10:01 eBay order reserves 1, ledger says 4
10:01 connector starts Shopify write to 4
10:02 warehouse receives 3, ledger says 7
10:02 Shopify write to 4 completes late
10:03 Shopify webhook reports 4
10:03 naive connector overwrites ledger from 7 to 4Every individual API call can be successful. The final stock is wrong because an old projection was mistaken for a new source fact.
You need causality. Every inventory change needs a reason, source, source operation ID, and ledger version.
create table inventory_ledger_entries (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
canonical_sku_id uuid not null,
location_id uuid not null,
quantity_delta numeric not null,
entry_type text not null,
source_system text not null,
source_operation_id text not null,
source_occurred_at timestamptz,
ledger_version bigint not null,
metadata jsonb not null default '{}',
created_at timestamptz not null default now(),
unique (merchant_id, source_system, source_operation_id, entry_type)
);Examples of entry_type are receipt, shopify_reservation, ebay_reservation, reservation_release, damage, manual_correction, transfer_out, and transfer_in.
The ledger entry is a fact. Channel quantity is a projection derived from facts and policy.
[DIAGRAM] Draw the naive two-way loop between Shopify and eBay, then draw the corrected hub model with both channels reading from and writing events into a central inventory ledger. Mark channel quantities as projections, not authority.
Which system should be the inventory source of truth?
Choose one system that is allowed to declare physical and allocatable stock.
That can be a warehouse management system, ERP, purpose-built Postgres ledger, or Shopify in a simple operation where Shopify truly owns receiving, corrections, locations, and all sales channels. The wrong answer is “Shopify and eBay together.”
Shopify's current `inventorySetQuantities` documentation gives a useful clue. It says the mutation should be used when the caller acts on behalf of a system that is the source of truth for inventory quantities. It also recommends compare-and-set protection for concurrent updates. That language matches the architecture: an absolute set operation is safe only when the caller has authority to state the number.
For a merchant selling across Shopify and eBay, I usually prefer a neutral ledger when any of these are true:
multiple warehouses or stock locations
bundles or kits share components
manual eBay listing operations exist
returns can arrive outside Shopify
other channels will be added
channel-specific safety buffers are required
warehouse receiving is not performed in ShopifyA simple Shopify-led model can work when Shopify is where every physical adjustment is recorded and eBay is only a published sales channel. In that model, eBay orders still need to reserve stock in Shopify or in an adjacent reservation service quickly. Do not wait for a periodic “copy eBay sold quantity into Shopify” job.
A neutral ledger becomes the allocation authority:
on_hand
minus active reservations
minus safety stock
minus non-sellable stock
plus or minus approved channel policy
= available to allocateThen each channel receives a projected quantity.
shopify_publishable = min(available_to_allocate, shopify_cap)
ebay_publishable = min(available_to_allocate, ebay_cap)That formula alone is unsafe because both channels can each advertise the full available amount. The actual publication policy may split exposure or use channel buffers.
For one unit, you may choose:
Shopify 1, eBay 0or:
Shopify 0, eBay 1Publishing one on both channels accepts oversell risk during propagation. Some merchants accept it for conversion. Others do not. Architecture cannot decide the commercial policy.
I store the policy explicitly:
create table channel_allocation_policies (
merchant_id uuid not null,
canonical_sku_id uuid not null,
channel text not null,
priority integer not null,
safety_buffer numeric not null default 0,
max_exposed numeric,
low_stock_exclusive boolean not null default false,
enabled boolean not null default true,
primary key (merchant_id, canonical_sku_id, channel)
);When low_stock_exclusive is true, the allocator exposes the last units to only the highest-priority channel.
That is an opinionated choice. It reduces oversells and may reduce sales on the lower-priority channel. Make the tradeoff visible.
What should the inventory ledger actually store?
Store movements, reservations, balances, and projections separately.
One mutable quantity column cannot explain how it reached the current value or reverse one order safely.
The core tables can look like this:
create table canonical_skus (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
sku text not null,
title text,
unit_of_measure text not null default 'each',
active boolean not null default true,
created_at timestamptz not null default now(),
unique (merchant_id, sku)
);
create table inventory_locations (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
location_key text not null,
name text not null,
sellable boolean not null default true,
unique (merchant_id, location_key)
);
create table inventory_balances (
merchant_id uuid not null,
canonical_sku_id uuid not null references canonical_skus(id),
location_id uuid not null references inventory_locations(id),
on_hand numeric not null default 0,
reserved numeric not null default 0,
non_sellable numeric not null default 0,
ledger_version bigint not null default 0,
updated_at timestamptz not null default now(),
primary key (merchant_id, canonical_sku_id, location_id)
);Reservations need their own lifecycle.
create type reservation_status as enum (
'pending',
'active',
'committed',
'released',
'cancelled',
'expired',
'conflict'
);
create table inventory_reservations (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
canonical_sku_id uuid not null,
location_id uuid not null,
channel text not null,
external_order_id text not null,
external_line_id text not null,
quantity numeric not null check (quantity > 0),
status reservation_status not null,
source_event_id uuid,
created_at timestamptz not null default now(),
committed_at timestamptz,
released_at timestamptz,
unique (merchant_id, channel, external_order_id, external_line_id)
);The unique constraint makes repeated order events return the same reservation.
Channel projections record desired and observed quantities.
create table channel_inventory_projections (
merchant_id uuid not null,
canonical_sku_id uuid not null,
channel text not null,
channel_account_id text not null,
channel_item_id text not null,
channel_location_id text,
channel_location_key text not null default '',
desired_quantity numeric not null,
desired_ledger_version bigint not null,
observed_quantity numeric,
observed_at timestamptz,
last_write_id uuid,
write_status text not null default 'pending',
last_error jsonb,
updated_at timestamptz not null default now(),
primary key (
merchant_id,
channel,
channel_account_id,
channel_item_id,
channel_location_key
)
);Populate channel_location_key with the external location ID when the projection is location-scoped, and keep the empty string only for a deliberate account-wide projection. The primary key then enforces one projection per channel item and scope with valid PostgreSQL syntax.
Keep ledger version monotonic per SKU-location balance. When a new movement commits, increment the version and calculate new desired channel projections in the same transaction or through an outbox event.
begin;
select *
from inventory_balances
where merchant_id = $1
and canonical_sku_id = $2
and location_id = $3
for update;
-- Validate availability and insert ledger or reservation records.
-- Update balance and increment ledger_version.
-- Insert projection recalculation outbox record.
commit;The ledger is append-oriented. Corrections are new entries, not edits to old history. If a warehouse count was wrong, add a correction with reason and approver.
How do you map Shopify variants to eBay SKUs and offers?
Do not assume the displayed SKU string is enough.
Shopify has product variants, inventory items, and location-specific inventory levels. eBay's Inventory API uses seller-defined SKU inventory items, offers, listings, and inventory locations. One eBay SKU can have one or more offers across marketplaces, and offer-level quantity can override inventory-item availability in specific cases.
Your mapping needs stable platform IDs and a canonical SKU.
create table channel_item_mappings (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
canonical_sku_id uuid not null references canonical_skus(id),
channel text not null,
channel_account_id text not null,
external_product_id text,
external_variant_id text,
external_inventory_item_id text,
external_listing_id text,
external_offer_id text,
external_sku text,
marketplace_id text,
active boolean not null default true,
verified_at timestamptz,
metadata jsonb not null default '{}'
);
create unique index channel_mapping_unique_active
on channel_item_mappings (
merchant_id,
channel,
channel_account_id,
coalesce(external_variant_id, ''),
coalesce(external_offer_id, ''),
coalesce(marketplace_id, '')
)
where active = true;The canonical SKU should be merchant-controlled and immutable enough to survive title changes. Do not use product title as identity. Do not silently trim or uppercase SKUs unless the merchant approves canonicalization, because ABC-01 and abc-01 may be distinct in one source.
A concrete mapping problem is a Shopify variant with SKU BRAKE-PAD-01 and two eBay offers, one on eBay US and one on eBay UK, both backed by the same eBay inventory item. The offers can have different prices and possibly different quantity policies. Your mapping must distinguish the shared physical stock from the offer projections.
The hierarchy becomes:
canonical SKU
-> Shopify inventory item
-> Shopify location inventory levels
-> eBay inventory item SKU
-> eBay US offer
-> eBay UK offerDo not let an eBay listing ID become your canonical SKU. Listings can end and be republished. Offers and listing relationships can change. Store all identifiers and their validity periods.
Mappings need a verification job. It should detect:
missing Shopify variant or inventory item
duplicate active mappings
changed eBay offer or listing association
SKU edited on one platform
inactive or ended listing still receiving projections
location mapping removed
one canonical SKU mapped to incompatible unitsIf operations remaps a channel item, do not immediately push inventory. Preview the before and after relationships. A wrong mapping can zero the wrong listing or expose another product's stock.
I require a two-step change for high-volume stores:
propose mapping
validate remote objects and units
show affected projections
approve and activate[DIAGRAM] Draw the canonical SKU mapping from one physical item to a Shopify variant and inventory item, multiple Shopify locations, an eBay inventory item SKU, and multiple eBay offers or marketplaces.
How do you reserve stock before channel updates finish?
Reserve in the central ledger as soon as you have authoritative order evidence.
Do not wait for the channel quantity write to complete before reducing allocatable stock. The write is a projection. Reservation is the business decision.
For a Shopify paid or committed order event, create one reservation per canonical SKU and line. For an eBay ORDER_CONFIRMATION, use the order ID and line-item ID. eBay's current Notification API documentation says ORDER_CONFIRMATION is sent to the seller when checkout completes and payment clears for a fixed-price or auction listing. The payload includes an order ID and order line items with quantities.
The reservation transaction should lock the balance.
begin;
select on_hand, reserved, non_sellable, ledger_version
from inventory_balances
where merchant_id = $1
and canonical_sku_id = $2
and location_id = $3
for update;
insert into inventory_reservations (
merchant_id,
canonical_sku_id,
location_id,
channel,
external_order_id,
external_line_id,
quantity,
status,
source_event_id
)
values ($1, $2, $3, $4, $5, $6, $7, 'active', $8)
on conflict (merchant_id, channel, external_order_id, external_line_id)
do nothing;
-- Only increment reserved when the reservation insert is new.
-- Validate that on_hand - reserved - non_sellable can cover the request.
commit;You need to know whether the insert was new. Use returning id or a common table expression, then update the balance only for a newly inserted reservation.
If available quantity is insufficient, mark the reservation conflict and open an incident. Do not create negative stock silently unless the business intentionally allows backorders and has a separate backorder model.
A simplified function can return a clear outcome:
type ReservationResult =
| { status: 'reserved'; reservationId: string; ledgerVersion: number }
| { status: 'duplicate'; reservationId: string; ledgerVersion: number }
| {
status: 'conflict';
requested: number;
available: number;
incidentId: string;
};After reservation commits, calculate desired quantities for every affected channel. Insert write intents into an outbox.
create table inventory_write_outbox (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
canonical_sku_id uuid not null,
channel text not null,
projection_key text not null,
desired_quantity numeric not null,
ledger_version bigint not null,
status text not null default 'pending',
attempt_count integer not null default 0,
next_attempt_at timestamptz,
created_at timestamptz not null default now(),
unique (channel, projection_key, ledger_version)
);Do not send every intermediate version if three reservations happen quickly. The dispatcher can collapse pending writes per projection and publish the newest desired ledger version, provided no required delta operation is skipped.
For absolute channel projections, newest state wins. For audit-only delta operations, every movement may matter. Know which API semantics you are using.
The reservation closes the internal race. It does not stop the eBay storefront from accepting another order before eBay receives the new quantity. Low-stock exclusivity and safety buffers address that external window.
How should Shopify webhooks enter the system?
Verify, deduplicate, store, acknowledge, then process asynchronously.
Shopify's current webhook documentation says to verify delivery signatures, use the X-Shopify-Webhook-Id header to identify duplicates, respond quickly, and avoid relying on webhooks as the only reconciliation mechanism. Its order-webhook guidance treats order payloads as current state and warns against blindly replaying older deliveries.
The receiver should preserve the raw body for HMAC verification.
import crypto from 'node:crypto';
function verifyShopifyHmac(
rawBody: Buffer,
receivedHmac: string,
clientSecret: string
): boolean {
const computed = crypto
.createHmac('sha256', clientSecret)
.update(rawBody)
.digest('base64');
const a = Buffer.from(computed, 'utf8');
const b = Buffer.from(receivedHmac, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Follow Shopify's current verification documentation in production. Header names and framework body handling matter.
Persist these headers when available:
X-Shopify-Webhook-Id
X-Shopify-Topic
X-Shopify-Shop-Domain
X-Shopify-Triggered-At
X-Shopify-Event-Id when present for the delivery model in useA minimal inbox:
create table marketplace_events (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
channel text not null,
channel_account_id text not null,
external_event_id text not null,
event_type text not null,
source_object_id text,
source_updated_at timestamptz,
received_at timestamptz not null default now(),
signature_valid boolean not null,
payload jsonb not null,
status text not null default 'received',
duplicate_count integer not null default 0,
last_error jsonb,
unique (merchant_id, channel, channel_account_id, external_event_id)
);Do not use a Shopify inventory-update webhook as a direct instruction to overwrite the central ledger unless Shopify is your declared physical authority and the event came from an allowed source operation.
For order events, normalize order line identity and reserve. For inventory events, decide whether they represent an authoritative warehouse adjustment, a projection echo from your own write, or a manual change that needs review.
A concrete example is a merchant correcting stock manually in Shopify Admin. If the neutral ledger is authority, you have three choices:
reject the change by publishing the ledger value back
import the change as an approved manual correction
open a discrepancy for reviewSilently importing every Shopify edit gives Shopify authority. Silently overwriting every edit frustrates operations. I expose a policy per store and location.
Record the actor or source details Shopify provides when possible, but do not depend on them always being available. Your own write intents should carry expected quantity, ledger version, and request evidence so you can recognize likely echoes.
[WORKFLOW JSON: n8n workflow that receives a normalized Shopify order event from the ingress service, resolves canonical line mappings, calls the reservation API, branches on reserved, duplicate, and conflict outcomes, and triggers projection dispatch without editing inventory directly.]
// [WORKFLOW JSON: n8n workflow that receives a normalized Shopify order event from the ingress service, resolves canonical line mappings, calls the reservation API, branches on reserved, duplicate, and conflict outcomes, and triggers projection dispatch without editing inventory directly.]How should eBay order notifications enter the system?
Treat the notification as a wake-up event with a stable notification identity, then verify order state when the risk requires it.
The current eBay Notification Topics documentation describes HTTPS webhook delivery, X-EBAY-SIGNATURE, a unique notificationId, event and publish timestamps, and publishAttemptCount. It tells subscribers to validate delivery and handle retries idempotently. For ORDER_CONFIRMATION, the current page lists up to three delivery attempts and includes order and line-item identifiers.
Persist the original eBay notification before reserving stock.
{
"metadata": {
"topic": "ORDER_CONFIRMATION",
"schemaVersion": "current_provider_value",
"deprecated": false
},
"notification": {
"notificationId": "external_notification_id",
"eventDate": "provider_event_time",
"publishDate": "provider_publish_time",
"publishAttemptCount": 1,
"data": {
"order": {
"orderId": "external_order_id",
"orderLineItems": [
{
"orderLineItemId": "external_line_id",
"listingId": "external_listing_id",
"quantity": 1
}
]
}
}
}
}This is a structural example. Use the exact current schema returned for your subscribed topic.
The eBay listing ID in the notification must resolve through your mapping table to an inventory item or offer and then a canonical SKU. If the mapping is missing, do not guess by title. Create an unmapped-order incident and reserve against an emergency SKU only if your operations policy supports it.
For high-risk low-stock items, fetch the order through eBay's Sell Fulfillment API using the seller OAuth context before committing the reservation. The notification tells you something happened. The order read confirms current order and line details.
Do not wait for a broad scheduled order import when you have a timely notification. The longer you wait, the longer other channels advertise unavailable stock.
Also run polling or reconciliation because notifications are not the only recovery path. eBay's notification system includes retries, but your endpoint, subscription, OAuth scopes, or processing can still fail. Query recent orders and compare external order lines against reservations.
A concrete failure is an eBay notification received while the OAuth token needed for order verification is expired. The ingress can still validate and store the notification if signature verification is independent. Mark the event credential_blocked, refresh or repair the connection, then process. Do not return a fake success from the business layer and forget the order.
Keep notification IDs separate from order IDs. Several notifications can relate to one order. Deduplicate delivery by notification ID, and deduplicate reservation by order line ID.
Should you set an absolute quantity or apply an adjustment in Shopify?
Use the mutation that matches your authority and event semantics.
Shopify's current GraphQL Admin API offers both absolute setting and delta adjustment. The `inventorySetQuantities` documentation says it is for a system acting as the inventory source of truth and supports compare-and-set behavior. The `inventoryAdjustQuantities` mutation applies deltas with a reason and optional reference URI for audit.
As of Shopify API version 2026-04, the current documentation says an idempotency key is required for inventoryAdjustQuantities through the @idempotent directive. The same requirement is documented for inventorySetQuantities. Shopify added and then required this protection because duplicate inventory changes after retries are dangerous.
Use an absolute set when your ledger says, “Shopify location X must show 7, and I know the expected previous Shopify value.”
Use an adjustment when the operation itself is a delta Shopify should record, such as a warehouse correction that Shopify is authorized to own.
For a neutral cross-channel ledger, I normally publish absolute desired quantities with compare-and-set protection. That avoids applying the same external order decrement twice. The ledger already recorded the movement. Shopify receives the resulting projection.
A simplified mutation shape is:
mutation SetInventory(
$input: InventorySetQuantitiesInput!
$idempotencyKey: String!
) {
inventorySetQuantities(input: $input)
@idempotent(key: $idempotencyKey) {
inventoryAdjustmentGroup {
id
createdAt
reason
referenceDocumentUri
}
userErrors {
field
message
code
}
}
}Check the current API version schema before shipping. Shopify's compare-and-set input evolved in 2026-01, including a newer changeFromQuantity approach while older comparison fields remain in transition. Do not copy a stale input object from a blog.
The idempotency key should represent one projection write attempt with fixed variables.
shopify:set_available:{shop_id}:{inventory_item_id}:{location_id}:ledger_v184Retry the exact same variables with the same key. If desired quantity changes to a newer ledger version, create a new key.
Shopify's idempotency documentation currently says reusing a key with different parameters can return IDEMPOTENCY_KEY_PARAMETER_MISMATCH. Concurrent duplicate requests can return IDEMPOTENCY_CONCURRENT_REQUEST, in which case the documented handling is to back off and retry with the same key.
Compare-and-set protects you from overwriting an unexpected Shopify change.
ledger wants 7
last observed Shopify quantity was 8
set 7 only if Shopify still equals 8If Shopify now equals 10 because a warehouse receipt was entered there, the compare fails. Do not set 7 with comparison disabled just to make the job green. Reconcile the authority and import or reject the receipt according to policy.
A concrete failure is a connector that reads 8, calculates 7, then a staff member receives 3 units in Shopify, making 11. The connector's late absolute write to 7 erases the receipt. Compare-and-set turns that silent data loss into a conflict.
How should quantity writes reach eBay?
Write from the latest desired projection, preserve the inventory-item and offer distinction, and inspect item-level errors.
The eBay Inventory API manages inventory items and published offers. Its current bulk quantity and price update guide says bulkUpdatePriceQuantity can update total ship-to-home quantity and offer-level quantity or price. The current `bulkUpdatePriceQuantity` method contract says one SKU can be updated per call, with up to 25 offers associated with that inventory item. Some current static guide and schema text instead describes up to 25 inventory-item records in one request. That documentation is internally inconsistent as of August 2026. Treat multi-SKU batching as unverified until you confirm the current production contract or eBay corrects the discrepancy.
I build the conservative path around one SKU per call. That matches the method-level contract, prevents one product's offer IDs from being mixed with another product, and gives you a clean retry boundary. Do not assume arbitrary SKUs can be mixed because the word bulk appears in the method name.
Also understand override behavior. eBay's current publishing offers guide says an offer's availableQuantity can override the inventory item's ship-to-location quantity. If your connector writes the inventory item but an offer-level override remains, the live listing may not show the number you expect.
Your projection needs a target scope:
alter table channel_inventory_projections
add column target_scope text not null default 'inventory_item',
add column external_offer_id text;Possible values are inventory_item and offer, according to the mapping and listing model you operate.
A simplified eBay write intent:
{
"requests": [
{
"sku": "CANONICAL-OR-MAPPED-EBAY-SKU",
"shipToLocationAvailability": {
"quantity": 4
},
"offers": [
{
"offerId": "external_offer_id",
"availableQuantity": 4
}
]
}
]
}Use the current API schema and omit fields you do not intend to update. eBay's documentation warns in some inventory-item update paths that existing availability data may need to be included again or it can be lost. A createOrReplace operation is not a partial patch.
For quantity-only updates, prefer the API method intended for price and quantity changes rather than replacing the entire inventory item with an incomplete object.
The bulk response can contain an outcome per offer or inventory item. Inspect every response entry. A top-level HTTP success does not mean every target updated.
for (const response of ebayResult.responses ?? []) {
if (response.errors?.length) {
await markProjectionFailed({
sku: response.sku,
offerId: response.offerId,
errors: response.errors,
});
continue;
}
await markProjectionWritten({
sku: response.sku,
offerId: response.offerId,
ledgerVersion: write.ledgerVersion,
});
}eBay's REST methods do not necessarily give you the same mutation-level idempotency directive Shopify now provides. Protect writes in your operation ledger. A repeated absolute set to the same quantity is usually safer than a repeated decrement, but you still need to prevent an older write from landing after a newer one.
Before sending, compare the write's ledger version with the current desired projection. If a newer version exists, mark the old write superseded.
update inventory_write_outbox w
set status = 'superseded'
from channel_inventory_projections p
where w.id = $1
and p.channel = w.channel
and p.projection_key = w.projection_key
and p.desired_ledger_version > w.ledger_version;After eBay accepts the write, fetch or observe the listing quantity and store it as observed_quantity. Do not mark the projection reconciled only because the API request returned without transport error.
How do you prevent your own sync updates from looping back?
Track write intent and compare incoming state against it.
You may not receive a perfect “changed by this API request” marker in every channel webhook. You can still recognize likely echoes with a write record:
create table channel_inventory_writes (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
channel text not null,
projection_key text not null,
ledger_version bigint not null,
idempotency_key text,
desired_quantity numeric not null,
previous_observed_quantity numeric,
request_sent_at timestamptz,
response_received_at timestamptz,
provider_request_id text,
status text not null,
response jsonb,
unique (channel, projection_key, ledger_version)
);When a Shopify inventory webhook arrives with the same item, location, and expected quantity shortly after your write, it is probably confirmation. Mark the projection observed. Do not create a new ledger movement.
“Probably” is not enough when manual edits can happen simultaneously. Use compare-and-set response evidence and provider timestamps where possible. If the webhook quantity differs from your desired projection, treat it as a conflict, not an echo.
For eBay, an item-availability notification is not a full inventory-write receipt for every seller integration, and topic access or semantics can differ. Use the Inventory API read path or listing observation for reconciliation. The Notification API includes notificationId, timestamps, and delivery attempts, which are useful for event identity, not for proving your exact write caused the state.
Do not prevent loops by ignoring every inventory event for thirty seconds after a write. That can discard a real order or manual correction in the same window.
A better decision table:
Incoming channel state | Pending write exists | Quantity matches | Action |
|---|---|---|---|
New observation | Yes | Yes | Confirm write, no ledger movement |
New observation | Yes | No | Conflict, fetch current state |
New observation | No | Equals desired projection | Refresh observation only |
New observation | No | Differs from desired, channel is authority | Import movement with source evidence |
New observation | No | Differs from desired, ledger is authority | Open discrepancy or publish correction |
A concrete loop failure is an eBay order that reserves one unit, then a Shopify projection write causes a Shopify inventory event. The connector interprets the Shopify event as a warehouse adjustment and subtracts again from the ledger. Inventory drops by two for one sale.
The event classifier needs source context and expected write evidence before it is allowed to create a movement.
[WORKFLOW JSON: event-classification workflow that receives a Shopify or eBay inventory observation, checks pending write evidence and authority policy, then chooses confirmation, authoritative movement, discrepancy, or ignored duplicate. Show the branch that prevents a projection echo from changing the ledger.]
// [WORKFLOW JSON: event-classification workflow that receives a Shopify or eBay inventory observation, checks pending write evidence and authority policy, then chooses confirmation, authoritative movement, discrepancy, or ignored duplicate. Show the branch that prevents a projection echo from changing the ledger.]What happens when events arrive twice or out of order?
Deduplicate delivery, deduplicate reservation, and reject stale projections.
Those are three different protections.
Shopify says the same webhook can be delivered more than once and recommends using X-Shopify-Webhook-Id. eBay notifications include a unique notificationId and publishAttemptCount. Use those for delivery identity.
Then reserve by business identity:
Shopify: shop + order ID + line item ID + reservation phase
eBay: seller account + order ID + order line item IDIf the same order notification appears under a different delivery ID, the reservation unique constraint still prevents a second allocation.
Ordering is harder.
An order cancellation can arrive before an order-created worker finishes. A return event can arrive after a manual refund. An inventory write based on ledger version 20 can complete after version 21 has already been published.
Every asynchronous job should carry the ledger version it represents. Before applying or confirming, compare it with current desired version.
if (job.ledgerVersion < projection.desiredLedgerVersion) {
await markSuperseded(job.id, projection.desiredLedgerVersion);
return;
}For order lifecycle events, use a transition model.
type OrderAllocationState =
| 'observed'
| 'reserved'
| 'committed'
| 'cancel_requested'
| 'cancelled'
| 'returned_partial'
| 'returned_full';A cancellation event should release only an active or committed reservation according to fulfillment policy. Receiving it twice should return the same release result. Receiving an old “paid” snapshot after cancellation should not recreate the reservation unless current channel state confirms reactivation.
Shopify's order-webhook guidance currently says order payloads represent full current state and that the latest delivery should be treated as truth rather than merging old payloads. Use source update times, but do not assume arrival order equals update order. Store the latest accepted source version or timestamp per order.
For eBay, notification payloads include event and publish dates. A current order read is safer before reversing inventory when events conflict.
A concrete out-of-order failure:
order paid, reservation worker delayed
order cancelled, cancellation worker releases nothing because no reservation exists yet
reservation worker wakes and reserves stock for the cancelled orderFix it by locking the order-allocation record. The paid worker sees the current state is already cancelled and refuses to reserve.
select *
from order_allocations
where merchant_id = $1
and channel = $2
and external_order_id = $3
for update;The event does not directly run the movement. It requests a state transition against current order state.
How do cancellations, returns, and refunds restore stock?
They restore stock only when the physical item is sellable again.
A refund is financial. A cancellation is contractual. A return is physical. They often happen together, but they are not the same inventory event.
If an unfulfilled order is cancelled before picking, releasing its reservation can increase available stock immediately. If a shipped order is refunded, the unit is not back in the warehouse. If a return is received damaged, it increases on hand but also increases non-sellable stock.
Model each transition.
create table return_receipts (
id uuid primary key default gen_random_uuid(),
merchant_id uuid not null,
channel text not null,
external_order_id text not null,
external_line_id text not null,
canonical_sku_id uuid not null,
quantity numeric not null,
received_location_id uuid not null,
condition text not null,
disposition text not null,
source_operation_id text not null,
received_at timestamptz not null,
unique (merchant_id, channel, source_operation_id)
);Possible dispositions:
restock_sellable
restock_non_sellable
repair_pending
return_to_vendor
scrap
quarantineOnly restock_sellable increases available-to-sell immediately.
For eBay, current Notification API topics include order cancellation and return activity events. Use them as timely signals, then verify the current order or return state when restoring stock has material risk. For Shopify, inspect order, fulfillment, refund, and return data appropriate to the store's workflow.
A concrete failure is an automatic rule that adds one unit whenever a refund webhook arrives. The customer received a partial refund for late shipping but kept the item. Inventory increases even though nothing returned.
Tie release to the original reservation and return receipt.
unfulfilled cancellation
-> release reservation
fulfilled cancellation or refund
-> no stock change until physical return disposition
partial line cancellation
-> release only cancelled unfulfilled quantity
replacement shipment
-> create a new reservation for replacement unitDo not delete reservation history when released. Mark its status and create a ledger release entry with the original reservation ID.
insert into inventory_ledger_entries (
merchant_id,
canonical_sku_id,
location_id,
quantity_delta,
entry_type,
source_system,
source_operation_id,
ledger_version,
metadata
)
values (
$1, $2, $3, 0,
'reservation_release',
$4, $5, $6,
jsonb_build_object('reservation_id', $7, 'released_quantity', $8)
);The physical on-hand delta can remain zero while reserved decreases. Your balance update handles the release.
How do bundles, kits, and shared components change the model?
A sellable SKU may not be a stock unit.
A Shopify bundle and an eBay kit can consume the same components in different quantities. If you sync only parent SKU quantity, component sales can make the parent projection wrong.
Model a bill of materials.
create table sku_components (
merchant_id uuid not null,
parent_sku_id uuid not null references canonical_skus(id),
component_sku_id uuid not null references canonical_skus(id),
component_quantity numeric not null check (component_quantity > 0),
effective_from timestamptz not null default now(),
effective_to timestamptz,
primary key (
merchant_id,
parent_sku_id,
component_sku_id,
effective_from
)
);Bundle availability is the minimum number of complete bundles supported by all components.
select min(floor(component_available / component_quantity))
from component_availability
where parent_sku_id = $1;A bundle order reserves components atomically. Lock component balances in a stable order to avoid deadlocks.
sort component SKU IDs
lock every component balance in that order
validate all quantities
insert all reservations
commit togetherIf one component is unavailable, reserve none. Partial reservation creates ghost stock and difficult repair.
A concrete example is a brake service kit containing two pad sets and one fitting kit. Shopify sells the kit. eBay sells the pad set individually. An eBay pad sale reduces the number of complete Shopify kits even though the Shopify parent SKU never received an order.
After any component movement, recalculate all parent projections that depend on it. Maintain a reverse dependency index so you do not scan every bundle.
Bundles also need versioning. If the bill of materials changes, an existing order should retain the component definition used when it was accepted. Store component reservations directly on the order allocation, not only a pointer to the current bundle definition.
create table reservation_components (
reservation_id uuid not null references inventory_reservations(id),
component_sku_id uuid not null,
component_quantity numeric not null,
primary key (reservation_id, component_sku_id)
);Kits expose why “Shopify is source of truth” may stop being enough. Shopify can show a bundle quantity, but your cross-channel allocation needs to understand shared components and independent listings.
What breaks with multiple Shopify and eBay locations?
Location meaning drifts.
A Shopify location can represent a warehouse, store, third-party fulfillment service, or virtual pool. eBay inventory locations use merchant-defined location keys and can support ship-to-home and certain pickup use cases. The same physical warehouse may have different IDs and capabilities in each platform.
Map physical locations separately from channel locations.
create table channel_location_mappings (
merchant_id uuid not null,
internal_location_id uuid not null references inventory_locations(id),
channel text not null,
channel_account_id text not null,
external_location_id text not null,
fulfillment_priority integer,
active boolean not null default true,
metadata jsonb not null default '{}',
primary key (
merchant_id,
channel,
channel_account_id,
external_location_id
)
);Do not sum every Shopify location and publish that total to eBay. Some stock may be reserved for retail, unavailable for eBay shipping, or stored in a region that cannot fulfill the listing's promise.
Use an eligibility table:
create table channel_location_eligibility (
merchant_id uuid not null,
canonical_sku_id uuid not null,
internal_location_id uuid not null,
channel text not null,
marketplace_id text,
marketplace_key text not null default '',
eligible boolean not null,
safety_buffer numeric not null default 0,
primary key (
merchant_id,
canonical_sku_id,
internal_location_id,
channel,
marketplace_key
)
);Set marketplace_key to the marketplace ID for marketplace-specific eligibility, or to the empty string only when the rule intentionally applies across the channel account. Keep it in sync with marketplace_id in the same write path.
A concrete example is stock in a US warehouse and a UK warehouse. The Shopify store can route by market and location. eBay US and eBay UK offers should not both receive the global total if cross-border fulfillment is not allowed.
Your allocator calculates eligible available quantity per channel and marketplace.
sum sellable stock in eligible locations
minus reservations assigned to those locations
minus channel and location safety buffers
minus transfer commitmentsOrder reservation may need location assignment. If the channel order does not name a warehouse, apply your fulfillment policy and create an allocation. Rebalancing after reservation is a transfer or reassignment, not a silent location swap.
Location outages matter. If a warehouse cannot ship, set its channel eligibility false and recalculate projections. Do not set physical on-hand to zero just to stop sales. The stock still exists and may become available later.
Shopify compare-and-set operations are location-specific. eBay's ship-to-home availability can represent total quantity across published offers and inventory locations depending on the model. Read the current API fields carefully. Do not assume one platform's location granularity maps one-to-one to the other.
What happens during an API outage or a large order burst?
Reservations continue. Projections become stale. Exposure policy decides how much risk you accept.
When Shopify or eBay is unavailable, do not stop recording orders from the channel that still delivers them. Persist events and reserve in the ledger. Queue outbound quantity writes with backoff.
The outbox needs provider-aware state:
alter table inventory_write_outbox
add column error_class text,
add column locked_at timestamptz,
add column lock_expires_at timestamptz,
add column provider_request_id text;Classify failures:
429 throttling
5xx or transport outage
authentication failure
validation or mapping error
stale projection superseded
compare conflict
unsupported listing stateRetry transport and throttling failures with backoff. Pause a credential after authentication failure. Route mapping and listing errors to operations. Mark stale writes superseded.
During an outage, desired quantity may change many times. For absolute projections, publish only the newest version when the channel returns. Do not replay every intermediate set.
select distinct on (channel, projection_key)
*
from inventory_write_outbox
where status in ('pending', 'retryable')
order by channel, projection_key, ledger_version desc;Mark older pending writes superseded in a transaction before dispatch.
A burst can come from flash sales, imports, or notification recovery. Locking one balance row serializes reservations for the same SKU, which is exactly what correctness requires. Different SKUs can process concurrently.
Hot SKUs become contention points. Keep the reservation transaction short. Do not call Shopify or eBay while holding the row lock. Commit the reservation and outbox, then dispatch asynchronously.
Low-stock items need stricter policy during a channel outage. If eBay quantity writes are failing and available stock is falling, you may set Shopify exposure lower, pause promotions, or manually end an eBay offer when access returns. You cannot remotely remove eBay availability while eBay's API is unreachable.
This is the unsolved external window. Safety stock is the practical control.
normal operation: buffer 0 or merchant-selected value
provider degraded: increase buffer
provider unavailable: reserve stock for one channel or stop cross-channel exposure
recovery: reconcile before reducing bufferDo not change buffers without logging the policy decision. A dynamic buffer affects sell-through and should be visible to operations.
Use a circuit breaker per merchant and channel. One seller's invalid token should not stop every seller.
[DIAGRAM] Draw the outage state: orders continue entering the ledger, reservations reduce available stock, eBay writes queue, Shopify remains current, and a temporary safety policy reduces exposure until eBay reconciliation completes.
[REAL NUMBER] Insert the oldest outbound projection age and maximum stale-channel exposure from one tested outage or incident, using your own logs.
How do you reconcile inventory without trusting webhooks?
Fetch current state on a schedule and compare it with the ledger projection.
Shopify's webhook documentation explicitly recommends reconciliation because apps can miss or mishandle events. The same principle applies to eBay. Notifications accelerate awareness. API reads verify state.
A reconciliation job has four layers:
mapping reconciliation
order reconciliation
inventory observation
projection comparisonMapping reconciliation confirms that Shopify variants, inventory items, locations, eBay SKUs, offers, and listings still exist and remain associated correctly.
Order reconciliation fetches orders updated since a checkpoint and ensures every relevant line has an allocation or release record.
Inventory observation reads current quantities from each channel and stores them with observation time.
Projection comparison classifies differences.
select
p.merchant_id,
p.canonical_sku_id,
p.channel,
p.projection_key,
p.desired_quantity,
p.observed_quantity,
p.desired_ledger_version,
p.write_status,
p.updated_at
from channel_inventory_projections p
where p.observed_quantity is distinct from p.desired_quantity
order by p.updated_at;A difference can mean:
write pending
write failed
channel order not yet ingested
manual channel change
wrong mapping
offer-level override
location mismatch
stale observation
ledger errorDo not auto-correct every discrepancy. First identify authority and cause.
For Shopify, use compare-and-set when writing absolute corrections so you do not erase a new change between read and write. For eBay, fetch the relevant inventory item and offer state, then publish the latest projection using the proper scope.
Reconciliation needs checkpoints and overlap. If an API query uses update timestamps, restart from slightly before the last checkpoint and deduplicate results. Boundaries and clock precision can otherwise skip records.
create table reconciliation_checkpoints (
merchant_id uuid not null,
channel text not null,
job_type text not null,
checkpoint_value text,
last_started_at timestamptz,
last_completed_at timestamptz,
last_successful_window jsonb,
primary key (merchant_id, channel, job_type)
);A full inventory sweep can be expensive. Use incremental jobs frequently and full sweeps less often according to catalog size and API limits. Do not guess current rate limits. Read Shopify and eBay's current limit documentation and instrument actual response headers.
A concrete reconciliation find is an eBay offer-level quantity of zero overriding an inventory-item quantity of five. The ledger and inventory item agree, but the live listing remains out of stock. Only an offer-aware reconciliation catches it.
The repair should update the correct layer, not keep rewriting the inventory item.
[WORKFLOW JSON: scheduled reconciliation workflow that pages through Shopify inventory and eBay inventory items or offers, stores observations, compares desired projection versions, classifies discrepancies, auto-repairs safe stale writes, and routes authority conflicts to manual review.]
// [WORKFLOW JSON: scheduled reconciliation workflow that pages through Shopify inventory and eBay inventory items or offers, stores observations, compares desired projection versions, classifies discrepancies, auto-repairs safe stale writes, and routes authority conflicts to manual review.]What should operations see when stock disagrees?
Show facts, not one red “sync error” badge.
An operations user needs to know:
canonical SKU and product
physical on-hand by location
active reservations by channel and order
non-sellable quantity
available-to-allocate
channel allocation policy and buffer
desired Shopify and eBay quantities
last observed channel quantities
last successful write and ledger version
open conflicts and unmapped orders
safe repair actionsA useful discrepancy card might read:
Canonical SKU: BRAKE-PAD-01
Available to allocate: 4
Shopify desired: 4
Shopify observed: 4
Ebay desired: 4
Ebay observed: 7
Cause: latest eBay write failed validation
Oldest exposure: [REAL NUMBER]
Risk: eBay can accept up to 3 units above ledger availabilityDo not invent the exposure value. Calculate it.
Repair actions should be specific:
retry latest projection
fetch current channel state
approve Shopify manual correction into ledger
restore ledger value to Shopify
remove stale eBay offer override
map unknown listing to canonical SKU
release orphaned reservation
open oversell incident
pause channel exposure for SKUAvoid a general “force sync” button. Force from where? To what? Under which authority? A vague button hides the decision that caused the problem.
Every manual action should show a preview:
{
"action": "publish_ledger_quantity_to_ebay_offer",
"canonical_sku": "BRAKE-PAD-01",
"offer_id": "redacted",
"current_observed": 7,
"desired": 4,
"ledger_version": 184,
"expected_effect": "reduce eBay exposure by 3",
"related_orders": [],
"requires_approval": true
}Log who approved it and why.
Operations also needs an unmapped-order queue. A paid order should not wait invisibly because a SKU mapping is missing. Show listing ID, external SKU, title for human context, quantity, order deadline, and suggested matches. Require explicit selection.
Do not allow editing physical stock from the discrepancy screen unless the user has warehouse authority. Sync repair and stock correction are different permissions.
What are the costs and tradeoffs of this architecture?
You are building inventory allocation software, not a field mapper.
That means database transactions, order ingestion, mapping management, OAuth, webhook verification, API-version maintenance, reconciliation, and an operations interface. You need tests for concurrent orders and partial failures. You need retention for event and ledger history. Someone must own discrepancies.
For a merchant with a small catalog, low order volume, and generous stock depth, an off-the-shelf connector may be the correct business choice. The occasional manual correction can cost less than a custom platform.
This architecture earns its cost when:
low-stock items are common
oversells are expensive
Shopify and eBay share physical inventory
bundles or multiple locations exist
manual channel edits happen
other systems adjust stock
the merchant needs an audit trail
order volume makes reconciliation manual workThere is a conversion tradeoff. Publishing the last unit to only one channel reduces cross-channel oversell risk and may lose a sale on the hidden channel. Publishing it everywhere increases exposure and may create a conflict. No database removes that choice.
There is a latency tradeoff. Central reservation adds one service and database transaction before projection. The transaction is usually fast, but the channel update still depends on external APIs. This is why safety policy matters more than marketing the sync as instant.
There is an authority tradeoff. Operations may want to edit stock in Shopify because that is familiar. A neutral ledger means Shopify edits need import approval or are overwritten. You must design a usable correction workflow, not just tell staff never to touch Shopify inventory.
There is a platform-change cost. Shopify's 2026 inventory mutations added required idempotency behavior and compare-and-set changes. eBay offer and inventory-item semantics require careful API usage. Your adapters and contract tests need updates as versions change.
Keep platform adapters separate from allocation logic.
interface InventoryChannelAdapter {
fetchMappings(context: ChannelContext): Promise<RemoteMapping[]>;
fetchInventory(targets: InventoryTarget[]): Promise<InventoryObservation[]>;
publishAbsoluteQuantities(writes: QuantityWrite[]): Promise<WriteResult[]>;
fetchRecentOrders(window: TimeWindow): Promise<NormalizedOrder[]>;
verifyWebhook(input: RawWebhook): Promise<VerifiedWebhook>;
normalizeWebhook(input: VerifiedWebhook): Promise<NormalizedEvent>;
}Shopify and eBay implement the interface differently. The ledger does not need to know GraphQL field names or eBay offer payload structure.
The final tradeoff is responsibility. Once your system is the allocation authority, a bug in it can suppress valid sales or expose nonexistent stock across every channel. Test the allocator more aggressively than the dashboard.
What would I do differently now?
I would stop using the word sync during design meetings.
“Sync” encourages people to think the goal is matching two numbers. The goal is accepting each order once, allocating real stock once, and publishing truthful availability fast enough for the merchant's risk tolerance.
I would choose authority before connecting webhooks. Without authority, every event handler becomes a negotiation between platforms.
I would create canonical SKU and location mappings before writing quantity automation. A perfect reservation engine attached to the wrong offer is still wrong.
I would record order reservations independently from channel quantity. Channel state can lag. Reservation cannot.
I would use absolute projections from the ledger, not repeated cross-channel decrements. Deltas are easier to double-apply after retries.
I would add Shopify idempotency keys and compare-and-set protection from the first mutation. The 2026-04 requirement makes idempotency unavoidable on current inventory mutations, but using the directive incorrectly can still defeat the protection. Reuse the same key for an exact retry. Use a new key for a new ledger version.
I would inspect eBay offer-level quantity before blaming delayed sync. An offer override can beat the inventory-item value.
I would keep every channel write tied to a ledger version. A late version 20 response must never mark version 21 reconciled.
I would build cancellation and return logic before launch. Orders do not only move forward. Restocking on refund without physical disposition is one of the fastest ways to invent stock.
I would test the last-unit race deliberately.
start with one unit
submit Shopify and eBay order events concurrently
verify only one reservation succeeds
verify the other becomes a visible conflict
verify channel projections go to zero
cancel the winning order
verify only its reservation releases
receive a damaged return
verify on-hand increases but available does notThe external channels may both accept the order in a real race. Your internal result should still be deterministic and immediately visible.
I would add reconciliation from day one. A connector without reconciliation is trusting that every webhook, mapping, token, and API response behaves forever.
And I would give operations a precise repair screen. Engineers should not be the only people who can explain why Shopify says four, eBay says seven, and the warehouse says five.
What is still the hardest inventory problem?
The hardest problem is the unit that can be purchased in two external systems before either system hears about the other purchase.
The central ledger determines who won. Reservations prevent your own workers from allocating the same unit twice. Idempotency prevents retries from subtracting again. Reconciliation finds drift. Low-stock exclusivity and buffers reduce exposure.
None of those controls can force an unreachable marketplace to stop selling instantly.
That closes the loop from the two buyers at the start. A production design does not promise that the race can never happen. It makes the risk explicit, narrows the window, chooses a winner deterministically, and gives operations evidence before the second customer has to explain the problem to you.
The part most teams still get wrong is not the API call. It is deciding how much inventory to expose on each channel when the last unit is more valuable than the extra conversion it might earn.