Reconciler Inventory and stock

Stuck reservations after cancelled carts

A shopper adds the last unit to their cart, then closes the tab, or the checkout times out, or someone voids the cart by hand outside the normal flow. The cart is gone, but the stock is not free. Reserved quantity keeps sitting against that inventory item as if a real order still needed it. Here is why Medusa v2 leaves these reservation rows behind and a small script that finds only the stale ones and releases them safely.

Python and Node.js Medusa Admin API Safe by default (dry run)
The short answer

In Medusa v2, the Inventory Module creates a ReservationItem (res_...) linked to a cart's line_item_id, inventory_item_id, and location_id as soon as stock is reserved, for example during cart completion or payment authorization. Reservations are only cleaned up by specific compensating steps in workflows like completeCartWorkflow or order cancellation. There is no cart cancel workflow that reliably deletes the associated reservations, and ReservationItem has no cart_id field to join back to, so an abandoned, timed out, or manually voided cart leaves orphaned reservation rows that keep counting against reserved_quantity. Run a script that lists reservations, resolves each line_item_id against real orders, and deletes only the ones that are truly orphaned or attached to a canceled order. Full code, tests, and a dry run guard are below.

The problem in plain words

When a cart reserves stock, Medusa writes a ReservationItem row that says, in effect, this many units of this inventory item at this location are spoken for. That row exists independently of the cart. It is not deleted automatically when the cart disappears, because Medusa never guarantees a cart "cancel" step at all. Carts can just stop existing: the browser tab closes, the session times out, or someone manually voids an order outside the normal checkout path.

The reservation itself has no way to tell it has been abandoned. It has a line_item_id, an inventory_item_id, and a location_id, but no cart_id field to check against. So the row just sits there, forever counted as reserved, unless a specific compensating step in a workflow like completeCartWorkflow or order cancellation happens to clean it up. When that step does not run, the reservation is orphaned, and the location level's reserved_quantity stays inflated even though nothing real is holding the stock.

Cart reserves stock ReservationItem created Cart abandoned, timed out, or voided outside checkout no cart_id on the reservation no reliable cleanup step Reservation orphaned row still exists reserved_quantity stays inflated Available stock too low
The cart disappears, but the ReservationItem it created does not. With no cart_id to join back to and no guaranteed cleanup step, the row is orphaned and keeps stock locked up.

Why it happens

Reservations are created eagerly, the moment stock needs to be held, but they are only released by narrow, specific code paths. A few common ways stores end up with stale rows:

ReservationItem simply was not designed with a back-reference to the cart that created it. There is a line_item_id, but once the cart is gone, or the order tied to that line item is canceled, nothing tells the Inventory Module to go clean up the row. See the citations at the end for the exact issue and docs.

The key insight

A reservation is only safe to delete once you know, with certainty, that nothing legitimate still needs the stock it holds. That means resolving every reservation's line_item_id against real orders first. If no order has a matching line item, the cart was never completed and the reservation is an orphan. If an order does match but its status is canceled, the reservation should have been released and was not. Anything still active, pending, or processing must be left alone, because deleting that reservation would free stock a real order still holds.

The fix, as a flow

We do not touch live checkouts. The job lists reservations, builds an index of every order's line items so it can resolve each reservation's line_item_id, runs a pure decision function to classify each reservation, and only deletes the ones classified as stale, age gated so nothing mid-checkout gets caught.

List reservations id, line_item_id, quantity Index order line items order id and status Pure decision fn classifyReservation Stale orphan or canceled order? yes, dry run off no, keep Reservation kept still active or too new DELETE reservation reserved_quantity drops
Every reservation is classified first. Only a stale orphan or a reservation tied to a canceled order is ever deleted, and only with DRY_RUN off.

Build it step by step

1

Authenticate against the Admin API

Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials 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 STALE_AFTER_HOURS="24"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install @medusajs/js-sdk

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

List reservations with their line items and inventory items

Ask for every reservation with the fields the decision needs: quantity, line_item_id, inventory_item_id, location_id, and created_at. Paginate with limit and offset since a busy store can carry hundreds of these rows.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]

def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def list_reservations(token):
    reservations = []
    offset = 0
    limit = 200
    while True:
        data = admin_get(token, "/admin/reservations", {
            "fields": "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*inventory_item",
            "limit": limit,
            "offset": offset,
        })
        reservations.extend(data["reservations"])
        offset += limit
        if offset >= data["count"]:
            return reservations
step2.js
import Medusa from "@medusajs/js-sdk";

const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });

async function listReservations() {
  const reservations = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const { reservations: page, count } = await sdk.admin.reservation.list({
      fields: "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*inventory_item",
      limit,
      offset,
    });
    reservations.push(...page);
    offset += limit;
    if (offset >= count) return reservations;
  }
}
3

Index every order's line items by their owning order

Pull orders with their items and build a lookup from line_item_id to the order that owns it, along with that order's status. This is what lets the script resolve whether a reservation's cart was ever completed, and if so, whether the order is still active or was canceled.

step3.py
def build_order_line_item_index(token):
    """Returns { line_item_id: {"orderId": str, "orderStatus": str} }."""
    index = {}
    offset = 0
    limit = 200
    while True:
        data = admin_get(token, "/admin/orders", {
            "fields": "id,status,*items",
            "limit": limit,
            "offset": offset,
        })
        for order in data["orders"]:
            for item in order.get("items") or []:
                index[item["id"]] = {"orderId": order["id"], "orderStatus": order["status"]}
        offset += limit
        if offset >= data["count"]:
            return index
step3.js
async function buildOrderLineItemIndex() {
  const index = new Map();
  let offset = 0;
  const limit = 200;
  while (true) {
    const { orders, count } = await sdk.admin.order.list({
      fields: "id,status,*items",
      limit,
      offset,
    });
    for (const order of orders) {
      for (const item of order.items || []) {
        index.set(item.id, { orderId: order.id, orderStatus: order.status });
      }
    }
    offset += limit;
    if (offset >= count) return index;
  }
}
4

Decide, with one pure function

Keep the classification in its own function that takes a reservation, the order line item index, the current time, and the staleness window, and returns a plain answer. It never touches the network, so it is trivial to unit test with fixture data. A reservation with no line_item_id, or one younger than the safety window, or one whose order is still active is always kept. Only a true orphan or a reservation tied to a canceled order is ever flagged stale.

decide.py
def classify_reservation(reservation, order_line_item_index, now, stale_after_ms):
    """Pure decision function. No I/O.

    reservation: {"id": str, "line_item_id": str | None, "created_at": str (ISO)}
    order_line_item_index: {line_item_id: {"orderId": str, "orderStatus": str}}
    now: datetime, timezone aware
    stale_after_ms: float

    Returns "keep" | "stale_orphan" | "stale_canceled_order".
    """
    line_item_id = reservation.get("line_item_id")
    if not line_item_id:
        return "keep"

    created_at = datetime.fromisoformat(reservation["created_at"].replace("Z", "+00:00"))
    age_ms = (now - created_at).total_seconds() * 1000
    if age_ms < stale_after_ms:
        return "keep"

    match = order_line_item_index.get(line_item_id)
    if match is None:
        return "stale_orphan"
    if match["orderStatus"] == "canceled":
        return "stale_canceled_order"
    return "keep"
decide.js
/**
 * Pure decision function. No I/O.
 *
 * @param {{ id: string, line_item_id: string | null, created_at: string }} reservation
 * @param {Map<string, { orderId: string, orderStatus: string }>} orderLineItemIndex
 * @param {Date} now
 * @param {number} staleAfterMs
 * @returns {"keep" | "stale_orphan" | "stale_canceled_order"}
 */
export function classifyReservation(reservation, orderLineItemIndex, now, staleAfterMs) {
  const lineItemId = reservation.line_item_id;
  if (!lineItemId) return "keep";

  const ageMs = now.getTime() - new Date(reservation.created_at).getTime();
  if (ageMs < staleAfterMs) return "keep";

  const match = orderLineItemIndex.get(lineItemId);
  if (!match) return "stale_orphan";
  if (match.orderStatus === "canceled") return "stale_canceled_order";
  return "keep";
}
5

Delete only the stale reservations, and verify the drop

When a reservation is classified as stale, call DELETE /admin/reservations/{id}. This invokes the Inventory Module's deleteReservationItem logic, which decrements reserved_quantity on the corresponding location level. After a real delete, re-fetch the location level and confirm the number actually dropped by the reservation's quantity, so a silent failure never gets logged as a success.

step5.py
def delete_reservation(token, reservation_id):
    r = requests.delete(
        f"{BACKEND_URL}/admin/reservations/{reservation_id}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def get_location_level(token, inventory_item_id, location_id):
    data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels", {
        "location_id": location_id,
    })
    levels = data.get("inventory_levels") or []
    return levels[0] if levels else None
step5.js
async function deleteReservation(reservationId) {
  return sdk.admin.reservation.delete(reservationId);
}

async function getLocationLevel(inventoryItemId, locationId) {
  const { inventory_levels } = await sdk.admin.inventoryItem.listLocationLevels(inventoryItemId, {
    location_id: locationId,
  });
  return (inventory_levels || [])[0] || null;
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs which reservations it would delete, along with the inventory_item_id, location_id, and quantity that would be released. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that fits how many carts your store abandons, for example once an hour.

Run it safe

Always start with DRY_RUN=true, and never lower STALE_AFTER_HOURS below what a slow but legitimate checkout could take. Deleting a reservation still tied to an active order releases stock a real customer's order depends on, so the age gate and the order status check both have to hold before anything is deleted.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever deletes a reservation that is either a true orphan or tied to an order that is already canceled.

View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.

release_stuck_reservations.py
"""Release Medusa reservations orphaned by abandoned or cancelled carts.

When stock is reserved for a cart, Medusa creates a ReservationItem linked to a
line_item_id, inventory_item_id, and location_id. There is no cart cancel workflow
that reliably deletes that reservation, and ReservationItem has no cart_id field to
join back to, so an abandoned, timed out, or manually voided cart leaves the row
behind. This lists reservations, resolves each line_item_id against real orders,
and deletes only the ones that are a true orphan or tied to a canceled order, after
an age gate so an in-flight checkout is never touched.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
from datetime import datetime, timezone

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

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
STALE_AFTER_HOURS = float(os.environ.get("STALE_AFTER_HOURS", "24"))
STALE_AFTER_MS = STALE_AFTER_HOURS * 3600 * 1000
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def admin_get(token, path, params=None):
    r = requests.get(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        params=params or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def admin_delete(token, path):
    r = requests.delete(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def classify_reservation(reservation, order_line_item_index, now, stale_after_ms):
    """Pure decision function. No I/O.

    reservation: {"id": str, "line_item_id": str | None, "created_at": str (ISO)}
    order_line_item_index: {line_item_id: {"orderId": str, "orderStatus": str}}
    now: datetime, timezone aware
    stale_after_ms: float

    Returns "keep" | "stale_orphan" | "stale_canceled_order".
    """
    line_item_id = reservation.get("line_item_id")
    if not line_item_id:
        return "keep"

    created_at = datetime.fromisoformat(reservation["created_at"].replace("Z", "+00:00"))
    age_ms = (now - created_at).total_seconds() * 1000
    if age_ms < stale_after_ms:
        return "keep"

    match = order_line_item_index.get(line_item_id)
    if match is None:
        return "stale_orphan"
    if match["orderStatus"] == "canceled":
        return "stale_canceled_order"
    return "keep"


def list_reservations(token):
    reservations = []
    offset = 0
    limit = 200
    while True:
        data = admin_get(token, "/admin/reservations", {
            "fields": "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*inventory_item",
            "limit": limit,
            "offset": offset,
        })
        reservations.extend(data["reservations"])
        offset += limit
        if offset >= data["count"]:
            return reservations


def build_order_line_item_index(token):
    index = {}
    offset = 0
    limit = 200
    while True:
        data = admin_get(token, "/admin/orders", {
            "fields": "id,status,*items",
            "limit": limit,
            "offset": offset,
        })
        for order in data["orders"]:
            for item in order.get("items") or []:
                index[item["id"]] = {"orderId": order["id"], "orderStatus": order["status"]}
        offset += limit
        if offset >= data["count"]:
            return index


def get_location_level(token, inventory_item_id, location_id):
    data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels", {
        "location_id": location_id,
    })
    levels = data.get("inventory_levels") or []
    return levels[0] if levels else None


def run():
    token = get_admin_token()
    reservations = list_reservations(token)
    order_line_item_index = build_order_line_item_index(token)
    now = datetime.now(timezone.utc)

    released = 0
    for reservation in reservations:
        outcome = classify_reservation(reservation, order_line_item_index, now, STALE_AFTER_MS)
        if outcome == "keep":
            continue

        before = get_location_level(token, reservation["inventory_item_id"], reservation["location_id"])
        before_reserved = before.get("reserved_quantity") if before else None

        log.warning(
            "Reservation %s classified as %s. inventory_item_id=%s location_id=%s quantity=%s. %s",
            reservation["id"], outcome, reservation["inventory_item_id"], reservation["location_id"],
            reservation["quantity"], "Would delete" if DRY_RUN else "Deleting",
        )

        if not DRY_RUN:
            admin_delete(token, f"/admin/reservations/{reservation['id']}")
            after = get_location_level(token, reservation["inventory_item_id"], reservation["location_id"])
            after_reserved = after.get("reserved_quantity") if after else None
            expected = (before_reserved - reservation["quantity"]) if before_reserved is not None else None
            if expected is not None and after_reserved != expected:
                log.warning(
                    "  reserved_quantity did not drop as expected for %s: before=%s after=%s expected=%s",
                    reservation["inventory_item_id"], before_reserved, after_reserved, expected,
                )
            else:
                log.info("  reserved_quantity confirmed: before=%s after=%s", before_reserved, after_reserved)

        released += 1

    log.info("Done. %d reservation(s) %s.", released, "to release" if DRY_RUN else "released")


if __name__ == "__main__":
    run()
release-stuck-reservations.js
/**
 * Release Medusa reservations orphaned by abandoned or cancelled carts.
 *
 * When stock is reserved for a cart, Medusa creates a ReservationItem linked to a
 * line_item_id, inventory_item_id, and location_id. There is no cart cancel workflow
 * that reliably deletes that reservation, and ReservationItem has no cart_id field to
 * join back to, so an abandoned, timed out, or manually voided cart leaves the row
 * behind. This lists reservations, resolves each line_item_id against real orders,
 * and deletes only the ones that are a true orphan or tied to a canceled order, after
 * an age gate so an in-flight checkout is never touched.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/stuck-reservations-after-cancelled-carts/
 */
import { pathToFileURL } from "node:url";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const STALE_AFTER_HOURS = Number(process.env.STALE_AFTER_HOURS || 24);
const STALE_AFTER_MS = STALE_AFTER_HOURS * 3600 * 1000;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

/**
 * Pure decision function. No I/O.
 *
 * @param {{ id: string, line_item_id: string | null, created_at: string }} reservation
 * @param {Map<string, { orderId: string, orderStatus: string }>} orderLineItemIndex
 * @param {Date} now
 * @param {number} staleAfterMs
 * @returns {"keep" | "stale_orphan" | "stale_canceled_order"}
 */
export function classifyReservation(reservation, orderLineItemIndex, now, staleAfterMs) {
  const lineItemId = reservation.line_item_id;
  if (!lineItemId) return "keep";

  const ageMs = now.getTime() - new Date(reservation.created_at).getTime();
  if (ageMs < staleAfterMs) return "keep";

  const match = orderLineItemIndex.get(lineItemId);
  if (!match) return "stale_orphan";
  if (match.orderStatus === "canceled") return "stale_canceled_order";
  return "keep";
}

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

async function adminGet(token, path, params = {}) {
  const url = new URL(`${BACKEND_URL}${path}`);
  for (const [key, value] of Object.entries(params)) {
    url.searchParams.set(key, value);
  }
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
  return res.json();
}

async function adminDelete(token, path) {
  const res = await fetch(`${BACKEND_URL}${path}`, {
    method: "DELETE",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status} on DELETE ${path}`);
  return res.json();
}

async function listReservations(token) {
  const reservations = [];
  let offset = 0;
  const limit = 200;
  while (true) {
    const data = await adminGet(token, "/admin/reservations", {
      fields: "id,quantity,line_item_id,inventory_item_id,location_id,created_at,*inventory_item",
      limit,
      offset,
    });
    reservations.push(...data.reservations);
    offset += limit;
    if (offset >= data.count) return reservations;
  }
}

async function buildOrderLineItemIndex(token) {
  const index = new Map();
  let offset = 0;
  const limit = 200;
  while (true) {
    const data = await adminGet(token, "/admin/orders", {
      fields: "id,status,*items",
      limit,
      offset,
    });
    for (const order of data.orders) {
      for (const item of order.items || []) {
        index.set(item.id, { orderId: order.id, orderStatus: order.status });
      }
    }
    offset += limit;
    if (offset >= data.count) return index;
  }
}

async function getLocationLevel(token, inventoryItemId, locationId) {
  const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`, {
    location_id: locationId,
  });
  const levels = data.inventory_levels || [];
  return levels[0] || null;
}

export async function run() {
  const token = await getAdminToken();
  const reservations = await listReservations(token);
  const orderLineItemIndex = await buildOrderLineItemIndex(token);
  const now = new Date();

  let released = 0;
  for (const reservation of reservations) {
    const outcome = classifyReservation(reservation, orderLineItemIndex, now, STALE_AFTER_MS);
    if (outcome === "keep") continue;

    const before = await getLocationLevel(token, reservation.inventory_item_id, reservation.location_id);
    const beforeReserved = before ? before.reserved_quantity : undefined;

    console.warn(
      `Reservation ${reservation.id} classified as ${outcome}. inventory_item_id=${reservation.inventory_item_id} location_id=${reservation.location_id} quantity=${reservation.quantity}. ${DRY_RUN ? "Would delete" : "Deleting"}`
    );

    if (!DRY_RUN) {
      await adminDelete(token, `/admin/reservations/${reservation.id}`);
      const after = await getLocationLevel(token, reservation.inventory_item_id, reservation.location_id);
      const afterReserved = after ? after.reserved_quantity : undefined;
      const expected = beforeReserved !== undefined ? beforeReserved - reservation.quantity : undefined;
      if (expected !== undefined && afterReserved !== expected) {
        console.warn(
          `  reserved_quantity did not drop as expected for ${reservation.inventory_item_id}: before=${beforeReserved} after=${afterReserved} expected=${expected}`
        );
      } else {
        console.log(`  reserved_quantity confirmed: before=${beforeReserved} after=${afterReserved}`);
      }
    }

    released++;
  }

  console.log(`Done. ${released} reservation(s) ${DRY_RUN ? "to release" : "released"}.`);
}

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

Add a test

classify_reservation is the part most worth testing, because it decides which reservations are safe to delete. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain data structures and a fixed clock, and checks the answer.

test_stuck_reservations.py
from datetime import datetime, timezone
from release_stuck_reservations import classify_reservation

NOW = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
STALE_AFTER_MS = 24 * 3600 * 1000


def reservation(**over):
    base = {"id": "res_1", "line_item_id": "item_1", "created_at": "2026-07-08T00:00:00Z"}
    base.update(over)
    return base


def test_keep_when_no_line_item_id():
    r = reservation(line_item_id=None)
    assert classify_reservation(r, {}, NOW, STALE_AFTER_MS) == "keep"


def test_keep_when_younger_than_stale_window():
    r = reservation(created_at="2026-07-09T23:00:00Z")
    assert classify_reservation(r, {}, NOW, STALE_AFTER_MS) == "keep"


def test_stale_orphan_when_no_matching_order():
    r = reservation()
    assert classify_reservation(r, {}, NOW, STALE_AFTER_MS) == "stale_orphan"


def test_stale_canceled_order_when_order_is_canceled():
    index = {"item_1": {"orderId": "order_1", "orderStatus": "canceled"}}
    r = reservation()
    assert classify_reservation(r, index, NOW, STALE_AFTER_MS) == "stale_canceled_order"


def test_keep_when_order_is_still_active():
    index = {"item_1": {"orderId": "order_1", "orderStatus": "pending"}}
    r = reservation()
    assert classify_reservation(r, index, NOW, STALE_AFTER_MS) == "keep"


def test_exactly_at_stale_window_is_stale():
    r = reservation(created_at="2026-07-09T00:00:00Z")
    assert classify_reservation(r, {}, NOW, STALE_AFTER_MS) == "stale_orphan"
reservations.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyReservation } from "./release-stuck-reservations.js";

const NOW = new Date("2026-07-10T00:00:00Z");
const STALE_AFTER_MS = 24 * 3600 * 1000;

const reservation = (over = {}) => ({
  id: "res_1",
  line_item_id: "item_1",
  created_at: "2026-07-08T00:00:00Z",
  ...over,
});

test("keep when no line_item_id", () => {
  const r = reservation({ line_item_id: null });
  assert.equal(classifyReservation(r, new Map(), NOW, STALE_AFTER_MS), "keep");
});

test("keep when younger than stale window", () => {
  const r = reservation({ created_at: "2026-07-09T23:00:00Z" });
  assert.equal(classifyReservation(r, new Map(), NOW, STALE_AFTER_MS), "keep");
});

test("stale orphan when no matching order", () => {
  const r = reservation();
  assert.equal(classifyReservation(r, new Map(), NOW, STALE_AFTER_MS), "stale_orphan");
});

test("stale canceled order when order is canceled", () => {
  const index = new Map([["item_1", { orderId: "order_1", orderStatus: "canceled" }]]);
  const r = reservation();
  assert.equal(classifyReservation(r, index, NOW, STALE_AFTER_MS), "stale_canceled_order");
});

test("keep when order is still active", () => {
  const index = new Map([["item_1", { orderId: "order_1", orderStatus: "pending" }]]);
  const r = reservation();
  assert.equal(classifyReservation(r, index, NOW, STALE_AFTER_MS), "keep");
});

test("exactly at stale window is stale", () => {
  const r = reservation({ created_at: "2026-07-09T00:00:00Z" });
  assert.equal(classifyReservation(r, new Map(), NOW, STALE_AFTER_MS), "stale_orphan");
});

Case studies

Abandoned checkout

The flash sale that locked up its own stock

A store ran a flash sale on a limited run item. Hundreds of shoppers added the last few units to their carts during payment authorization, then closed the tab when the price felt too high at the last step. Each of those carts left a reservation behind, and within an hour the item showed zero available stock even though almost none of it had actually sold.

Running the classifier in dry run surfaced every one of those reservations as stale_orphan, since none of them matched a real order. The team reviewed the list, ran it for real, and the item's true availability came back immediately.

Manual cancellation

Support cancelled the order, but the stock stayed reserved

A support agent cancelled a customer's order directly through a database fix during an incident, bypassing the normal cancellation workflow. The order's status flipped to canceled, but the compensating step that would have deleted the reservation never ran, so the item stayed reserved for stock nobody needed anymore.

The script's order status check matched the line item to that now canceled order and classified it as stale_canceled_order. Deleting it dropped reserved_quantity back down, confirmed by the location level re-fetch right after.

What good looks like

After this runs on a schedule, reserved quantity always reflects stock that a real, active order still needs. Orphaned reservations from abandoned checkouts and reservations tied to orders that were cancelled outside the normal flow get cleared automatically, and every deletion is verified against the location level before it is counted as done. Real in-flight checkouts are never touched, because the age gate and the order status check both have to agree first.

FAQ

Why does my Medusa inventory still show reserved stock for a cart that no longer exists?

When stock is reserved for a cart, Medusa creates a ReservationItem linked to the cart's line item, inventory item, and location. There is no cart cancel workflow that reliably deletes that reservation, and ReservationItem has no cart_id field to join back to, so an abandoned, timed out, or manually voided cart leaves its reservation rows behind. Those rows keep counting against reserved_quantity even though no live order holds the stock.

Is it safe to delete Medusa reservations with a script?

Yes, when the script only deletes a reservation after confirming its line_item_id does not belong to any existing order, or belongs only to an order whose status is canceled, and only once the reservation is older than a safety window such as 24 hours. That age gate keeps it from racing an in-flight checkout that has not finished yet.

What happens when you delete a ReservationItem in Medusa?

Deleting a reservation calls the Inventory Module's deleteReservationItem logic, which decrements reserved_quantity on the corresponding inventory location level by the reservation's quantity. That frees the stock so stocked_quantity minus reserved_quantity, the available quantity, goes back up immediately.

Related field notes

Citations

On the problem:

  1. Order fulfillment is not deleting inventory item reservations sometimes. Medusa GitHub Issue #11266. github.com/medusajs/medusa/issues/11266
  2. Medusa Documentation: Inventory Module Concepts. docs.medusajs.com/resources/commerce-modules/inventory/concepts
  3. Medusa Admin User Guide: Manage Reservations. docs.medusajs.com/user-guide/inventory/reservations

On the solution:

  1. Medusa Documentation: Inventory Module. docs.medusajs.com/resources/commerce-modules/inventory
  2. Medusa Documentation: Inventory Module in Medusa Flows. docs.medusajs.com/resources/commerce-modules/inventory/inventory-in-flows
  3. Medusa Documentation: ReservationItem, Inventory Module Data Models. docs.medusajs.com/resources/references/inventory-next/models/ReservationItem

Stuck on a tricky one?

If you have a problem in Medusa storefront access, pricing, inventory, orders, promotions, or workflows 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 free up your stock?

If this saved you from a phantom out of stock item or a support ticket about inventory that would not add up, 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