Skip to content

Repair Inventory (MSI)

Out of stock threshold change not applied to existing items

Someone lowers the Out-of-Stock Threshold to sell down the last few units of everything, or raises it to stop selling on razor thin stock. The setting saves without an error. But a pile of products that should have flipped in stock or out of stock never move, the admin grid disagrees with what the storefront shows, and the MSI salable quantity API tells a third story entirely. The threshold changed. The existing inventory did not catch up. Here is why, and a small script that finds and repairs the exact source items the change left behind.

Python and Node.js Inventory REST API Safe by default (dry run)
Using a laptop
Photo by Roberto Cortese on Unsplash
The short answer

Saving a new value for cataloginventory/options/stock_threshold_qty under Stores, Configuration, Catalog, Inventory, Product Stock Options fires the admin_system_config_changed_section_cataloginventory observer, which correctly recalculates the legacy cataloginventory_stock_item.is_in_stock flag for every product. MSI never got the matching piece. There is no observer or plugin that recomputes the status column on existing inventory_source_item rows against the new threshold, so those rows keep whichever status value the old threshold produced. This is a confirmed defect, tracked as magento/inventory issue #3061, and it shows up as the admin grid, the storefront, and the MSI salable quantity API all disagreeing on the same SKU until something forces a recompute. Run a small Python or Node.js script that reads the current threshold, pages through your catalog, reads every source item's quantity and stored status, recomputes what that status should be, and reports or repairs the ones that fell out of sync. Full code, tests, and a dry run guard are below.

The problem in plain words

The Out-of-Stock Threshold is one of the oldest settings in Magento's inventory model. It says how many units of buffer stock to hold back, so a product goes out of stock a little before it truly hits zero. Under classic single-source inventory, that setting only ever touched one table, and changing it and saving the config was enough, since the legacy observer recalculates is_in_stock for every stock item right then.

MSI split that single table into inventory_source_item rows, one per source per SKU, each carrying its own status column that is supposed to mean the same thing is_in_stock used to mean. The legacy observer still runs and still updates the old table correctly. Nothing was ever wired up to walk the MSI source item table and do the same recompute. So the moment you save a new threshold, the legacy flag and the MSI source item status start telling two different stories about the same product, and they stay that way until quantity changes trigger a fresh calculation on their own, or a full reindex and cron pass happens to touch them.

Admin saves new Out-of-Stock Threshold Legacy stock item is_in_stock recalculated no matching MSI observer source_item status unchanged, stale Grid, storefront, API disagree
The threshold change only reaches the legacy stock item. Existing inventory_source_item rows keep the status the old threshold produced, so every place that reads stock ends up disagreeing.

Why it happens

This is a confirmed, reported defect rather than a guess. See the citations at the end for the exact GitHub issue and the MSI FAQ describing the split between legacy stock status and source item status.

The key insight

A threshold change is a global rule. Applying it to existing rows is a separate, missing step. So the fix is not to wait for the next reindex to maybe catch it. It is to recompute, in one pure function, what every existing source item's status should be under the current threshold and backorders setting, compare that against what is actually stored, and only then decide whether a given source item is stale. Quantity is never touched, only the status field the threshold change was supposed to update in the first place.

The fix, as a flow

We do not touch quantity anywhere in this script. We add a job that reads the current threshold, pages through the catalog, reads every SKU's source items, recomputes the status each one should have, and writes back only the ones that disagree with what MSI is currently storing.

Scheduled job runs on a timer List products, read source items GET /V1/inventory/source-items Recompute status qty, threshold, backorders Status stale? yes no, leave alone PUT source-items status only, qty untouched
The script only rewrites status on source items it confirmed are stale under the current threshold. Quantity is never part of the write, and everything already correct is left alone.

Build it step by step

1

Get an admin bearer token and the current threshold

Authenticate the way any Magento REST client does, either POST /rest/V1/integration/admin/token with an admin username and password, or an integration token you already have. There is no public REST getter for cataloginventory/options/stock_threshold_qty, so read the current threshold from the admin config UI or database once and pass it in as a script input.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STOCK_THRESHOLD_QTY="5"
export BACKORDERS_ENABLED="false"
export PAGE_SIZE="100"
export DRY_RUN="true"   # start safe, change to false to write corrected status
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STOCK_THRESHOLD_QTY="5"
export BACKORDERS_ENABLED="false"
export PAGE_SIZE="100"
export DRY_RUN="true"   // start safe, change to false to write corrected status
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps GET and PUT and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def magento_put(path, body):
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=body,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function magentoPut(path, body) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

List SKUs and their source items

Page through GET /rest/V1/products with searchCriteria[pageSize] and searchCriteria[currentPage]. For each SKU, call GET /rest/V1/inventory/source-items filtered by sku equal to that value to get every source item's sku, source_code, quantity, and stored status.

step3.py
def products_page(page_size, current_page):
    params = {
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/products", params)["items"]


def source_items_for_sku(sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    return magento_get("/inventory/source-items", params)["items"]
step3.js
async function productsPage(pageSize, currentPage) {
  const params = {
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/products", params);
  return data.items;
}

async function sourceItemsForSku(sku) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "sku",
    "searchCriteria[filterGroups][0][filters][0][value]": sku,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/inventory/source-items", params);
  return data.items;
}
4

Cross-check with live salable quantity

Optionally call GET /rest/V1/inventory/get-product-salable-quantity/{sku}/{stockId}, default stockId 1, to see the salable quantity MSI is currently exposing for the SKU. It is a useful sanity check alongside the recomputed status, since a SKU with real salable stock but a stale zero status is exactly the case worth fixing.

step4.py
def salable_quantity(sku, stock_id=1):
    return magento_get(f"/inventory/get-product-salable-quantity/{sku}/{stock_id}")
step4.js
async function salableQuantity(sku, stockId = 1) {
  return magentoGet(`/inventory/get-product-salable-quantity/${sku}/${stockId}`);
}
5

Decide, with one pure function

Keep the recompute in its own function that takes quantity, the current threshold, and whether backorders are enabled, and returns 0 or 1. A pure function like this is easy to read and easy to test, which we do later. Salable quantity is quantity minus threshold, and status flips to out of stock at salable quantity zero or below, except when backorders are enabled and the threshold is zero or negative, since that combination means infinite or threshold-extended backorders keep the item salable regardless of quantity.

decide.py
def recompute_source_item_status(quantity, threshold, backorders_enabled):
    if backorders_enabled and threshold <= 0:
        return 1
    salable = quantity - threshold
    return 1 if salable > 0 else 0
decide.js
export function recomputeSourceItemStatus(quantity, threshold, backordersEnabled) {
  if (backordersEnabled && threshold <= 0) return 1;
  const salable = quantity - threshold;
  return salable > 0 ? 1 : 0;
}
6

Repair only the status field, with a dry run guard

For each source item whose stored status disagrees with the recomputed value, send PUT /rest/V1/inventory/source-items with a body that repeats the item's sku, source_code, and existing quantity, and overwrites only status with the corrected value. Guard every write behind DRY_RUN. When it is true, the default, only log the diff of sku, source_code, old status, new status, quantity, and threshold, and skip the write entirely. When it is false, perform the PUT, then log that bin/magento indexer:reindex cataloginventory_stock plus bin/magento cron:run should follow, since those are CLI-only and out of REST's reach, and they are what makes cataloginventory_stock_item and the salable quantity index reflect the corrected source items.

Run it safe

Always start with DRY_RUN=true. The write path only ever changes the status field on source items the script itself recomputed as stale, quantity is never part of the payload, and it never invents a source item that was not already there. Follow every real run with a CLI reindex and cron pass, since that is the only way the legacy stock item and salable quantity index pick up the correction.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through the catalog, recomputes status for every source item, respects the dry run flag, and is safe to run again and again because it only ever overwrites status on source items it confirmed are stale.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
repair_threshold_source_items.py
"""Repair Magento 2 or Adobe Commerce inventory_source_item rows left stale
after an Out-of-Stock Threshold change.

Saving a new cataloginventory/options/stock_threshold_qty value fires
admin_system_config_changed_section_cataloginventory, which correctly
recalculates the legacy cataloginventory_stock_item.is_in_stock flag. MSI has
no matching observer for inventory_source_item, so existing source items keep
whichever status value the old threshold produced until quantity changes on
its own or a full reindex and cron pass happen to touch them. This script
pages through the catalog, reads every source item's quantity and stored
status, recomputes the status each should have under the current threshold
and backorders setting, and by default only reports the mismatches. Only
under an explicit DRY_RUN=false operator override does it PUT the corrected
status. It never touches quantity. Run on a schedule after any threshold
change. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_threshold_source_items")

MAGENTO_URL = os.environ.get("MAGENTO_URL", "https://demo.example.com").rstrip("/")
TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN", "token_dummy")
STOCK_THRESHOLD_QTY = float(os.environ.get("STOCK_THRESHOLD_QTY", "0"))
BACKORDERS_ENABLED = os.environ.get("BACKORDERS_ENABLED", "false").lower() == "true"
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def magento_put(path, body):
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=body,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def recompute_source_item_status(quantity, threshold, backorders_enabled):
    if backorders_enabled and threshold <= 0:
        return 1
    salable = quantity - threshold
    return 1 if salable > 0 else 0


def products_page(page_size, current_page):
    params = {
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/products", params)["items"]


def all_products(page_size):
    page = 1
    while True:
        items = products_page(page_size, page)
        if not items:
            return
        for item in items:
            yield item
        if len(items) < page_size:
            return
        page += 1


def source_items_for_sku(sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    return magento_get("/inventory/source-items", params)["items"]


def repair_source_item(sku, source_code, quantity, new_status):
    body = {"sourceItems": [{"sku": sku, "source_code": source_code, "quantity": quantity, "status": new_status}]}
    return magento_put("/inventory/source-items", body)


def run():
    fixed = 0
    for product in all_products(PAGE_SIZE):
        sku = product.get("sku")
        for item in source_items_for_sku(sku):
            source_code = item.get("source_code")
            quantity = item.get("quantity", 0)
            old_status = item.get("status")
            new_status = recompute_source_item_status(quantity, STOCK_THRESHOLD_QTY, BACKORDERS_ENABLED)

            if old_status == new_status:
                continue

            log.warning(
                "Stale status: sku=%s source_code=%s quantity=%s threshold=%s old_status=%s new_status=%s. %s",
                sku, source_code, quantity, STOCK_THRESHOLD_QTY, old_status, new_status,
                "would repair" if DRY_RUN else "repairing",
            )
            if not DRY_RUN:
                repair_source_item(sku, source_code, quantity, new_status)
            fixed += 1

    if not DRY_RUN and fixed:
        log.info("Run bin/magento indexer:reindex cataloginventory_stock and bin/magento cron:run to reconcile the legacy stock item and salable quantity index.")
    log.info("Done. %d source item(s) %s.", fixed, "to repair" if DRY_RUN else "repaired")


if __name__ == "__main__":
    run()
repair-threshold-source-items.js
/**
 * Repair Magento 2 or Adobe Commerce inventory_source_item rows left stale
 * after an Out-of-Stock Threshold change.
 *
 * Saving a new cataloginventory/options/stock_threshold_qty value fires
 * admin_system_config_changed_section_cataloginventory, which correctly
 * recalculates the legacy cataloginventory_stock_item.is_in_stock flag. MSI
 * has no matching observer for inventory_source_item, so existing source
 * items keep whichever status value the old threshold produced until
 * quantity changes on its own or a full reindex and cron pass happen to
 * touch them. This script pages through the catalog, reads every source
 * item's quantity and stored status, recomputes the status each should have
 * under the current threshold and backorders setting, and by default only
 * reports the mismatches. Only under an explicit DRY_RUN=false operator
 * override does it PUT the corrected status. It never touches quantity. Run
 * on a schedule after any threshold change. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/threshold-change-not-applied-existing-items/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const STOCK_THRESHOLD_QTY = Number(process.env.STOCK_THRESHOLD_QTY || 0);
const BACKORDERS_ENABLED = (process.env.BACKORDERS_ENABLED || "false").toLowerCase() === "true";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function recomputeSourceItemStatus(quantity, threshold, backordersEnabled) {
  if (backordersEnabled && threshold <= 0) return 1;
  const salable = quantity - threshold;
  return salable > 0 ? 1 : 0;
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function magentoPut(path, body) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function productsPage(pageSize, currentPage) {
  const params = {
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/products", params);
  return data.items;
}

async function* allProducts(pageSize) {
  let page = 1;
  while (true) {
    const items = await productsPage(pageSize, page);
    if (!items.length) return;
    for (const item of items) yield item;
    if (items.length < pageSize) return;
    page++;
  }
}

async function sourceItemsForSku(sku) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "sku",
    "searchCriteria[filterGroups][0][filters][0][value]": sku,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  };
  const data = await magentoGet("/inventory/source-items", params);
  return data.items;
}

async function repairSourceItem(sku, sourceCode, quantity, newStatus) {
  const body = { sourceItems: [{ sku, source_code: sourceCode, quantity, status: newStatus }] };
  return magentoPut("/inventory/source-items", body);
}

export async function run() {
  let fixed = 0;
  for await (const product of allProducts(PAGE_SIZE)) {
    const sku = product.sku;
    for (const item of await sourceItemsForSku(sku)) {
      const sourceCode = item.source_code;
      const quantity = item.quantity || 0;
      const oldStatus = item.status;
      const newStatus = recomputeSourceItemStatus(quantity, STOCK_THRESHOLD_QTY, BACKORDERS_ENABLED);

      if (oldStatus === newStatus) continue;

      console.warn(
        `Stale status: sku=${sku} source_code=${sourceCode} quantity=${quantity} threshold=${STOCK_THRESHOLD_QTY} old_status=${oldStatus} new_status=${newStatus}. ${
          DRY_RUN ? "would repair" : "repairing"
        }`
      );
      if (!DRY_RUN) await repairSourceItem(sku, sourceCode, quantity, newStatus);
      fixed++;
    }
  }

  if (!DRY_RUN && fixed) {
    console.log("Run bin/magento indexer:reindex cataloginventory_stock and bin/magento cron:run to reconcile the legacy stock item and salable quantity index.");
  }
  console.log(`Done. ${fixed} source item(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The recompute rule is the part most worth testing, because it decides which source items are treated as stale and what value they get repaired to. Because we kept recompute_source_item_status pure, the test needs no network, no Magento store, and no database. It just feeds in numbers and checks the answer, covering a positive threshold, a zero threshold with backorders, and a negative threshold with backorders.

test_threshold_recompute.py
from repair_threshold_source_items import recompute_source_item_status


def test_in_stock_when_quantity_above_positive_threshold():
    assert recompute_source_item_status(10, 5, False) == 1


def test_out_of_stock_when_quantity_at_positive_threshold():
    assert recompute_source_item_status(5, 5, False) == 0


def test_out_of_stock_when_quantity_below_positive_threshold():
    assert recompute_source_item_status(2, 5, False) == 0


def test_out_of_stock_when_quantity_zero_and_threshold_zero_no_backorders():
    assert recompute_source_item_status(0, 0, False) == 0


def test_in_stock_when_zero_threshold_and_backorders_enabled():
    assert recompute_source_item_status(0, 0, True) == 1


def test_in_stock_when_negative_threshold_and_backorders_enabled():
    assert recompute_source_item_status(-3, -2, True) == 1


def test_uses_normal_math_when_positive_threshold_even_with_backorders():
    assert recompute_source_item_status(10, 5, True) == 1
    assert recompute_source_item_status(3, 5, True) == 0
threshold-recompute.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { recomputeSourceItemStatus } from "./repair-threshold-source-items.js";

test("in stock when quantity above positive threshold", () => {
  assert.equal(recomputeSourceItemStatus(10, 5, false), 1);
});

test("out of stock when quantity at positive threshold", () => {
  assert.equal(recomputeSourceItemStatus(5, 5, false), 0);
});

test("out of stock when quantity below positive threshold", () => {
  assert.equal(recomputeSourceItemStatus(2, 5, false), 0);
});

test("out of stock when quantity zero and threshold zero, no backorders", () => {
  assert.equal(recomputeSourceItemStatus(0, 0, false), 0);
});

test("in stock when zero threshold and backorders enabled", () => {
  assert.equal(recomputeSourceItemStatus(0, 0, true), 1);
});

test("in stock when negative threshold and backorders enabled", () => {
  assert.equal(recomputeSourceItemStatus(-3, -2, true), 1);
});

test("uses normal math when positive threshold even with backorders", () => {
  assert.equal(recomputeSourceItemStatus(10, 5, true), 1);
  assert.equal(recomputeSourceItemStatus(3, 5, true), 0);
});

Case studies

Threshold lowered

The clearance push that never showed up

A merchant lowered the Out-of-Stock Threshold from 5 to 0 to sell down leftover units before a season change. The legacy stock item flipped in stock immediately for everything with 1 to 4 units left, exactly as expected. But dozens of the same SKUs stayed marked out of stock in the MSI source item status, so the storefront still hid them and the salable quantity API reported zero even though units were sitting in the warehouse.

Running the repair script in dry run listed every source item where quantity now cleared the new threshold but status still said 0. A real run corrected just those rows, followed by the reindex and cron pass, and the clearance stock actually became purchasable the same day instead of quietly sitting unsold.

Threshold raised

The buffer increase that left phantom stock live

A different store raised its threshold from 0 to 10 after a run of oversold orders on thin margins. The legacy flag correctly flipped several products to out of stock right away. Their MSI source items, untouched by the config save, still carried status 1 from before, so those exact SKUs kept accepting orders through the storefront and MSI's reservation system for two more days until a routine cron pass happened to recompute them.

After that, the team ran the detection and repair script immediately after every threshold change instead of waiting on the next scheduled reindex, closing the gap down to minutes instead of days.

What good looks like

After this runs right after a threshold change, every source item's status matches what the new threshold, quantity, and backorders setting actually say, not whatever the old threshold left behind. The admin grid, the storefront, and the salable quantity API agree again, and the only follow-up left is the CLI reindex and cron pass that pulls the legacy stock item into line with the source items this script already corrected.

FAQ

Why does changing the Out-of-Stock Threshold not update my existing products?

Saving a new threshold in Stores, Configuration, Catalog, Inventory, Product Stock Options fires an observer that recalculates the legacy cataloginventory_stock_item row so is_in_stock reflects the new threshold right away. MSI has no matching observer for inventory_source_item, so every existing source item keeps whichever status value was computed under the old threshold until something else touches it.

How do I find the source items the threshold change missed?

Page through GET /rest/V1/products, then for each SKU call GET /rest/V1/inventory/source-items filtered by sku to read every source item's quantity and stored status. Recompute what that status should be under the current threshold and backorders setting with a pure function, and flag any source item whose stored status disagrees with the recomputed value.

Is it safe to fix the mismatch through the REST API?

Yes, when the fix only overwrites the status field on the exact source items the script flagged, leaving quantity untouched, and stays behind a DRY_RUN guard that defaults to true. The PUT to /rest/V1/inventory/source-items is a REST-safe write, but the reindex and cron run that make cataloginventory_stock_item and the salable quantity index reflect the correction are CLI-only, so the script reports those as a required follow-up rather than pretending it can trigger them.

Related field notes

Citations

On the problem:

  1. GitHub Issue: Products stock status issues with Out-of-Stock Threshold. github.com/magento/inventory/issues/3061
  2. MSI FAQs, magento/inventory Wiki. github.com/magento/inventory/wiki/MSI-FAQs
  3. Adobe Commerce: Configure Inventory Management product options. experienceleague.adobe.com/en/docs/commerce-admin/inventory/configuration/product-options

On the solution:

  1. Adobe Commerce REST API: Manage source items. developer.adobe.com/commerce/webapi/rest/inventory/manage-source-items
  2. Adobe Commerce REST API: Check salable quantities. developer.adobe.com/commerce/webapi/rest/inventory/check-salable-quantity
  3. Commerce PHP Extensions: InventoryApi module reference. developer.adobe.com/commerce/php/module-reference/module-inventory-api

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce inventory, catalog data, orders, or indexing that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this clear up your stock mismatch?

If this saved you a confusing inventory report or a threshold change that never seemed to take, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Magento field notes