Repair Fulfillment & Returns
Cannot cancel a fulfillment once stock has gone negative
Someone clicks Cancel fulfillment in the admin, or a script calls the same route, and it just will not go through. The order sits there, the fulfillment is neither canceled nor usable, and the logs point at an inventory step. Here is why Medusa v2's cancel-fulfillment workflow chokes once the location level behind it has already gone negative, and a script that finds every fulfillment stuck this way so you can fix the stock first and retry the cancel.
Canceling a fulfillment runs cancelFulfillmentWorkflow, which restores stock on the inventory location level tied to that fulfillment's line items. That restore step assumes the level's numbers already make sense. If the level's available stock, stocked_quantity minus reserved_quantity, is already negative from an earlier oversell or a drifted reservation, the restore can fail or leave the level worse off, so the cancel never finishes. Run a small Python or Node.js script that lists fulfillments not yet canceled, checks each one's inventory location level, and flags every fulfillment sitting behind a negative level so a human can reconcile the stock first and retry the cancel. Full code, tests, and a dry run guard are below.
The problem in plain words
Canceling a fulfillment in Medusa v2 is not just flipping a status. The cancelFulfillmentWorkflow also has to give back the stock that fulfillment claimed, so a canceled shipment does not quietly disappear inventory forever. That means a write against the same inventory location level the fulfillment already touched.
That write assumes the level it is writing to is in a normal state. But a location level can already be broken before the cancel ever runs. An earlier oversell can leave reserved_quantity higher than stocked_quantity. A direct external write from an ERP sync can set stocked_quantity without knowing what is already reserved. A reservation can drift out of sync with the order it belongs to. In any of these cases, available stock on that level is already negative, and the workflow's assumption that restoring stock returns things to a sane number stops holding. The result is a fulfillment that is stuck. It is not canceled, so it still shows as active and blocks whatever came next, like a return or a replacement shipment. But nobody can safely retry the cancel either, because retrying just runs the same broken math again.
Why it happens
The location level was already unhealthy before anyone tried to cancel anything. A few common paths get it there:
- An earlier oversell under concurrent checkout traffic left
reserved_quantityhigher thanstocked_quantityon the level, so available stock was negative before this fulfillment was ever canceled. - A custom integration or ERP sync wrote
stocked_quantitydirectly on the location level, outside the normal reservation flow, without accounting for what this fulfillment still had reserved against it. - A reservation tied to the order drifted out of sync, so the quantity the cancel workflow expects to restore no longer matches the quantity actually reserved at that location.
- A previous cancel or return on the same order partially ran, adjusted the level once, then failed partway, leaving the level in a half corrected state that the next cancel attempt trips over again.
This is a common source of confusion because the error usually surfaces as a workflow or inventory exception, not anything that says "your stock is negative." Support sees a failed cancel and assumes it is a fulfillment bug, when the real problem is the inventory level underneath it. See the citations at the end for the exact docs this rests on.
Retrying the cancel does not fix anything if the location level underneath it is already broken. The safe order of operations is: reconcile the location level against real, open reservations first, confirm the corrected number with a human, write it, and only then retry the cancel. That way the restore step has accurate numbers to work with instead of compounding a level that was already wrong.
The fix, as a flow
We do not force the cancel. We add a job that lists fulfillments not yet canceled, looks up the inventory location level behind each one, and flags the ones where available stock is already negative. A human reconciles the flagged level first. Only after the level is sane does the cancel get retried, by hand or by re-running the same admin action.
Build it step by step
Get an Admin API token
Authenticate against your own backend with POST /auth/user/emailpass using an admin email and password, and keep the returned JWT for the Authorization: Bearer header on every call after that. Keep the backend URL, email, and password in environment variables, never in the file.
pip install requests
export MEDUSA_BACKEND_URL="https://your-medusa-backend.example.com"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, change to false to write a report file
// Node 18+ has fetch built in, no dependencies needed
export MEDUSA_BACKEND_URL="https://your-medusa-backend.example.com"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, change to false to write a report file
Authenticate and build a small request helper
Trade the email and password for a JWT, then send it as a bearer token on every Admin API call. A small helper wraps the token, raises on a non-OK response, and returns parsed JSON so the rest of the script stays simple.
import os, requests
BASE_URL = os.environ["MEDUSA_BACKEND_URL"].rstrip("/")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BASE_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
const BASE_URL = (process.env.MEDUSA_BACKEND_URL || "").replace(/\/$/, "");
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
async function getToken() {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function adminGet(token, path, params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`${BASE_URL}${path}${qs ? `?${qs}` : ""}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return res.json();
}
List active fulfillments with their line items
Page through /admin/orders and expand fields=id,display_id,*fulfillments,*fulfillments.items,*fulfillments.items.line_item so each order carries its fulfillments and the line items on them. Keep only fulfillments that are not canceled, since a canceled one is not our concern.
def active_fulfillments(token):
offset = 0
limit = 50
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,display_id,*fulfillments,*fulfillments.items",
"limit": limit,
"offset": offset,
})
for order in data["orders"]:
for f in (order.get("fulfillments") or []):
if f.get("canceled_at"):
continue
yield order, f
offset += limit
if offset >= data["count"]:
return
async function* activeFulfillments(token) {
const limit = 50;
let offset = 0;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,display_id,*fulfillments,*fulfillments.items",
limit,
offset,
});
for (const order of data.orders) {
for (const f of order.fulfillments || []) {
if (f.canceled_at) continue;
yield [order, f];
}
}
offset += limit;
if (offset >= data.count) return;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the location level's numbers and returns true or false, with no network calls. A fulfillment is blocked when its location level's available stock, stocked_quantity minus reserved_quantity, is already negative. A pure function like this is easy to read and easy to test, which we do later.
def is_cancel_blocked_by_negative_stock(fulfillment, location_level):
if fulfillment.get("canceled_at"):
return False
if location_level is None:
return False
stocked = location_level.get("stocked_quantity")
reserved = location_level.get("reserved_quantity")
if stocked is None or reserved is None:
return False
available = stocked - reserved
return available < 0
export function isCancelBlockedByNegativeStock(fulfillment, locationLevel) {
if (fulfillment.canceled_at) return false;
if (!locationLevel) return false;
const { stocked_quantity: stocked, reserved_quantity: reserved } = locationLevel;
if (stocked == null || reserved == null) return false;
const available = stocked - reserved;
return available < 0;
}
Look up the location level behind a fulfillment
For each fulfillment's line items, resolve the inventory item id and call GET /admin/inventory-items/{"{"}inventory_item_id{"}"}/location-levels, then match the level for the fulfillment's stock location. That level's stocked_quantity and reserved_quantity are exactly what the pure decision function needs.
def location_level_for(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_item", {}).get("location_levels", [])
for lvl in levels:
if lvl.get("location_id") == location_id:
return lvl
return None
async function locationLevelFor(token, inventoryItemId, locationId) {
const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`, {
location_id: locationId,
});
const levels = data.inventory_item?.location_levels || [];
return levels.find((lvl) => lvl.location_id === locationId) || null;
}
Wire it together with a dry run guard
The loop ties every piece together: authenticate, page through active fulfillments, resolve each one's location level, run the pure decision function, and collect the blocked ones into a report. This script only reports. The location level fix and the cancel retry are separate, deliberate actions a human takes once the report is reviewed, since silently rewriting stocked_quantity or forcing a cancel could hide a real fulfillment or accounting problem.
This script never writes a location level and never calls the cancel route itself. It only reports which fulfillments are blocked and why. Reconciling the location level and retrying the cancel are separate steps a human takes after reviewing the numbers, the same discipline as any inventory recount.
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 is safe to run again and again because it only reads data and reports, it never writes an order, a fulfillment, or an inventory level.
"""Find Medusa v2 fulfillments that cannot cancel because their inventory
location level has already gone negative.
cancelFulfillmentWorkflow restores stock on the location level tied to a
fulfillment's line items. If that level's available stock (stocked_quantity
minus reserved_quantity) is already negative, from an earlier oversell, a
direct external write, or a drifted reservation, the restore step can fail
or leave the level worse off, so the fulfillment is stuck: neither canceled
nor usable. This script only reports blocked fulfillments. It never writes
a location level and never calls the cancel route. Safe to run again and
again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_blocked_cancels")
BASE_URL = os.environ["MEDUSA_BACKEND_URL"].rstrip("/")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BASE_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def is_cancel_blocked_by_negative_stock(fulfillment, location_level):
if fulfillment.get("canceled_at"):
return False
if location_level is None:
return False
stocked = location_level.get("stocked_quantity")
reserved = location_level.get("reserved_quantity")
if stocked is None or reserved is None:
return False
available = stocked - reserved
return available < 0
def active_fulfillments(token):
offset = 0
limit = 50
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,display_id,*fulfillments,*fulfillments.items",
"limit": limit,
"offset": offset,
})
for order in data["orders"]:
for f in (order.get("fulfillments") or []):
if f.get("canceled_at"):
continue
yield order, f
offset += limit
if offset >= data["count"]:
return
def location_level_for(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_item", {}).get("location_levels", [])
for lvl in levels:
if lvl.get("location_id") == location_id:
return lvl
return None
def fulfillment_inventory_refs(fulfillment):
"""Yield (inventory_item_id, location_id) pairs for a fulfillment's items."""
location_id = fulfillment.get("location_id")
for item in (fulfillment.get("items") or []):
inventory_item_id = item.get("inventory_item_id")
if inventory_item_id and location_id:
yield inventory_item_id, location_id
def run():
token = get_token()
blocked = []
for order, fulfillment in active_fulfillments(token):
for inventory_item_id, location_id in fulfillment_inventory_refs(fulfillment):
level = location_level_for(token, inventory_item_id, location_id)
if is_cancel_blocked_by_negative_stock(fulfillment, level):
blocked.append({
"order_id": order["id"],
"display_id": order.get("display_id"),
"fulfillment_id": fulfillment["id"],
"inventory_item_id": inventory_item_id,
"location_id": location_id,
"stocked_quantity": level["stocked_quantity"],
"reserved_quantity": level["reserved_quantity"],
})
log.warning(
"Order %s fulfillment %s blocked. Location %s available=%s.",
order.get("display_id"), fulfillment["id"], location_id,
level["stocked_quantity"] - level["reserved_quantity"],
)
log.info("Done. %d fulfillment(s) blocked by negative stock. %s",
len(blocked), "(dry run, report only)" if DRY_RUN else "(report only, no writes made)")
return blocked
if __name__ == "__main__":
run()
/**
* Find Medusa v2 fulfillments that cannot cancel because their inventory
* location level has already gone negative.
*
* cancelFulfillmentWorkflow restores stock on the location level tied to a
* fulfillment's line items. If that level's available stock (stocked_quantity
* minus reserved_quantity) is already negative, from an earlier oversell, a
* direct external write, or a drifted reservation, the restore step can fail
* or leave the level worse off, so the fulfillment is stuck: neither canceled
* nor usable. This script only reports blocked fulfillments. It never writes
* a location level and never calls the cancel route. Safe to run again and
* again.
*
* Guide: https://www.allanninal.dev/medusa/cancel-fulfillment-negative-stock/
*/
import { pathToFileURL } from "node:url";
const BASE_URL = (process.env.MEDUSA_BACKEND_URL || "http://localhost:9000").replace(/\/$/, "");
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function isCancelBlockedByNegativeStock(fulfillment, locationLevel) {
if (fulfillment.canceled_at) return false;
if (!locationLevel) return false;
const { stocked_quantity: stocked, reserved_quantity: reserved } = locationLevel;
if (stocked == null || reserved == null) return false;
const available = stocked - reserved;
return available < 0;
}
export function fulfillmentInventoryRefs(fulfillment) {
const locationId = fulfillment.location_id;
const refs = [];
for (const item of fulfillment.items || []) {
if (item.inventory_item_id && locationId) {
refs.push([item.inventory_item_id, locationId]);
}
}
return refs;
}
async function getToken() {
const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
if (!res.ok) throw new Error(`Auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function adminGet(token, path, params = {}) {
const qs = new URLSearchParams(params).toString();
const res = await fetch(`${BASE_URL}${path}${qs ? `?${qs}` : ""}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status}`);
return res.json();
}
async function* activeFulfillments(token) {
const limit = 50;
let offset = 0;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,display_id,*fulfillments,*fulfillments.items",
limit,
offset,
});
for (const order of data.orders) {
for (const f of order.fulfillments || []) {
if (f.canceled_at) continue;
yield [order, f];
}
}
offset += limit;
if (offset >= data.count) return;
}
}
async function locationLevelFor(token, inventoryItemId, locationId) {
const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`, {
location_id: locationId,
});
const levels = data.inventory_item?.location_levels || [];
return levels.find((lvl) => lvl.location_id === locationId) || null;
}
export async function run() {
const token = await getToken();
const blocked = [];
for await (const [order, fulfillment] of activeFulfillments(token)) {
for (const [inventoryItemId, locationId] of fulfillmentInventoryRefs(fulfillment)) {
const level = await locationLevelFor(token, inventoryItemId, locationId);
if (isCancelBlockedByNegativeStock(fulfillment, level)) {
blocked.push({
orderId: order.id,
displayId: order.display_id,
fulfillmentId: fulfillment.id,
inventoryItemId,
locationId,
stockedQuantity: level.stocked_quantity,
reservedQuantity: level.reserved_quantity,
});
console.warn(
`Order ${order.display_id} fulfillment ${fulfillment.id} blocked. Location ${locationId} available=${level.stocked_quantity - level.reserved_quantity}.`
);
}
}
}
console.log(
`Done. ${blocked.length} fulfillment(s) blocked by negative stock. ${DRY_RUN ? "(dry run, report only)" : "(report only, no writes made)"}`
);
return blocked;
}
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 fulfillment gets reported as blocked. Because we kept is_cancel_blocked_by_negative_stock pure, the test needs no network and no Medusa backend. It just feeds in plain objects and checks the answer.
from find_blocked_cancels import is_cancel_blocked_by_negative_stock
def fulfillment(**over):
base = {"id": "ful_1", "canceled_at": None}
base.update(over)
return base
def level(**over):
base = {"stocked_quantity": 5, "reserved_quantity": 3}
base.update(over)
return base
def test_blocked_when_available_is_negative():
lvl = level(stocked_quantity=2, reserved_quantity=5)
assert is_cancel_blocked_by_negative_stock(fulfillment(), lvl) is True
def test_not_blocked_when_available_is_zero():
lvl = level(stocked_quantity=5, reserved_quantity=5)
assert is_cancel_blocked_by_negative_stock(fulfillment(), lvl) is False
def test_not_blocked_when_available_is_positive():
assert is_cancel_blocked_by_negative_stock(fulfillment(), level()) is False
def test_not_blocked_when_already_canceled():
lvl = level(stocked_quantity=1, reserved_quantity=9)
assert is_cancel_blocked_by_negative_stock(fulfillment(canceled_at="2026-07-01T00:00:00Z"), lvl) is False
def test_not_blocked_when_no_location_level():
assert is_cancel_blocked_by_negative_stock(fulfillment(), None) is False
def test_not_blocked_when_quantities_missing():
lvl = level(stocked_quantity=None, reserved_quantity=None)
assert is_cancel_blocked_by_negative_stock(fulfillment(), lvl) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isCancelBlockedByNegativeStock } from "./find-blocked-cancels.js";
const fulfillment = (over = {}) => ({ id: "ful_1", canceled_at: null, ...over });
const level = (over = {}) => ({ stocked_quantity: 5, reserved_quantity: 3, ...over });
test("blocked when available is negative", () => {
const lvl = level({ stocked_quantity: 2, reserved_quantity: 5 });
assert.equal(isCancelBlockedByNegativeStock(fulfillment(), lvl), true);
});
test("not blocked when available is zero", () => {
const lvl = level({ stocked_quantity: 5, reserved_quantity: 5 });
assert.equal(isCancelBlockedByNegativeStock(fulfillment(), lvl), false);
});
test("not blocked when available is positive", () => {
assert.equal(isCancelBlockedByNegativeStock(fulfillment(), level()), false);
});
test("not blocked when already canceled", () => {
const lvl = level({ stocked_quantity: 1, reserved_quantity: 9 });
assert.equal(isCancelBlockedByNegativeStock(fulfillment({ canceled_at: "2026-07-01T00:00:00Z" }), lvl), false);
});
test("not blocked when no location level", () => {
assert.equal(isCancelBlockedByNegativeStock(fulfillment(), null), false);
});
test("not blocked when quantities missing", () => {
const lvl = level({ stocked_quantity: null, reserved_quantity: null });
assert.equal(isCancelBlockedByNegativeStock(fulfillment(), lvl), false);
});
Case studies
A return could not be started because the cancel would not clear
A store ran a flash sale that oversold a limited item under concurrent checkout traffic, leaving the location level with reserved_quantity above stocked_quantity. Days later a customer wanted to swap an item, which meant canceling the original fulfillment first. The cancel kept failing, and support had no idea the real cause was a location level that had gone negative before the return was even attempted.
Running the scan immediately flagged the fulfillment against the exact location level and quantities involved. Once the level was reconciled from real open reservations, the cancel went through on the next try, and the swap shipped the same day.
A nightly stock overwrite broke cancels store-wide
A merchant's nightly ERP sync pushed warehouse counts straight into stocked_quantity without accounting for what was already reserved by same-day orders. Several location levels ended up negative every morning, and any fulfillment cancel touching one of those levels failed silently in the admin.
The team added this scan to their morning checklist. It gave them the exact list of blocked fulfillments before a customer ever noticed, so stock got reconciled first and cancels stopped failing across the board.
After this runs on a schedule, a fulfillment cancel never gets stuck as a mystery. The scan tells you exactly which location level is broken and by how much, before anyone wastes time retrying a cancel that cannot succeed. Fix the stock first, then the cancel goes through cleanly, the same order of operations every time.
FAQ
Why can I not cancel a Medusa fulfillment once the inventory level is negative?
Canceling a fulfillment runs cancelFulfillmentWorkflow, which restores stock on the inventory location level tied to that fulfillment's line items. That restore assumes the level's stocked_quantity and reserved_quantity reflect reality. If the level already sits with available stock negative, from an earlier oversell, a direct external write, or a reservation that drifted, the restore step can fail validation or push the numbers further out of shape instead of returning the level to a sane state, so the fulfillment is left neither canceled nor fulfillable.
Is it safe to force a fulfillment cancel when stock is negative?
No, not directly. Forcing the cancel without first fixing the location level just moves the same bad numbers forward. The safe order of operations is to reconcile the inventory location level against real open reservations first, confirm the recount with a human, write the corrected stocked_quantity, and only then retry the cancel so the restore step has accurate numbers to work with.
How do I find every fulfillment stuck behind a negative stock level?
List orders with the Admin API and expand fields=id,display_id,*fulfillments,*fulfillments.items, then for each fulfillment not yet canceled, look up its line items' inventory items and call GET /admin/inventory-items/{"{id}"}/location-levels for the fulfillment's stock location. A fulfillment is blocked when it is still active, cancellation was attempted or is expected, and the location level's stocked_quantity minus reserved_quantity is negative.
Related field notes
Stuck on a tricky one?
If you have a problem in Medusa orders, fulfillment, inventory, or payments that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this unblock a stuck cancel?
If this saved you from chasing a mystery workflow failure or a wrong revenue report, 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