Skip to content

Diagnostic Inventory (MSI)

REST created orders skip reservation placement

An ERP or marketplace connector posts historical or external orders straight to POST /V1/orders, and every one of them looks fine. Real order items, a decremented product quantity, a normal looking order in the grid. But MSI's salable quantity never moves, because the only thing that reduces it, a row in inventory_reservation, is only ever written by a plugin that hooks the checkout flow those orders never touch. Here is why that gap opens and a small script that finds exactly which order lines were never reserved.

Python and Node.js Adobe Commerce REST API Safe by default (report only)
Empty warehouse shelves
Photo by lim woojung on Unsplash
The short answer

MSI reduces salable quantity only by writing an append only, negative row to the inventory_reservation table, and that row is created by a plugin around PlaceReservationsForSalesEventInterface hooked to the sales_order_place_after event, which fires from the normal quote to order checkout pipeline, OrderManagementInterface::place. An order built and persisted directly through POST /V1/orders, the way ERPs and marketplaces inject historical or external orders, never runs that pipeline, so the reservation plugin never executes for those items. You end up with real order_items rows and a decremented product quantity, but zero matching reservation rows, so salable quantity still reports the item as sellable when it is not. Run a small Python or Node.js script that lists recent orders over REST, sums each order's per SKU quantity ordered, and cross checks it against source item quantity minus the currently reported salable quantity for that SKU. Where the two do not reconcile, the order's reservation was skipped. The script only reports, since there is no REST endpoint to create a reservation. Full code, tests, and a dry run guard are below.

The problem in plain words

Magento's Multi Source Inventory never blocks stock the moment an order row is saved. It blocks stock by adding a small append only entry to inventory_reservation, a negative quantity tied to the SKU and stock, and salable quantity is always calculated on the fly as source item quantity minus the sum of every reservation ever written. That entry only gets written by one place, a plugin wired around PlaceReservationsForSalesEventInterface that listens for the sales_order_place_after event.

That event only fires when an order goes through the normal checkout path, cart to quote to order, ending in OrderManagementInterface::place. When an ERP, a marketplace connector, or a migration script instead calls POST /V1/orders to inject an order directly, entity_id, items, totals, and all, it skips that pipeline entirely. The order row and its order_items rows get written straight to the database. The legacy stock_item quantity even gets decremented, because that part is older code with no dependency on the checkout event. But the reservation plugin was never in the call stack, so it never runs, and no row lands in inventory_reservation for that order. The result looks completely normal in the order grid and completely wrong in salable quantity math.

POST /V1/orders ERP injects order Order saved directly order_items written sales_order_place_after never fires Reservation plugin never runs for these order items Salable qty wrong item still shows sellable Zero rows land in inventory_reservation for this order's items, even though the product quantity was decremented.
The order is real, the items are real, but the checkout event that writes the reservation row never fires for orders created directly through the REST order endpoint.

Why it happens

None of this is a bug in the subtraction that computes salable quantity. The gap comes from a legitimate REST endpoint being used for a job it was never wired to also do, keep the reservation ledger current. A few concrete ways it shows up on real stores:

None of this throws an error anyone would notice at order creation time. The order grid, the invoice, the shipment, all look completely normal. The only place the gap shows is in InventorySalesApi's salable quantity calculation, source items total minus reservations, which reports quantity as still salable because the reservation that should have removed it from the pool never got written. See the citations at the end for the exact tracker issues and wiki page that describe this.

The key insight

There is no public REST list endpoint for raw inventory_reservation rows, it is a low level database table, not an exposed API resource. So this has to be detected indirectly, by comparing what salable quantity should be against what it actually reports. For each SKU on a recent order, sum the source item quantity, read the current salable quantity, and compare that gap against the total quantity ordered across every open order referencing that SKU. If the gap does not reconcile, the missing amount is exactly the reservation that never got written.

The fix, as a flow

We never attempt to write a reservation row from this script, because there is no REST resource for it. Instead, for each SKU we pull the real source item quantity, the currently reported salable quantity, and the quantity ordered across every open order that references the SKU. We derive how much reservation impact should exist, compare it against what actually happened, source minus salable, and attribute any shortfall back to the earliest under reserved order lines for that SKU.

List open orders GET /V1/orders Sum qty_ordered per SKU across open orders source qty minus salable qty per SKU Actual under expected? yes Flag order + SKU missing reservation qty no Reconciled nothing written
The script only ever reports. Writing the actual reservation row is CLI and cron territory, and the report tells the operator exactly which order and SKU need attention.

Build it step by step

1

Get an admin token

Authenticate against the admin token endpoint with an admin username and password, or use an integration access token if you already have one. Keep the base URL and credentials in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export STOCK_ID="1"
export ORDER_STATUSES="processing,pending"
export DRY_RUN="true"   # report only, this script never writes a reservation row
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_USERNAME="admin"
export MAGENTO_ADMIN_PASSWORD="change-me"
export STOCK_ID="1"
export ORDER_STATUSES="processing,pending"
export DRY_RUN="true"   // report only, this script never writes a reservation row
2

List recent open orders and their items

POST to /rest/V1/integration/admin/token for a bearer token, then GET /rest/V1/orders with searchCriteria filters on created_at, condition type gteq, and status, paged with searchCriteria[pageSize] and searchCriteria[currentPage]. For each order read entity_id, increment_id, and the items[] array, keeping sku, qty_ordered, and item_id.

step2.py
import os, requests

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

def get_token(username, password):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": username, "password": password},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def list_open_orders(token, since_iso, statuses, page_size=100):
    orders = []
    page = 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
            "searchCriteria[filterGroups][0][filters][0][value]": since_iso,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
            "searchCriteria[pageSize]": page_size,
            "searchCriteria[currentPage]": page,
        }
        for i, status in enumerate(statuses):
            params[f"searchCriteria[filterGroups][1][filters][{i}][field]"] = "status"
            params[f"searchCriteria[filterGroups][1][filters][{i}][value]"] = status
            params[f"searchCriteria[filterGroups][1][filters][{i}][conditionType]"] = "eq"
        r = requests.get(
            f"{MAGENTO_URL}/rest/V1/orders",
            params=params,
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        items = body.get("items", [])
        orders.extend(items)
        if len(items) < page_size:
            return orders
        page += 1
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");

async function getToken(username, password) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function listOpenOrders(token, sinceIso, statuses, pageSize = 100) {
  const orders = [];
  let page = 1;
  while (true) {
    const params = new URLSearchParams({
      "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
      "searchCriteria[filterGroups][0][filters][0][value]": sinceIso,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
      "searchCriteria[pageSize]": String(pageSize),
      "searchCriteria[currentPage]": String(page),
    });
    statuses.forEach((status, i) => {
      params.set(`searchCriteria[filterGroups][1][filters][${i}][field]`, "status");
      params.set(`searchCriteria[filterGroups][1][filters][${i}][value]`, status);
      params.set(`searchCriteria[filterGroups][1][filters][${i}][conditionType]`, "eq");
    });
    const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!res.ok) throw new Error(`Magento ${res.status}`);
    const body = await res.json();
    const items = body.items || [];
    orders.push(...items);
    if (items.length < pageSize) return orders;
    page += 1;
  }
}
3

Read source quantity and reported salable quantity per SKU

There is no public REST resource for raw inventory_reservation rows, so we cross check indirectly. For each distinct SKU across the open orders, sum /rest/V1/inventory/source-items filtered by SKU, and read /rest/V1/inventory/get-product-salable-quantity/{sku}/{stockId} for the current reported number.

step3.py
def source_qty_sum(token, sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/source-items",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    items = r.json().get("items", [])
    return sum(item.get("quantity", 0) for item in items)

def salable_qty(token, sku, stock_id):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/{sku}/{stock_id}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step3.js
async function sourceQtySum(token, sku) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "sku",
    "searchCriteria[filterGroups][0][filters][0][value]": sku,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/source-items?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  const items = body.items || [];
  return items.reduce((sum, item) => sum + (item.quantity || 0), 0);
}

async function salableQty(token, sku, stockId) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/${encodeURIComponent(sku)}/${stockId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
4

Decide, with one pure function

Keep the reconciliation math in its own function that takes only already fetched values, the open orders with their items, source quantity per SKU, and salable quantity per SKU, and returns the exact affected order and SKU pairs. It has no I/O, so it is trivial to unit test with fixed inputs and never depends on the network being up.

find_unreserved.py
def find_unreserved_order_items(open_orders, source_qty_by_sku, salable_qty_by_sku):
    expected_by_sku = {}
    for order in open_orders:
        for item in order["items"]:
            sku = item["sku"]
            expected_by_sku[sku] = expected_by_sku.get(sku, 0) + item["qtyOrdered"]

    findings = []
    for sku, expected_reserved in expected_by_sku.items():
        source_qty = source_qty_by_sku.get(sku, 0)
        salable_qty = salable_qty_by_sku.get(sku, 0)
        actual_reserved = source_qty - salable_qty
        shortfall = expected_reserved - actual_reserved
        if shortfall <= 0:
            continue
        remaining = shortfall
        for order in open_orders:
            if remaining <= 0:
                break
            for item in order["items"]:
                if item["sku"] != sku:
                    continue
                take = min(remaining, item["qtyOrdered"])
                if take <= 0:
                    continue
                findings.append({
                    "incrementId": order["incrementId"],
                    "sku": sku,
                    "qtyOrdered": item["qtyOrdered"],
                    "missingReservationQty": take,
                })
                remaining -= take
    return findings
find-unreserved.js
export function findUnreservedOrderItems(openOrders, sourceQtyBySku, salableQtyBySku) {
  const expectedBySku = {};
  for (const order of openOrders) {
    for (const item of order.items) {
      expectedBySku[item.sku] = (expectedBySku[item.sku] || 0) + item.qtyOrdered;
    }
  }

  const findings = [];
  for (const [sku, expectedReserved] of Object.entries(expectedBySku)) {
    const sourceQty = sourceQtyBySku[sku] || 0;
    const salableQty = salableQtyBySku[sku] || 0;
    const actualReserved = sourceQty - salableQty;
    let remaining = expectedReserved - actualReserved;
    if (remaining <= 0) continue;

    for (const order of openOrders) {
      if (remaining <= 0) break;
      for (const item of order.items) {
        if (item.sku !== sku) continue;
        const take = Math.min(remaining, item.qtyOrdered);
        if (take <= 0) continue;
        findings.push({
          incrementId: order.incrementId,
          sku,
          qtyOrdered: item.qtyOrdered,
          missingReservationQty: take,
        });
        remaining -= take;
      }
    }
  }
  return findings;
}
5

There is no REST write for this, report and guard the stopgap instead

Reservations are append only and Magento deliberately omits a setter or creation web API for InventoryReservationsApi, to prevent exactly this kind of external manipulation. So the script only ever emits one record per affected order and SKU with the missing reservation quantity. Only when an operator explicitly sets DRY_RUN=false does it fall back to the one safe REST lever available, adjusting extension_attributes.stock_item.qty directly through PUT /rest/V1/products/{sku} as a stopgap, logged and idempotent per increment_id so it is never applied twice.

stopgap.py
def apply_stopgap_stock_correction(token, sku, current_qty, missing_qty, applied_increment_ids):
    """Idempotent per increment_id. Only called when DRY_RUN is false and an
    operator has confirmed the finding. This is a legacy stock_item adjustment,
    not a reservation, and is a stopgap only."""
    new_qty = current_qty - missing_qty
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1/products/{sku}",
        json={"product": {"sku": sku, "extension_attributes": {
            "stock_item": {"qty": new_qty}
        }}},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return new_qty
stopgap.js
// Idempotent per incrementId. Only called when DRY_RUN is false and an
// operator has confirmed the finding. This is a legacy stock_item adjustment,
// not a reservation, and is a stopgap only.
async function applyStopgapStockCorrection(token, sku, currentQty, missingQty) {
  const newQty = currentQty - missingQty;
  const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ product: { sku, extension_attributes: { stock_item: { qty: newQty } } } }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return newQty;
}
6

Wire it together with a dry run guard

The loop authenticates once, lists open orders since a lookback window, fetches source quantity and salable quantity for every distinct SKU on those orders, runs the pure finder function, and writes one report row per affected order and SKU. It also keeps a small ledger of increment_id values it has already stopgap corrected, so a second run never double applies the fix. DRY_RUN defaults to true.

Run it safe

This script never calls a reservation write endpoint, because none exists. It reports the affected orders, SKUs, and missing reservation quantity so a human can see exactly what is wrong, and it points the operator at switching order ingestion to the checkout flow or running the CLI reservation tooling. The PUT /rest/V1/products/{sku} stopgap only ever runs with an explicit operator confirmation and DRY_RUN=false, and it tracks applied order ids so it cannot double correct the same order.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever performs reads plus the guarded stopgap write when explicitly enabled.

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.
flag_unreserved_orders.py
"""Flag Magento 2 or Adobe Commerce order lines whose reservation placement
was skipped because the order was created directly through POST /V1/orders.

MSI reduces salable quantity only by writing an append only, negative row to
inventory_reservation. That row is written by a plugin hooked to the
sales_order_place_after event, which fires from the normal quote to order
checkout pipeline, OrderManagementInterface::place. An order built and
persisted directly through POST /V1/orders, the way ERPs and marketplaces
inject historical or external orders, never runs that pipeline, so the
reservation plugin never executes for those items. This script lists recent
open orders, sums qty_ordered per SKU, and cross checks that against source
item quantity minus reported salable quantity for the same SKU. Any shortfall
means a reservation was never written, and it is attributed back to the
earliest under reserved order lines. There is no REST endpoint to create a
reservation, so this script only reports, unless DRY_RUN=false and an operator
has confirmed the guarded legacy stock_item stopgap. Safe to run again and
again.
"""
import os
import csv
import json
import logging
import datetime
import requests

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

MAGENTO_URL = os.environ.get("MAGENTO_URL", "https://example.test").rstrip("/")
ADMIN_USERNAME = os.environ.get("MAGENTO_ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.environ.get("MAGENTO_ADMIN_PASSWORD", "change-me")
ADMIN_TOKEN = os.environ.get("MAGENTO_ADMIN_TOKEN")
STOCK_ID = os.environ.get("STOCK_ID", "1")
ORDER_STATUSES = [s.strip() for s in os.environ.get("ORDER_STATUSES", "processing,pending").split(",") if s.strip()]
LOOKBACK_DAYS = float(os.environ.get("LOOKBACK_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "unreserved_order_items.csv")
APPLIED_LEDGER = os.environ.get("APPLIED_LEDGER", "unreserved_stopgap_applied.json")


def get_token():
    if ADMIN_TOKEN:
        return ADMIN_TOKEN
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1/integration/admin/token",
        json={"username": ADMIN_USERNAME, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def list_open_orders(token, since_iso, statuses, page_size=100):
    orders = []
    page = 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
            "searchCriteria[filterGroups][0][filters][0][value]": since_iso,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
            "searchCriteria[pageSize]": page_size,
            "searchCriteria[currentPage]": page,
        }
        for i, status in enumerate(statuses):
            params[f"searchCriteria[filterGroups][1][filters][{i}][field]"] = "status"
            params[f"searchCriteria[filterGroups][1][filters][{i}][value]"] = status
            params[f"searchCriteria[filterGroups][1][filters][{i}][conditionType]"] = "eq"
        r = requests.get(
            f"{MAGENTO_URL}/rest/V1/orders",
            params=params,
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        items = body.get("items", [])
        orders.extend(items)
        if len(items) < page_size:
            return orders
        page += 1


def source_qty_sum(token, sku):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "sku",
        "searchCriteria[filterGroups][0][filters][0][value]": sku,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
    }
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/source-items",
        params=params,
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    items = r.json().get("items", [])
    return sum(item.get("quantity", 0) for item in items)


def salable_qty(token, sku, stock_id):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/{sku}/{stock_id}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def find_unreserved_order_items(open_orders, source_qty_by_sku, salable_qty_by_sku):
    expected_by_sku = {}
    for order in open_orders:
        for item in order["items"]:
            sku = item["sku"]
            expected_by_sku[sku] = expected_by_sku.get(sku, 0) + item["qtyOrdered"]

    findings = []
    for sku, expected_reserved in expected_by_sku.items():
        source_qty = source_qty_by_sku.get(sku, 0)
        salable = salable_qty_by_sku.get(sku, 0)
        actual_reserved = source_qty - salable
        remaining = expected_reserved - actual_reserved
        if remaining <= 0:
            continue
        for order in open_orders:
            if remaining <= 0:
                break
            for item in order["items"]:
                if item["sku"] != sku:
                    continue
                take = min(remaining, item["qtyOrdered"])
                if take <= 0:
                    continue
                findings.append({
                    "incrementId": order["incrementId"],
                    "sku": sku,
                    "qtyOrdered": item["qtyOrdered"],
                    "missingReservationQty": take,
                })
                remaining -= take
    return findings


def apply_stopgap_stock_correction(token, sku, current_qty, missing_qty):
    new_qty = current_qty - missing_qty
    r = requests.put(
        f"{MAGENTO_URL}/rest/V1/products/{sku}",
        json={"product": {"sku": sku, "extension_attributes": {
            "stock_item": {"qty": new_qty}
        }}},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return new_qty


def load_applied_ledger():
    if os.path.exists(APPLIED_LEDGER):
        with open(APPLIED_LEDGER) as fh:
            return set(json.load(fh))
    return set()


def save_applied_ledger(applied):
    with open(APPLIED_LEDGER, "w") as fh:
        json.dump(sorted(applied), fh)


def run():
    token = get_token()
    since_iso = (datetime.datetime.utcnow() - datetime.timedelta(days=LOOKBACK_DAYS)).strftime("%Y-%m-%d %H:%M:%S")
    raw_orders = list_open_orders(token, since_iso, ORDER_STATUSES)

    open_orders = []
    for order in raw_orders:
        items = [
            {"sku": line["sku"], "qtyOrdered": line.get("qty_ordered", 0) or 0}
            for line in order.get("items", [])
            if (line.get("qty_ordered", 0) or 0) > 0
        ]
        if items:
            open_orders.append({"incrementId": order["increment_id"], "items": items})

    skus = sorted({item["sku"] for order in open_orders for item in order["items"]})
    source_qty_by_sku = {sku: source_qty_sum(token, sku) for sku in skus}
    salable_qty_by_sku = {sku: salable_qty(token, sku, STOCK_ID) for sku in skus}

    findings = find_unreserved_order_items(open_orders, source_qty_by_sku, salable_qty_by_sku)

    if findings:
        with open(OUTPUT_CSV, "w", newline="") as fh:
            writer = csv.DictWriter(fh, fieldnames=["incrementId", "sku", "qtyOrdered", "missingReservationQty"])
            writer.writeheader()
            writer.writerows(findings)

    applied = load_applied_ledger()
    for finding in findings:
        log.info(
            "Order %s SKU %s: qty_ordered=%s missing_reservation_qty=%s",
            finding["incrementId"], finding["sku"], finding["qtyOrdered"], finding["missingReservationQty"],
        )
        ledger_key = f"{finding['incrementId']}:{finding['sku']}"
        if DRY_RUN or ledger_key in applied:
            continue
        current_qty = source_qty_by_sku[finding["sku"]]
        apply_stopgap_stock_correction(token, finding["sku"], current_qty, finding["missingReservationQty"])
        applied.add(ledger_key)
    if not DRY_RUN and findings:
        save_applied_ledger(applied)

    log.info(
        "Done. %d order/SKU pair(s) flagged, %s. No REST endpoint writes inventory_reservation; "
        "switch order ingestion to the checkout flow or run the CLI reservation tooling.",
        len(findings), "dry run, nothing written" if DRY_RUN else "stopgap applied where confirmed",
    )


if __name__ == "__main__":
    run()
flag-unreserved-orders.js
/**
 * Flag Magento 2 or Adobe Commerce order lines whose reservation placement
 * was skipped because the order was created directly through POST /V1/orders.
 *
 * MSI reduces salable quantity only by writing an append only, negative row to
 * inventory_reservation. That row is written by a plugin hooked to the
 * sales_order_place_after event, which fires from the normal quote to order
 * checkout pipeline, OrderManagementInterface::place. An order built and
 * persisted directly through POST /V1/orders, the way ERPs and marketplaces
 * inject historical or external orders, never runs that pipeline, so the
 * reservation plugin never executes for those items. This script lists recent
 * open orders, sums qty_ordered per SKU, and cross checks that against source
 * item quantity minus reported salable quantity for the same SKU. Any
 * shortfall means a reservation was never written, and it is attributed back
 * to the earliest under reserved order lines. There is no REST endpoint to
 * create a reservation, so this script only reports, unless DRY_RUN=false and
 * an operator has confirmed the guarded legacy stock_item stopgap. Safe to run
 * again and again.
 *
 * Guide: https://www.allanninal.dev/magento/rest-orders-skip-reservation-placement/
 */
import { pathToFileURL } from "node:url";
import fs from "node:fs";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USERNAME = process.env.MAGENTO_ADMIN_USERNAME || "admin";
const ADMIN_PASSWORD = process.env.MAGENTO_ADMIN_PASSWORD || "change-me";
const ADMIN_TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "";
const STOCK_ID = process.env.STOCK_ID || "1";
const ORDER_STATUSES = (process.env.ORDER_STATUSES || "processing,pending").split(",").map((s) => s.trim()).filter(Boolean);
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const OUTPUT_CSV = process.env.OUTPUT_CSV || "unreserved_order_items.csv";
const APPLIED_LEDGER = process.env.APPLIED_LEDGER || "unreserved_stopgap_applied.json";

export function findUnreservedOrderItems(openOrders, sourceQtyBySku, salableQtyBySku) {
  const expectedBySku = {};
  for (const order of openOrders) {
    for (const item of order.items) {
      expectedBySku[item.sku] = (expectedBySku[item.sku] || 0) + item.qtyOrdered;
    }
  }

  const findings = [];
  for (const [sku, expectedReserved] of Object.entries(expectedBySku)) {
    const sourceQty = sourceQtyBySku[sku] || 0;
    const salableQty = salableQtyBySku[sku] || 0;
    const actualReserved = sourceQty - salableQty;
    let remaining = expectedReserved - actualReserved;
    if (remaining <= 0) continue;

    for (const order of openOrders) {
      if (remaining <= 0) break;
      for (const item of order.items) {
        if (item.sku !== sku) continue;
        const take = Math.min(remaining, item.qtyOrdered);
        if (take <= 0) continue;
        findings.push({
          incrementId: order.incrementId,
          sku,
          qtyOrdered: item.qtyOrdered,
          missingReservationQty: take,
        });
        remaining -= take;
      }
    }
  }
  return findings;
}

async function getToken() {
  if (ADMIN_TOKEN) return ADMIN_TOKEN;
  const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username: ADMIN_USERNAME, password: ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function listOpenOrders(token, sinceIso, statuses, pageSize = 100) {
  const orders = [];
  let page = 1;
  while (true) {
    const params = new URLSearchParams({
      "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
      "searchCriteria[filterGroups][0][filters][0][value]": sinceIso,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
      "searchCriteria[pageSize]": String(pageSize),
      "searchCriteria[currentPage]": String(page),
    });
    statuses.forEach((status, i) => {
      params.set(`searchCriteria[filterGroups][1][filters][${i}][field]`, "status");
      params.set(`searchCriteria[filterGroups][1][filters][${i}][value]`, status);
      params.set(`searchCriteria[filterGroups][1][filters][${i}][conditionType]`, "eq");
    });
    const res = await fetch(`${MAGENTO_URL}/rest/V1/orders?${params}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!res.ok) throw new Error(`Magento ${res.status}`);
    const body = await res.json();
    const items = body.items || [];
    orders.push(...items);
    if (items.length < pageSize) return orders;
    page += 1;
  }
}

async function sourceQtySum(token, sku) {
  const params = new URLSearchParams({
    "searchCriteria[filterGroups][0][filters][0][field]": "sku",
    "searchCriteria[filterGroups][0][filters][0][value]": sku,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
  });
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/source-items?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  const body = await res.json();
  const items = body.items || [];
  return items.reduce((sum, item) => sum + (item.quantity || 0), 0);
}

async function salableQty(token, sku, stockId) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1/inventory/get-product-salable-quantity/${encodeURIComponent(sku)}/${stockId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function applyStopgapStockCorrection(token, sku, currentQty, missingQty) {
  const newQty = currentQty - missingQty;
  const res = await fetch(`${MAGENTO_URL}/rest/V1/products/${encodeURIComponent(sku)}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ product: { sku, extension_attributes: { stock_item: { qty: newQty } } } }),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return newQty;
}

function loadAppliedLedger() {
  if (fs.existsSync(APPLIED_LEDGER)) {
    return new Set(JSON.parse(fs.readFileSync(APPLIED_LEDGER, "utf8")));
  }
  return new Set();
}

function saveAppliedLedger(applied) {
  fs.writeFileSync(APPLIED_LEDGER, JSON.stringify([...applied].sort()));
}

export async function run() {
  const token = await getToken();
  const since = new Date(Date.now() - LOOKBACK_DAYS * 86400 * 1000);
  const sinceIso = since.toISOString().slice(0, 19).replace("T", " ");
  const rawOrders = await listOpenOrders(token, sinceIso, ORDER_STATUSES);

  const openOrders = [];
  for (const order of rawOrders) {
    const items = (order.items || [])
      .filter((line) => (line.qty_ordered || 0) > 0)
      .map((line) => ({ sku: line.sku, qtyOrdered: line.qty_ordered || 0 }));
    if (items.length) openOrders.push({ incrementId: order.increment_id, items });
  }

  const skus = [...new Set(openOrders.flatMap((order) => order.items.map((item) => item.sku)))];
  const sourceQtyBySku = {};
  const salableQtyBySku = {};
  for (const sku of skus) {
    sourceQtyBySku[sku] = await sourceQtySum(token, sku);
    salableQtyBySku[sku] = await salableQty(token, sku, STOCK_ID);
  }

  const findings = findUnreservedOrderItems(openOrders, sourceQtyBySku, salableQtyBySku);

  const applied = loadAppliedLedger();
  for (const finding of findings) {
    console.log(`Order ${finding.incrementId} SKU ${finding.sku}: qty_ordered=${finding.qtyOrdered} missing_reservation_qty=${finding.missingReservationQty}`);
    const ledgerKey = `${finding.incrementId}:${finding.sku}`;
    if (DRY_RUN || applied.has(ledgerKey)) continue;
    await applyStopgapStockCorrection(token, finding.sku, sourceQtyBySku[finding.sku], finding.missingReservationQty);
    applied.add(ledgerKey);
  }
  if (!DRY_RUN && findings.length) saveAppliedLedger(applied);

  console.log(
    `Done. ${findings.length} order/SKU pair(s) flagged, ${DRY_RUN ? "dry run, nothing written" : "stopgap applied where confirmed"}. ` +
    `No REST endpoint writes inventory_reservation; switch order ingestion to the checkout flow or run the CLI reservation tooling.`
  );
  return findings;
}

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

Add a test

The reconciliation rule is the part most worth testing, because it decides which orders and SKUs get reported as under reserved. Since find_unreserved_order_items and findUnreservedOrderItems are pure, the tests need no network and no Magento instance. They just feed in plain order lists and quantity maps, covering a fully reserved SKU, a completely skipped reservation, a partially skipped one, and multiple orders sharing the same SKU.

test_rest_unreserved.py
from flag_unreserved_orders import find_unreserved_order_items


def test_fully_reserved_sku_is_not_flagged():
    orders = [{"incrementId": "100000001", "items": [{"sku": "SKU-1", "qtyOrdered": 5}]}]
    findings = find_unreserved_order_items(orders, {"SKU-1": 100}, {"SKU-1": 95})
    assert findings == []


def test_completely_skipped_reservation_is_flagged():
    orders = [{"incrementId": "100000002", "items": [{"sku": "SKU-2", "qtyOrdered": 5}]}]
    findings = find_unreserved_order_items(orders, {"SKU-2": 100}, {"SKU-2": 100})
    assert findings == [{
        "incrementId": "100000002",
        "sku": "SKU-2",
        "qtyOrdered": 5,
        "missingReservationQty": 5,
    }]


def test_partially_reserved_sku_reports_only_the_shortfall():
    orders = [{"incrementId": "100000003", "items": [{"sku": "SKU-3", "qtyOrdered": 10}]}]
    # expected reserved is 10, actual reserved is 100-96=4, shortfall is 6
    findings = find_unreserved_order_items(orders, {"SKU-3": 100}, {"SKU-3": 96})
    assert findings == [{
        "incrementId": "100000003",
        "sku": "SKU-3",
        "qtyOrdered": 10,
        "missingReservationQty": 6,
    }]


def test_shortfall_attributed_to_earliest_orders_first():
    orders = [
        {"incrementId": "100000004", "items": [{"sku": "SKU-4", "qtyOrdered": 3}]},
        {"incrementId": "100000005", "items": [{"sku": "SKU-4", "qtyOrdered": 4}]},
    ]
    # expected reserved is 7, actual reserved is 0, so both orders are short
    findings = find_unreserved_order_items(orders, {"SKU-4": 100}, {"SKU-4": 100})
    assert findings == [
        {"incrementId": "100000004", "sku": "SKU-4", "qtyOrdered": 3, "missingReservationQty": 3},
        {"incrementId": "100000005", "sku": "SKU-4", "qtyOrdered": 4, "missingReservationQty": 4},
    ]


def test_shortfall_smaller_than_first_order_only_flags_that_order():
    orders = [
        {"incrementId": "100000006", "items": [{"sku": "SKU-5", "qtyOrdered": 5}]},
        {"incrementId": "100000007", "items": [{"sku": "SKU-5", "qtyOrdered": 5}]},
    ]
    # expected reserved is 10, actual reserved is 100-97=3, shortfall is 7
    findings = find_unreserved_order_items(orders, {"SKU-5": 100}, {"SKU-5": 97})
    assert findings == [
        {"incrementId": "100000006", "sku": "SKU-5", "qtyOrdered": 5, "missingReservationQty": 5},
        {"incrementId": "100000007", "sku": "SKU-5", "qtyOrdered": 5, "missingReservationQty": 2},
    ]


def test_multiple_skus_are_evaluated_independently():
    orders = [
        {"incrementId": "100000008", "items": [
            {"sku": "SKU-6", "qtyOrdered": 2},
            {"sku": "SKU-7", "qtyOrdered": 3},
        ]},
    ]
    findings = find_unreserved_order_items(
        orders,
        {"SKU-6": 50, "SKU-7": 50},
        {"SKU-6": 48, "SKU-7": 50},
    )
    assert findings == [{
        "incrementId": "100000008",
        "sku": "SKU-7",
        "qtyOrdered": 3,
        "missingReservationQty": 3,
    }]
rest-unreserved.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findUnreservedOrderItems } from "./flag-unreserved-orders.js";

test("fully reserved SKU is not flagged", () => {
  const orders = [{ incrementId: "100000001", items: [{ sku: "SKU-1", qtyOrdered: 5 }] }];
  const findings = findUnreservedOrderItems(orders, { "SKU-1": 100 }, { "SKU-1": 95 });
  assert.deepEqual(findings, []);
});

test("completely skipped reservation is flagged", () => {
  const orders = [{ incrementId: "100000002", items: [{ sku: "SKU-2", qtyOrdered: 5 }] }];
  const findings = findUnreservedOrderItems(orders, { "SKU-2": 100 }, { "SKU-2": 100 });
  assert.deepEqual(findings, [
    { incrementId: "100000002", sku: "SKU-2", qtyOrdered: 5, missingReservationQty: 5 },
  ]);
});

test("partially reserved SKU reports only the shortfall", () => {
  const orders = [{ incrementId: "100000003", items: [{ sku: "SKU-3", qtyOrdered: 10 }] }];
  const findings = findUnreservedOrderItems(orders, { "SKU-3": 100 }, { "SKU-3": 96 });
  assert.deepEqual(findings, [
    { incrementId: "100000003", sku: "SKU-3", qtyOrdered: 10, missingReservationQty: 6 },
  ]);
});

test("shortfall attributed to earliest orders first", () => {
  const orders = [
    { incrementId: "100000004", items: [{ sku: "SKU-4", qtyOrdered: 3 }] },
    { incrementId: "100000005", items: [{ sku: "SKU-4", qtyOrdered: 4 }] },
  ];
  const findings = findUnreservedOrderItems(orders, { "SKU-4": 100 }, { "SKU-4": 100 });
  assert.deepEqual(findings, [
    { incrementId: "100000004", sku: "SKU-4", qtyOrdered: 3, missingReservationQty: 3 },
    { incrementId: "100000005", sku: "SKU-4", qtyOrdered: 4, missingReservationQty: 4 },
  ]);
});

test("shortfall smaller than first order only flags that order", () => {
  const orders = [
    { incrementId: "100000006", items: [{ sku: "SKU-5", qtyOrdered: 5 }] },
    { incrementId: "100000007", items: [{ sku: "SKU-5", qtyOrdered: 5 }] },
  ];
  const findings = findUnreservedOrderItems(orders, { "SKU-5": 100 }, { "SKU-5": 97 });
  assert.deepEqual(findings, [
    { incrementId: "100000006", sku: "SKU-5", qtyOrdered: 5, missingReservationQty: 5 },
    { incrementId: "100000007", sku: "SKU-5", qtyOrdered: 5, missingReservationQty: 2 },
  ]);
});

test("multiple SKUs are evaluated independently", () => {
  const orders = [
    { incrementId: "100000008", items: [
      { sku: "SKU-6", qtyOrdered: 2 },
      { sku: "SKU-7", qtyOrdered: 3 },
    ] },
  ];
  const findings = findUnreservedOrderItems(
    orders,
    { "SKU-6": 50, "SKU-7": 50 },
    { "SKU-6": 48, "SKU-7": 50 }
  );
  assert.deepEqual(findings, [
    { incrementId: "100000008", sku: "SKU-7", qtyOrdered: 3, missingReservationQty: 3 },
  ]);
});

Case studies

ERP backfill

Historical orders that quietly oversold new stock

A distributor's ERP pushed three years of historical orders into a new Magento store through POST /V1/orders so reporting had a single source of truth. The import looked flawless, correct totals, correct items, correct customer records. Months later, a handful of SKUs that had been reordered since the import started overselling on the storefront even though the warehouse counted enough stock.

Running the script against open orders on those SKUs turned up a consistent shortfall, the exact quantity from the historical import that had never been reserved. The team switched future ERP pushes to route through the cart and payment-information checkout flow instead, and used the report to justify a one time stopgap correction on the affected SKUs.

Marketplace connector

Channel orders that never touched checkout

A housewares brand synced marketplace orders into Magento through a connector that called POST /V1/orders directly for speed, skipping the storefront cart entirely. Salable quantity on the marketplace's own best sellers slowly drifted higher than what the warehouse actually had, since none of those orders ever wrote a reservation.

The report flagged the exact increment ids and SKUs involved weeks before a stockout would have hit a paying customer. The connector team scheduled a fix to route new orders through the checkout API, and used the dry run report to size how much stock had been silently oversold in the interim.

What good looks like

After running this on a schedule, an order that skipped reservation placement stops being invisible. You get a short, dated list of exactly which order and SKU pairs are under reserved and by how much, so a human can decide between switching the integration to the checkout flow, running the CLI reservation tooling, or approving a guarded stopgap correction. The actual reservation row still belongs to the checkout pipeline, but nobody has to discover the gap from an oversold order first.

FAQ

Why does an order created through POST /V1/orders not reduce salable quantity?

MSI reduces salable quantity by writing a negative inventory_reservation row, and that row is only created by a plugin hooked to the sales_order_place_after event fired by the normal quote to order checkout pipeline. An order built and saved directly through POST /V1/orders never runs that pipeline, so the reservation plugin never executes, the order gets real order_items rows and a decremented product quantity, but zero matching inventory_reservation rows.

Is there a REST endpoint to create the missing reservation?

No. Reservations are append only and Magento deliberately does not expose a setter or creation web API for InventoryReservationsApi, to prevent exactly this kind of external manipulation. A script can detect and report the affected order lines over REST, but writing the reservation row itself is CLI and cron territory, not REST.

What is the safe way to stop this from happening to new orders?

Switch the integration that injects orders to go through the same checkout flow a storefront order uses, such as cart and payment-information, so the sales_order_place_after event fires normally. For orders already created without a reservation, the report should flag them so an operator can run the appropriate reindex or reservation tooling, and only as a stopgap, with a confirmed operator decision, adjust the legacy stock_item quantity directly through PUT /rest/V1/products/{sku}.

Related field notes

Citations

On the problem:

  1. REST API rest/all/V1/orders/create does not trigger "inventory_reservations_placement" plugin, magento/magento2 issue 26575. github.com/magento/magento2/issues/26575
  2. Inventory Reservation not taken into account when setting Product out of Stock, magento/magento2 issue 25696. github.com/magento/magento2/issues/25696
  3. Salable Quantity Calculation and Mechanism of Reservations, magento/inventory Wiki. github.com/magento/inventory/wiki/Salable-Quantity-Calculation-and-Mechanism-of-Reservations

On the solution:

  1. Reservations, Inventory Management Guide, Adobe Commerce and Magento Open Source devdocs. devdocs.magento.com guides/v2.4/inventory/reservations
  2. Reservations, Commerce PHP Extensions, Adobe Commerce Developer docs. developer.adobe.com commerce/php/development/framework/inventory-management/reservations
  3. Inventory Management API Reference, Commerce PHP Extensions. developer.adobe.com commerce/php/development/components/web-api/inventory-management

Stuck on a tricky one?

If you have a problem in Magento indexing, cron, MSI stock, or order grid 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 catch a silent oversell?

If this saved you a confusing oversell or a mystery stock discrepancy, 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