Catalog and inventory

Product, Variant, kit and how each Variant's balance is computed.

The catalog says what the Seller sells. It knows nothing about marketplaces: nothing here has a selling price, a listing or an external account. Those live in the listings.

The resources and their main fields

ResourceIn one sentenceEndpoints
ProductWhat the Seller sells, with the data shared by every variation.Create, list, update
VariantA sellable combination. It is the one with the SKU, GTIN and stock.Create, list
ProductComponentOne line of a kit's recipe (BOM).Read, replace
WarehouseWhere the Seller keeps the goods. Every Seller starts with a default one.Create, list
InventoryMovementOne stock entry or exit in a warehouse. It never changes after it is created.Record, list
InventoryBalanceThe Variant's balance in each warehouse, computed by the API.Read the balance

Product: what exists

The Product keeps what is the same across every variation: title and description, brand and model, typed attributes, item and package measurements, Brazilian fiscal data, compliance declarations and media. Texts are per language, such as { "en-US": "iPhone 16" }.

The Product does not keep a selling price or stock. The price belongs to the listing, and the stock belongs to the Variant.

Variant: what is sold

The Variant is the unit the buyer receives: the black 128 GB iPhone 16. It has its own SKU, GTIN and balance. It can also override the Product's content, attributes, dimensions and media, only where they differ.

  • Every Product is born with a default Variant, with options: {}, no SKU and inventoryAuthority: "disabled". A product with no variations only uses that one. To record stock on it, set inventoryAuthority to marketapi (update Variant).
  • options is unique within the Product. Two Variants cannot have the same combination (409, details.reason = "variant_options_taken"), and key order does not matter. Since the default one already takes {}, a product with colors works like this: give options to the default one (PATCH) and create the others.
  • sku and gtin are used to match imported listings to your catalog. The SKU does not have to be unique in the API, but a repeated SKU gets in the way of that match.
  • costInCents is cost, not price. It goes together with costCurrency.
{
  "id": "6f1c…",
  "productId": "2b7e…",
  "sku": "IP16-BLK-128",
  "gtin": "0195949000000",
  "options": { "color": "black", "storage": "128 GB" },
  "inventoryAuthority": "marketapi",
  "allowBackorder": false,
  "costInCents": 520000,
  "costCurrency": "BRL"
}

Kit: a Product made of other Variants

A kit is a regular Product with a recipe (BOM): the list of Variants it consumes and how many units of each. The recipe belongs to the Product. For the kit's balance to come from it, the kit's Variant uses inventoryAuthority: "derived".

The kit's balance is the smallest number of kits each component can build: min(⌊10 ÷ 2⌋, ⌊3 ÷ 1⌋) = min(5, 3) = 3. The charger is the bottleneck.

The calculation is done per warehouse, and the warehouses are summed at the end. A kit is not assembled from parts that sit in two places: if the 3 chargers are in another warehouse, with no cables, the kit is worth 0 in both.

Recipe rules:

  • PUT /v1/products/{productId}/components replaces the whole recipe at once, with up to 100 lines. Each Variant appears only once.
  • Components are Variants of the same Seller.
  • A kit can contain another kit, but it cannot contain itself, not even through an indirect path (409, details.reason = "component_cycle").
  • The kit takes no movement of its own. It changes when its components change.
  • An archived component, or one whose stock is neither marketapi nor derived, counts as zero.

Inventory: who owns the balance

Each Variant chooses, in inventoryAuthority, who is the source of truth for its balance:

inventoryAuthorityWho owns the balanceTakes movements?Sellable quantity
marketapimarketapi, through the movementsyesonHand − reserved
derivedthe kit's recipenocomputed from the components
managed_externallyyour ERP or WMSnonot kept in marketapi
disablednobodynonone

A movement on a Variant that is not marketapi returns 409 with details.reason = "inventory_authority".

Inventory: warehouses

The balance is not one number per Variant: it is one per warehouse. A warehouse is the place where the Seller keeps the goods, such as headquarters and a branch.

  • Every Seller starts with a default warehouse (isDefault: true), created together with it. Only one warehouse is the default. To change it, mark another one with isDefault: true (update warehouse).
  • A movement without warehouseId goes to the default warehouse. With warehouseId, it goes to the chosen warehouse, which must belong to the same Seller as the Variant (otherwise 404).
  • DELETE archives. An archived warehouse takes no movement (409, details.reason = "warehouse_archived") and leaves the balance list. The API refuses to archive the default or a warehouse with balance (409, details.reason = "warehouse_in_use", with details.uses).
  • The Variant balance is the sum of the warehouses. GET /v1/variants/{variantId}/inventory returns the totals and the warehouses list, one line per active warehouse.

Inventory: movements and balance

With marketapi, the balance comes from a ledger: a list of movements that never change. Each movement changes one warehouse. The warehouse balance is the sum of its movements.

Each movement type changes the balance in its own way:

typequantityEffect on the balanceWhat it is for
adjustmentpositive or negativeonHand goes up or downcount, loss, correction
transferpositiveonHand goes down at the source, up at the destinationmove units from one warehouse to another
returnpositiveonHand goes upcustomer return
reservationpositivereserved goes upopen order, not paid yet
releasepositivereserved goes downorder canceled before the sale
salepositivereserved and onHand go downconfirmed sale of something reserved

Example with the black iPhone Variant, from zero:

MovementonHandreservedavailable
adjustment +1010010
reservation 21028
sale 2808
return 1909

Transfer between warehouses

A transfer is not a signed adjustment. It takes warehouseId (the source), toWarehouseId (the destination) and a positive quantity, and writes two rows in the ledger with the same transferId: an outbound at the source and an inbound at the destination. Either both are written or neither is.

Example: the black iPhone has 10 in the default warehouse and 0 in the branch. A transfer of 4:

MovementWarehousequantityWarehouse onHandVariant onHand
transfer (outbound)default-4610
transfer (inbound)branch+4410

The Variant total does not change, so a transfer does not publish quantity by itself. Whoever sends publishTargets still gets the job.

Balance rules:

  • available is what can be sold. Do not use onHand for that.
  • Each warehouse has its own balance, and the rules below hold in each one. Release (release) and take out (sale) in the same warehouse where you reserved.
  • reserved never goes negative. That is why sale only takes out what was already reserved. For a sale with no earlier reservation, record reservation and then sale.
  • onHand and available do not go negative, unless the Variant has allowBackorder: true.
  • Not enough stock in the warehouse, the API refuses: 409 with details.reason = "inventory_underflow" and details.warehouseId (for a transfer, the source). It holds for every type. Nothing is written and no event is sent.
  • A quantity with the wrong sign for the type (for example, a negative sale) is 400.
  • A movement can carry a reference ({ "type": "order", "id": "ML-123" }) so you can tie the change to the order or to your ERP.

From the balance to the marketplace

The balance only reaches a listing through a binding (see Connections and listings). The quantity sent is the Variant's available, the sum of all warehouses. Sending it is always an inventory.publish Job, and it happens in three ways:

  1. In the movement itself: publishTargets says which listings, bindings or Connections to send to.
  2. Automatic: the listing is already published, has syncPolicy.inventory.mode = "automatic", and the binding is in eligibleBindingIds. Every movement of the Variant triggers the send.
  3. On demand: POST /v1/sellers/{sellerId}/inventory-publications sends again whenever you want, by Variant (variantIds), listing, binding or Connection.

In all three, the binding must have inventoryEligible: true, and the Variant must be marketapi or derived: managed_externally and disabled never go out. When no binding qualifies, the movement returns publicationJobId: null, and the on-demand send returns 409 with details.reason = "inventory_target_empty".

Kits: a movement on a component also counts for the kits that consume it, including a kit inside a kit. The kit's bindings go out by the same rules (automatic or publishTargets), with the computed balance. Publishing a kit's listing also sends that balance, unless the marketplace computes kit stock on its own.

Archiving

DELETE on a Product, a Variant or a warehouse archives it: the resource goes out of use, and its history stays. The API refuses with 409 when archiving would break something:

details.reasonWhenWhat to do
variant_in_usethe Variant is in a listing bindingremove the binding from the listing
variant_in_kitthe Variant is a component of an active kitremove the Variant from the kit recipe
product_in_usea Variant of the Product is in a bindingremove the bindings
product_in_kita Variant of the Product is a component of an active kitremove the Variant from the recipe
warehouse_in_usethe warehouse is the Seller's default (uses: default) or has balance (uses: stock)mark another one as the default, or move the balance out