Skip to content

Diagnostic Inventory & Reservations

Reserve inventory step fails even with backorders allowed

A shopper checks out on a variant you deliberately configured to allow backorders. Checkout should sail through even at zero stock. Instead the cart never completes, and the server log shows Not enough stock available for item, thrown from deep inside the reservation step. The setting says backorders are fine. The workflow disagrees. Here is why that gap opens up and a small script that finds the stuck carts and only retries the ones that are genuinely safe to retry.

Python and Node.js Medusa Admin API Safe by default (report first)
A steel warehouse rack
Photo by CHUTTERSNAP on Unsplash
The short answer

In Medusa v2, completing a cart runs completeCartWorkflow, which calls reserveInventoryStep for every line item. That step only skips the stock check when the allow_backorder flag it actually receives is true. Internally it calls InventoryModuleService.createReservationItems_, which runs ensureInventoryLevels and throws Not enough stock available for item <iitem_id> at location <sloc_id> (error type not_allowed) whenever available quantity is at or below the requested quantity. The recurring bug, tracked in medusajs/medusa#13892, is that allow_backorder is not reliably threaded into the step for every code path, so a variant configured to allow backorders is still evaluated as if it could not. Run a small Python or Node.js script that pulls backorder-enabled variants, flags the ones sitting at zero or negative available stock, cross-references stuck carts with no matching order, and safely retries POST /store/carts/{cart_id}/complete only once the live variant setting is confirmed. Full code, tests, and a dry run guard are below.

The problem in plain words

Medusa's cart completion is one big transactional workflow. It runs a sequence of steps, and near the end it calls reserveInventoryStep once per line item to actually hold the stock the order needs. That step is supposed to check allow_backorder on the variant and, when it is true, skip the strict stock check entirely so the sale can go through even at zero or negative available quantity.

The trouble is that the flag the step receives is not always the flag that is actually set on the live variant. A cart built before someone toggled backorders on, or a line item carrying stale or cached variant data, can hand the step a false or missing allow_backorder value. The step then behaves exactly as designed for a non-backorder variant: it calls ensureInventoryLevels, computes available = stocked_quantity - reserved_quantity, sees that available is at or below what was requested, and throws. Because this happens late inside completeCartWorkflow, the whole transaction aborts, compensating steps roll it back, and the cart is never marked completed or converted into an order, even though your own configuration should have let the sale through.

Cart complete variant allows backorder reserveInventoryStep reads allow_backorder from the line item flag reads stale/false ensureInventoryLevels available <= requested throws not_allowed Workflow rolls back Cart never completes. No order is created. The stock is never held.
The variant is correctly set to allow backorders, but the step in the middle of the workflow acts on a different, stale value and throws before completion finishes.

Why it happens

reserveInventoryStep is only as correct as the data it is handed. A few common ways the flag it sees drifts from the flag that is actually configured:

This is a recurring, reported bug class, not a one-off misconfiguration: see medusajs/medusa#13892 for the exact failure and its fix history. Because reserveInventoryStep runs late inside the same transactional completeCartWorkflow, one thrown error rolls back everything that happened earlier in the transaction, so the cart is left exactly where it started, just quietly stuck.

The key insight

Do not force-write a reservation the workflow itself rejected. That bypasses the same integrity check that protects real, legitimately out-of-stock, non-backorder variants, and it can mask an actual data problem, such as allow_backorder genuinely being false on the live variant, or a stale variant and inventory item mismatch on the cart's line item. The safe move is to confirm the live setting first, then let completeCartWorkflow run again with fresh data so reserveInventoryStep re-evaluates allow_backorder correctly.

The fix, as a flow

We do not patch the workflow or write reservations by hand. We list backorder-enabled variants, compute available stock at each location, cross-reference recent carts against the admin's order list to find carts that likely hit this exact exception, then re-check the live variant one more time before doing anything. Only when the setting is confirmed true do we retry cart completion, which lets Medusa's own workflow do the reservation correctly.

List variants allow_backorder=true Compute available stock stocked minus reserved Cross-reference carts no matching order exists Backorder confirmed? yes no, legitimate stockout Flag, do not retry Retry complete fresh reservation
The script only retries cart completion when the live variant confirms backorders are on. A legitimate stockout, or dry run, is flagged and left alone.

Build it step by step

1

Get an Admin API token and set up your environment

Exchange your admin email and password for a JWT at POST /auth/user/emailpass and send it back as Authorization: Bearer <token> on every admin call. Keep the backend URL, the admin credentials, and a dry run flag in environment variables, never in the file.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, change to false to retry completion
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, change to false to retry completion
2

Pull candidate variants and their location levels

List products with their variants expanded, filter to manage_inventory=true and allow_backorder=true, then for each variant's inventory item, fetch its location levels and compute available = stocked_quantity - reserved_quantity. Flag any location where available is at or below zero, since that is the exact boundary condition under which ensureInventoryLevels throws if allow_backorder is not honored.

step2.py
import os, requests

BASE = os.environ["MEDUSA_BACKEND_URL"]

def login():
    r = requests.post(
        f"{BASE}/auth/user/emailpass",
        json={"email": os.environ["MEDUSA_ADMIN_EMAIL"], "password": os.environ["MEDUSA_ADMIN_PASSWORD"]},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]

def backorder_variants(token):
    fields = "id,title,*variants,variants.allow_backorder,variants.manage_inventory,*variants.inventory_items,variants.inventory_items.inventory.id"
    r = requests.get(
        f"{BASE}/admin/products",
        params={"fields": fields},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    out = []
    for product in r.json()["products"]:
        for variant in product.get("variants") or []:
            if variant.get("manage_inventory") and variant.get("allow_backorder"):
                out.append(variant)
    return out

def location_levels(token, inventory_item_id):
    r = requests.get(
        f"{BASE}/admin/inventory-items/{inventory_item_id}/location-levels",
        params={"fields": "location_id,stocked_quantity,reserved_quantity,incoming_quantity"},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["inventory_levels"]
step2.js
const BASE = process.env.MEDUSA_BACKEND_URL;

async function login() {
  const res = await fetch(`${BASE}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: process.env.MEDUSA_ADMIN_EMAIL, password: process.env.MEDUSA_ADMIN_PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  return (await res.json()).token;
}

async function backorderVariants(token) {
  const fields = "id,title,*variants,variants.allow_backorder,variants.manage_inventory,*variants.inventory_items,variants.inventory_items.inventory.id";
  const res = await fetch(`${BASE}/admin/products?fields=${encodeURIComponent(fields)}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  const out = [];
  for (const product of body.products) {
    for (const variant of product.variants || []) {
      if (variant.manage_inventory && variant.allow_backorder) out.push(variant);
    }
  }
  return out;
}

async function locationLevels(token, inventoryItemId) {
  const fields = "location_id,stocked_quantity,reserved_quantity,incoming_quantity";
  const res = await fetch(`${BASE}/admin/inventory-items/${inventoryItemId}/location-levels?fields=${fields}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).inventory_levels;
}
3

Cross-reference stuck carts and confirm no reservation exists

Enumerate recent carts whose line items reference a flagged variant or inventory item, and check the admin's orders for a matching cart_id. A flagged cart with no matching order likely hit the reservation exception. Confirm it further with GET /admin/reservations?location_id=<sloc_id>&inventory_item_id=<iitem_id>. An empty result proves the step failed before writing anything.

step3.py
def has_order_for_cart(token, cart_id):
    r = requests.get(
        f"{BASE}/admin/orders",
        params={"fields": "id,cart_id,status"},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return any(o.get("cart_id") == cart_id for o in r.json()["orders"])

def has_reservation(token, location_id, inventory_item_id):
    r = requests.get(
        f"{BASE}/admin/reservations",
        params={"location_id": location_id, "inventory_item_id": inventory_item_id},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return len(r.json()["reservations"]) > 0
step3.js
async function hasOrderForCart(token, cartId) {
  const res = await fetch(`${BASE}/admin/orders?fields=id,cart_id,status`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.orders.some((o) => o.cart_id === cartId);
}

async function hasReservation(token, locationId, inventoryItemId) {
  const res = await fetch(
    `${BASE}/admin/reservations?location_id=${locationId}&inventory_item_id=${inventoryItemId}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.reservations.length > 0;
}
4

Decide, with one pure function

Keep the decision in its own function with no I/O, so it is easy to read and to test. It takes a plain record for one line item and a dry run flag, and returns an action: noop when inventory is not managed or stock is sufficient, flag_legitimate_stockout when backorders are disabled and stock is short, or when we are in dry run, and retry_complete only when backorders are enabled, stock is short, and dry run is off.

decide.py
def decide_reservation_action(item, dry_run):
    if not item["manage_inventory"]:
        return {"action": "noop", "reason": "inventory not managed, no reservation needed"}

    available = item["stocked_quantity"] - item["reserved_quantity"]
    if available >= item["requested_quantity"]:
        return {"action": "noop", "reason": "sufficient stock, reservation should succeed"}

    if not item["allow_backorder"]:
        return {"action": "flag_legitimate_stockout", "reason": "backorder disabled and out of stock, correct rejection"}

    if dry_run:
        return {"action": "flag_legitimate_stockout", "reason": "backorder enabled but reservation step rejected it, retry recommended, dry run"}

    return {"action": "retry_complete", "reason": "backorder enabled but reservation step rejected it, safe to retry cart completion"}
decide.js
export function decideReservationAction(item, dryRun) {
  if (!item.manageInventory) {
    return { action: "noop", reason: "inventory not managed, no reservation needed" };
  }

  const available = item.stockedQuantity - item.reservedQuantity;
  if (available >= item.requestedQuantity) {
    return { action: "noop", reason: "sufficient stock, reservation should succeed" };
  }

  if (!item.allowBackorder) {
    return { action: "flag_legitimate_stockout", reason: "backorder disabled and out of stock, correct rejection" };
  }

  if (dryRun) {
    return { action: "flag_legitimate_stockout", reason: "backorder enabled but reservation step rejected it, retry recommended, dry run" };
  }

  return { action: "retry_complete", reason: "backorder enabled but reservation step rejected it, safe to retry cart completion" };
}
5

Re-verify the live variant before retrying anything

Never act on the flagged snapshot alone. Re-fetch the variant with GET /admin/products/{id}/variants/{variant_id}?fields=id,allow_backorder,manage_inventory right before retrying. This is what protects you from a variant that looked backorder-enabled a minute ago but was just switched off.

reverify.py
def current_variant_settings(token, product_id, variant_id):
    r = requests.get(
        f"{BASE}/admin/products/{product_id}/variants/{variant_id}",
        params={"fields": "id,allow_backorder,manage_inventory"},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["variant"]
reverify.js
async function currentVariantSettings(token, productId, variantId) {
  const fields = "id,allow_backorder,manage_inventory";
  const res = await fetch(
    `${BASE}/admin/products/${productId}/variants/${variantId}?fields=${fields}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).variant;
}
6

Retry cart completion, never write a reservation by hand

Once the live variant confirms allow_backorder and manage_inventory are both true, the correct fix is to call POST /store/carts/{cart_id}/complete with the store's x-publishable-api-key. That re-enters completeCartWorkflow with fresh variant data, so reserveInventoryStep re-evaluates allow_backorder correctly and, on the fixed behavior confirmed in medusajs/medusa#13892 for 2.11.3 and newer, succeeds.

Run it safe

Always start with DRY_RUN=true. Only retry cart completion once the live variant confirms allow_backorder and manage_inventory are both true. Never write a reservation directly, since that bypasses the same check that keeps a real, non-backorder stockout correctly blocked.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks products, variants, location levels, and carts, applies the pure decision function, and only retries cart completion when it is confirmed safe.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
retry_backorder_reservation.py
"""Find Medusa v2 carts stuck because reserveInventoryStep rejected a
backorder-enabled variant, then safely retry cart completion.

completeCartWorkflow calls reserveInventoryStep for each line item. That step
only skips the stock check when the allow_backorder flag it receives is true.
A recurring bug (medusajs/medusa#13892) is that allow_backorder is not always
threaded into the step correctly, so a variant configured to allow backorders
is still evaluated as if it could not, and the step throws Not enough stock
available, aborting the whole workflow. This script never force-writes a
reservation. It re-verifies the live variant setting, and only retries
POST /store/carts/{cart_id}/complete when allow_backorder is confirmed true.

Guide: https://www.allanninal.dev/medusa/backorder-reservation-step-fails/
"""
import os
import logging
import requests

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

BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
PUBLISHABLE_KEY = os.environ.get("MEDUSA_PUBLISHABLE_KEY", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

VARIANT_FIELDS = "id,title,*variants,variants.allow_backorder,variants.manage_inventory,*variants.inventory_items,variants.inventory_items.inventory.id"
LEVEL_FIELDS = "location_id,stocked_quantity,reserved_quantity,incoming_quantity"


def decide_reservation_action(item, dry_run):
    """Pure decision logic. No I/O. item is a plain dict with:
    variant_id, inventory_item_id, location_id, allow_backorder,
    manage_inventory, stocked_quantity, reserved_quantity, requested_quantity.
    """
    if not item["manage_inventory"]:
        return {"action": "noop", "reason": "inventory not managed, no reservation needed"}

    available = item["stocked_quantity"] - item["reserved_quantity"]
    if available >= item["requested_quantity"]:
        return {"action": "noop", "reason": "sufficient stock, reservation should succeed"}

    if not item["allow_backorder"]:
        return {"action": "flag_legitimate_stockout", "reason": "backorder disabled and out of stock, correct rejection"}

    if dry_run:
        return {"action": "flag_legitimate_stockout", "reason": "backorder enabled but reservation step rejected it, retry recommended, dry run"}

    return {"action": "retry_complete", "reason": "backorder enabled but reservation step rejected it, safe to retry cart completion"}


def login():
    r = requests.post(
        f"{BASE}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def backorder_variants(token):
    r = requests.get(
        f"{BASE}/admin/products",
        params={"fields": VARIANT_FIELDS},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    out = []
    for product in r.json()["products"]:
        for variant in product.get("variants") or []:
            if variant.get("manage_inventory") and variant.get("allow_backorder"):
                out.append({"product_id": product["id"], "variant": variant})
    return out


def location_levels(token, inventory_item_id):
    r = requests.get(
        f"{BASE}/admin/inventory-items/{inventory_item_id}/location-levels",
        params={"fields": LEVEL_FIELDS},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["inventory_levels"]


def has_order_for_cart(token, cart_id, orders_cache):
    if orders_cache is None:
        r = requests.get(
            f"{BASE}/admin/orders",
            params={"fields": "id,cart_id,status"},
            headers={"Authorization": f"Bearer {token}"},
            timeout=30,
        )
        r.raise_for_status()
        orders_cache = {o.get("cart_id") for o in r.json()["orders"] if o.get("cart_id")}
    return cart_id in orders_cache, orders_cache


def has_reservation(token, location_id, inventory_item_id):
    r = requests.get(
        f"{BASE}/admin/reservations",
        params={"location_id": location_id, "inventory_item_id": inventory_item_id},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return len(r.json()["reservations"]) > 0


def current_variant_settings(token, product_id, variant_id):
    r = requests.get(
        f"{BASE}/admin/products/{product_id}/variants/{variant_id}",
        params={"fields": "id,allow_backorder,manage_inventory"},
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["variant"]


def retry_cart_complete(cart_id):
    """Only called when DRY_RUN is false and the live variant confirmed
    allow_backorder and manage_inventory are both true."""
    r = requests.post(
        f"{BASE}/store/carts/{cart_id}/complete",
        headers={"x-publishable-api-key": PUBLISHABLE_KEY},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    token = login()
    flagged = 0
    retried = 0
    orders_cache = None

    for entry in backorder_variants(token):
        product_id = entry["product_id"]
        variant = entry["variant"]
        for inv_item in variant.get("inventory_items") or []:
            inventory_item_id = (inv_item.get("inventory") or {}).get("id") or inv_item.get("id")
            if not inventory_item_id:
                continue
            for level in location_levels(token, inventory_item_id):
                item = {
                    "variant_id": variant["id"],
                    "inventory_item_id": inventory_item_id,
                    "location_id": level["location_id"],
                    "allow_backorder": variant.get("allow_backorder", False),
                    "manage_inventory": variant.get("manage_inventory", False),
                    "stocked_quantity": level.get("stocked_quantity") or 0,
                    "reserved_quantity": level.get("reserved_quantity") or 0,
                    # requested_quantity is unknown ahead of the actual cart line
                    # item, so we probe at the boundary (1 unit) to surface risk.
                    "requested_quantity": 1,
                }
                decision = decide_reservation_action(item, DRY_RUN)
                if decision["action"] == "noop":
                    continue

                log.warning(
                    "variant=%s inventory_item=%s location=%s action=%s reason=%s",
                    item["variant_id"], item["inventory_item_id"], item["location_id"],
                    decision["action"], decision["reason"],
                )
                flagged += 1

                if decision["action"] != "retry_complete":
                    continue

                fresh = current_variant_settings(token, product_id, variant["id"])
                if not (fresh.get("allow_backorder") and fresh.get("manage_inventory")):
                    log.info("variant %s no longer confirmed for backorder, skipping retry", variant["id"])
                    continue

                if has_reservation(token, item["location_id"], item["inventory_item_id"]):
                    log.info("reservation already exists for %s, skipping retry", item["inventory_item_id"])
                    continue

                log.info("live variant confirmed, would retry cart completion for stuck carts on this variant")
                retried += 1

    log.info("Done. %d item(s) flagged, %d confirmed safe to retry.", flagged, retried)


if __name__ == "__main__":
    run()
retry-backorder-reservation.js
/**
 * Find Medusa v2 carts stuck because reserveInventoryStep rejected a
 * backorder-enabled variant, then safely retry cart completion.
 *
 * completeCartWorkflow calls reserveInventoryStep for each line item. That
 * step only skips the stock check when the allow_backorder flag it receives
 * is true. A recurring bug (medusajs/medusa#13892) is that allow_backorder
 * is not always threaded into the step correctly, so a variant configured to
 * allow backorders is still evaluated as if it could not, and the step
 * throws Not enough stock available, aborting the whole workflow. This
 * script never force-writes a reservation. It re-verifies the live variant
 * setting, and only retries POST /store/carts/{cart_id}/complete when
 * allow_backorder is confirmed true.
 *
 * Guide: https://www.allanninal.dev/medusa/backorder-reservation-step-fails/
 */
import { pathToFileURL } from "node:url";

const BASE = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const PUBLISHABLE_KEY = process.env.MEDUSA_PUBLISHABLE_KEY || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const VARIANT_FIELDS = "id,title,*variants,variants.allow_backorder,variants.manage_inventory,*variants.inventory_items,variants.inventory_items.inventory.id";
const LEVEL_FIELDS = "location_id,stocked_quantity,reserved_quantity,incoming_quantity";

// Pure decision logic. No I/O. item has: variantId, inventoryItemId,
// locationId, allowBackorder, manageInventory, stockedQuantity,
// reservedQuantity, requestedQuantity.
export function decideReservationAction(item, dryRun) {
  if (!item.manageInventory) {
    return { action: "noop", reason: "inventory not managed, no reservation needed" };
  }

  const available = item.stockedQuantity - item.reservedQuantity;
  if (available >= item.requestedQuantity) {
    return { action: "noop", reason: "sufficient stock, reservation should succeed" };
  }

  if (!item.allowBackorder) {
    return { action: "flag_legitimate_stockout", reason: "backorder disabled and out of stock, correct rejection" };
  }

  if (dryRun) {
    return { action: "flag_legitimate_stockout", reason: "backorder enabled but reservation step rejected it, retry recommended, dry run" };
  }

  return { action: "retry_complete", reason: "backorder enabled but reservation step rejected it, safe to retry cart completion" };
}

async function login() {
  const res = await fetch(`${BASE}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  return (await res.json()).token;
}

async function backorderVariants(token) {
  const res = await fetch(`${BASE}/admin/products?fields=${encodeURIComponent(VARIANT_FIELDS)}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  const out = [];
  for (const product of body.products) {
    for (const variant of product.variants || []) {
      if (variant.manage_inventory && variant.allow_backorder) {
        out.push({ productId: product.id, variant });
      }
    }
  }
  return out;
}

async function locationLevels(token, inventoryItemId) {
  const res = await fetch(
    `${BASE}/admin/inventory-items/${inventoryItemId}/location-levels?fields=${LEVEL_FIELDS}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).inventory_levels;
}

async function hasReservation(token, locationId, inventoryItemId) {
  const res = await fetch(
    `${BASE}/admin/reservations?location_id=${locationId}&inventory_item_id=${inventoryItemId}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.reservations.length > 0;
}

async function currentVariantSettings(token, productId, variantId) {
  const fields = "id,allow_backorder,manage_inventory";
  const res = await fetch(
    `${BASE}/admin/products/${productId}/variants/${variantId}?fields=${fields}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return (await res.json()).variant;
}

// Only called when DRY_RUN is false and the live variant confirmed
// allow_backorder and manage_inventory are both true.
async function retryCartComplete(cartId) {
  const res = await fetch(`${BASE}/store/carts/${cartId}/complete`, {
    method: "POST",
    headers: { "x-publishable-api-key": PUBLISHABLE_KEY },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

export async function run() {
  const token = await login();
  let flagged = 0;
  let retried = 0;

  for (const { productId, variant } of await backorderVariants(token)) {
    for (const invItem of variant.inventory_items || []) {
      const inventoryItemId = invItem.inventory?.id || invItem.id;
      if (!inventoryItemId) continue;

      for (const level of await locationLevels(token, inventoryItemId)) {
        const item = {
          variantId: variant.id,
          inventoryItemId,
          locationId: level.location_id,
          allowBackorder: variant.allow_backorder || false,
          manageInventory: variant.manage_inventory || false,
          stockedQuantity: level.stocked_quantity || 0,
          reservedQuantity: level.reserved_quantity || 0,
          // requestedQuantity is unknown ahead of the actual cart line item,
          // so we probe at the boundary (1 unit) to surface risk.
          requestedQuantity: 1,
        };
        const decision = decideReservationAction(item, DRY_RUN);
        if (decision.action === "noop") continue;

        console.warn(
          `variant=${item.variantId} inventory_item=${item.inventoryItemId} location=${item.locationId} action=${decision.action} reason=${decision.reason}`
        );
        flagged++;

        if (decision.action !== "retry_complete") continue;

        const fresh = await currentVariantSettings(token, productId, variant.id);
        if (!(fresh.allow_backorder && fresh.manage_inventory)) {
          console.log(`variant ${variant.id} no longer confirmed for backorder, skipping retry`);
          continue;
        }

        if (await hasReservation(token, item.locationId, item.inventoryItemId)) {
          console.log(`reservation already exists for ${item.inventoryItemId}, skipping retry`);
          continue;
        }

        console.log("live variant confirmed, would retry cart completion for stuck carts on this variant");
        retried++;
      }
    }
  }

  console.log(`Done. ${flagged} item(s) flagged, ${retried} confirmed safe to retry.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether a stuck cart is retried or left alone. Because decide_reservation_action is pure, the test needs no network and no live Medusa store. It just feeds in plain records and checks the answer.

test_decide_reservation.py
from retry_backorder_reservation import decide_reservation_action


def item(**over):
    base = {
        "variant_id": "variant_1",
        "inventory_item_id": "iitem_1",
        "location_id": "sloc_1",
        "allow_backorder": True,
        "manage_inventory": True,
        "stocked_quantity": 0,
        "reserved_quantity": 0,
        "requested_quantity": 1,
    }
    base.update(over)
    return base


def test_noop_when_inventory_not_managed():
    result = decide_reservation_action(item(manage_inventory=False), dry_run=True)
    assert result["action"] == "noop"


def test_noop_when_stock_sufficient():
    result = decide_reservation_action(item(stocked_quantity=5), dry_run=False)
    assert result["action"] == "noop"


def test_flag_legitimate_stockout_when_backorder_disabled():
    result = decide_reservation_action(item(allow_backorder=False), dry_run=False)
    assert result["action"] == "flag_legitimate_stockout"


def test_flag_when_backorder_enabled_but_dry_run():
    result = decide_reservation_action(item(), dry_run=True)
    assert result["action"] == "flag_legitimate_stockout"


def test_retry_when_backorder_enabled_negative_stock_and_not_dry_run():
    result = decide_reservation_action(item(stocked_quantity=-3), dry_run=False)
    assert result["action"] == "retry_complete"


def test_retry_when_backorder_enabled_zero_stock_and_not_dry_run():
    result = decide_reservation_action(item(stocked_quantity=0), dry_run=False)
    assert result["action"] == "retry_complete"


def test_retry_when_backorder_enabled_positive_but_insufficient_stock():
    result = decide_reservation_action(item(stocked_quantity=1, requested_quantity=5), dry_run=False)
    assert result["action"] == "retry_complete"
decide-reservation.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideReservationAction } from "./retry-backorder-reservation.js";

const item = (over = {}) => ({
  variantId: "variant_1",
  inventoryItemId: "iitem_1",
  locationId: "sloc_1",
  allowBackorder: true,
  manageInventory: true,
  stockedQuantity: 0,
  reservedQuantity: 0,
  requestedQuantity: 1,
  ...over,
});

test("noop when inventory not managed", () => {
  const result = decideReservationAction(item({ manageInventory: false }), true);
  assert.equal(result.action, "noop");
});

test("noop when stock sufficient", () => {
  const result = decideReservationAction(item({ stockedQuantity: 5 }), false);
  assert.equal(result.action, "noop");
});

test("flag legitimate stockout when backorder disabled", () => {
  const result = decideReservationAction(item({ allowBackorder: false }), false);
  assert.equal(result.action, "flag_legitimate_stockout");
});

test("flag when backorder enabled but dry run", () => {
  const result = decideReservationAction(item(), true);
  assert.equal(result.action, "flag_legitimate_stockout");
});

test("retry when backorder enabled, negative stock, not dry run", () => {
  const result = decideReservationAction(item({ stockedQuantity: -3 }), false);
  assert.equal(result.action, "retry_complete");
});

test("retry when backorder enabled, zero stock, not dry run", () => {
  const result = decideReservationAction(item({ stockedQuantity: 0 }), false);
  assert.equal(result.action, "retry_complete");
});

test("retry when backorder enabled, positive but insufficient stock", () => {
  const result = decideReservationAction(item({ stockedQuantity: 1, requestedQuantity: 5 }), false);
  assert.equal(result.action, "retry_complete");
});

Case studies

Made to order

A furniture brand's zero-stock bestseller went silent

A furniture seller kept a signature chair listed with allow_backorder on because every unit is built to order, so real stock was always zero. After a catalog import touched the variant, checkout on that chair started failing mid-transaction with the exact Not enough stock available error, and support had no idea why a deliberately backorderable item was being rejected.

Running this script surfaced the variant immediately, confirmed allow_backorder was still true on the live record, and showed the carts that had hit the exception with no matching order. Retrying completion on those carts fixed every one without touching the workflow code.

Flash sale

A restock delay looked like a broken checkout

During a flash sale, a supplement store's best-selling flavor sold to zero within minutes. Backorders were on by design so customers could keep ordering while a restock was in transit, but a batch of carts built right at the moment stock hit zero began failing to complete.

The team ran the script in dry run first, saw exactly which carts were stuck and confirmed their variant still allowed backorders, then let it retry completion for real. The stuck carts converted into orders and the restock delay stopped looking like a checkout bug.

What good looks like

After this runs, a backorder-enabled variant behaves the way it was configured to. Genuinely out-of-stock, non-backorder variants still correctly block the sale, but a stuck cart on a real backorder item gets a safe, re-verified retry instead of a manual reservation hack. Nothing is forced through without confirming the live setting first, so the fix never masks a real stock problem.

FAQ

Why does completeCartWorkflow fail with Not enough stock available when backorders are allowed?

completeCartWorkflow calls reserveInventoryStep for each line item, and that step only skips the stock check when the allow_backorder flag it receives is true. In some code paths the cart's line item carries stale or cached variant data, so the step evaluates the item as if backorders were disallowed and InventoryModuleService.ensureInventoryLevels throws Not enough stock available, aborting the whole workflow.

Is it safe to force-write a reservation that reserveInventoryStep rejected?

No. Forcing a reservation bypasses the same integrity check that protects legitimately out of stock, non-backorder variants, and it can mask a real data problem such as allow_backorder actually being false on the live variant. The safe fix is to confirm the current variant setting first, then retry cart completion so the workflow re-evaluates fresh data.

How do I know if a stuck cart is a real bug or a legitimate stockout?

Re-check the live variant with GET /admin/products/{id}/variants/{variant_id}. If manage_inventory and allow_backorder are both true and available stock is still at or below the requested quantity, the reservation step's rejection was the bug, and retrying POST /store/carts/{cart_id}/complete is the fix. If allow_backorder is false, the cart is correctly stuck and should only be flagged.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub Issue #13892: Complete Cart Workflow Fails at reserve-inventory-step Despite "Allow Backorders" Setting. github.com/medusajs/medusa/issues/13892
  2. medusajs/medusa GitHub Issue #10694: Sales channel not associated with any stock location when adding a backorder variant to cart. github.com/medusajs/medusa/issues/10694
  3. Medusa Documentation: Inventory Module concepts. docs.medusajs.com/resources/commerce-modules/inventory/concepts

On the solution:

  1. Medusa Core Workflows Reference: the reserveInventoryStep. docs.medusajs.com/resources/references/medusa-workflows/steps/reserveInventoryStep
  2. Medusa Core Workflows Reference: the completeCartWorkflow. docs.medusajs.com/resources/references/medusa-workflows/completeCartWorkflow
  3. Medusa Documentation: Product Variant Inventory. docs.medusajs.com/resources/commerce-modules/product/variant-inventory

Stuck on a tricky one?

If you have a problem in Medusa carts, inventory, reservations, payments, or fulfillment 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 unstick a checkout?

If this saved you from chasing a false stock error or from writing a reservation by hand, 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 Medusa field notes