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
| Resource | In one sentence | Endpoints |
|---|---|---|
| Product | What the Seller sells, with the data shared by every variation. | Create, list, update |
| Variant | A sellable combination. It is the one with the SKU, GTIN and stock. | Create, list |
| ProductComponent | One line of a kit's recipe (BOM). | Read, replace |
| Warehouse | Where the Seller keeps the goods. Every Seller starts with a default one. | Create, list |
| InventoryMovement | One stock entry or exit in a warehouse. It never changes after it is created. | Record, list |
| InventoryBalance | The 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 andinventoryAuthority: "disabled". A product with no variations only uses that one. To record stock on it, setinventoryAuthoritytomarketapi(update Variant). optionsis 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: giveoptionsto the default one (PATCH) and create the others.skuandgtinare 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.costInCentsis cost, not price. It goes together withcostCurrency.
{
"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}/componentsreplaces 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
marketapinorderived, counts as zero.
Inventory: who owns the balance
Each Variant chooses, in inventoryAuthority, who is the source of truth for its balance:
inventoryAuthority | Who owns the balance | Takes movements? | Sellable quantity |
|---|---|---|---|
marketapi | marketapi, through the movements | yes | onHand − reserved |
derived | the kit's recipe | no | computed from the components |
managed_externally | your ERP or WMS | no | not kept in marketapi |
disabled | nobody | no | none |
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 withisDefault: true(update warehouse). - A movement without
warehouseIdgoes to the default warehouse. WithwarehouseId, it goes to the chosen warehouse, which must belong to the same Seller as the Variant (otherwise404). DELETEarchives. 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", withdetails.uses).- The Variant balance is the sum of the warehouses.
GET /v1/variants/{variantId}/inventoryreturns the totals and thewarehouseslist, 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:
type | quantity | Effect on the balance | What it is for |
|---|---|---|---|
adjustment | positive or negative | onHand goes up or down | count, loss, correction |
transfer | positive | onHand goes down at the source, up at the destination | move units from one warehouse to another |
return | positive | onHand goes up | customer return |
reservation | positive | reserved goes up | open order, not paid yet |
release | positive | reserved goes down | order canceled before the sale |
sale | positive | reserved and onHand go down | confirmed sale of something reserved |
Example with the black iPhone Variant, from zero:
| Movement | onHand | reserved | available |
|---|---|---|---|
adjustment +10 | 10 | 0 | 10 |
reservation 2 | 10 | 2 | 8 |
sale 2 | 8 | 0 | 8 |
return 1 | 9 | 0 | 9 |
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:
| Movement | Warehouse | quantity | Warehouse onHand | Variant onHand |
|---|---|---|---|---|
transfer (outbound) | default | -4 | 6 | 10 |
transfer (inbound) | branch | +4 | 4 | 10 |
The Variant total does not change, so a transfer does not publish quantity by itself. Whoever sends publishTargets still gets the job.
Balance rules:
availableis what can be sold. Do not useonHandfor 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. reservednever goes negative. That is whysaleonly takes out what was already reserved. For a sale with no earlier reservation, recordreservationand thensale.onHandandavailabledo not go negative, unless the Variant hasallowBackorder: true.- Not enough stock in the warehouse, the API refuses:
409withdetails.reason = "inventory_underflow"anddetails.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) is400. - 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:
- In the movement itself:
publishTargetssays which listings, bindings or Connections to send to. - Automatic: the listing is already published, has
syncPolicy.inventory.mode = "automatic", and the binding is ineligibleBindingIds. Every movement of the Variant triggers the send. - On demand:
POST /v1/sellers/{sellerId}/inventory-publicationssends 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.reason | When | What to do |
|---|---|---|
variant_in_use | the Variant is in a listing binding | remove the binding from the listing |
variant_in_kit | the Variant is a component of an active kit | remove the Variant from the kit recipe |
product_in_use | a Variant of the Product is in a binding | remove the bindings |
product_in_kit | a Variant of the Product is a component of an active kit | remove the Variant from the recipe |
warehouse_in_use | the warehouse is the Seller's default (uses: default) or has balance (uses: stock) | mark another one as the default, or move the balance out |