Diagnostic Fulfillment & Returns
Fulfillment created event never fires for untracked items
The fulfillment shows up on the order. Tracking looks right. Everything in the admin says it shipped. But the customer never got a shipment email, and nothing in your logs says why. When every item on that fulfillment is untracked inventory, Medusa v2 can create the fulfillment and quietly skip the event that was supposed to tell your notification subscriber about it. Here is why it happens and a script that finds every fulfillment it happened to.
createOrderFulfillmentWorkflow, the workflow behind POST /admin/orders/{id}/fulfillments, runs emitEventStep for order.fulfillment_created as one of its last steps, downstream of the inventory reservation steps that only touch line items whose variant has manage_inventory: true. When every fulfilled item is untracked inventory, those reservation steps have nothing to operate on, and the gap tracked as medusajs/medusa#10721 lets the step graph finish before it reaches emitEventStep. The fulfillment record itself is created correctly, so nothing errors and nothing retries. Run a small Python or Node.js script that pages through recent orders, flags every fulfillment where all items are untracked and no matching notification record exists, and, only when you turn off dry run, re-emits the missed event directly through the event bus so your existing subscriber fires. Full code, tests, and a dry run guard are below.
The problem in plain words
Marking an order fulfilled in Medusa v2 is not one write, it is a workflow with several steps in a row. Some of those steps update inventory reservations. Some create the fulfillment record. One of the very last ones emits an event so anything listening, like a shipment-notification subscriber, can react.
Those reservation steps only exist to do something when a variant is set up with manage_inventory: true. If a store sells digital items, services, or anything else marked as untracked, there is nothing for those steps to reserve or release. On a fulfillment where every single item is like that, the reservation steps effectively have no rows to touch, and the known gap in the step graph lets the workflow return before emitEventStep for order.fulfillment_created ever runs. The fulfillment saved fine. The event that was supposed to announce it just never went out.
Why it happens
The event is real, and the subscriber code is fine. The gap is that the workflow's own step graph can complete without ever reaching the emit step when the fulfilled items give the earlier steps nothing to do. A few common ways stores hit this:
- A digital-goods or services store where every variant has
manage_inventory: false, so no fulfillment on that store ever has a tracked item to trigger the reservation steps normally. - A mixed catalog where one order happens to be entirely gift cards, warranties, or another untracked product type, even though most of the catalog is tracked inventory.
- A partial fulfillment that, after splitting, ends up containing only the untracked line items from an otherwise mixed order.
- Any custom workflow that calls into the same fulfillment creation path with a set of items that are all untracked.
Nothing throws, nothing logs a failure, and no job shows as failed. The fulfillment record exists and looks complete in the admin. The only visible symptom is a side effect that quietly never happened, like a shipment-notification email nobody sent. See the citations at the end for the tracked issue and the workflow reference.
This is a silent-skip bug, not a crash, so there is nothing to catch with error monitoring. The fulfillment succeeded. Only a side effect that depended on an event being emitted is missing, and that side effect leaves no trace of its own absence. The only way to find it is to compare what should exist, a notification tied to that fulfillment_id, against what actually exists, and treat every gap as a miss. That is detection work, not error handling.
The fix, as a flow
We do not replay createOrderFulfillmentWorkflow. Re-running it would create a second fulfillment record for the same shipment, which is worse than a missing email. Instead the script lists recent orders with their fulfillments and item inventory flags, decides with one pure function whether a fulfillment's event was likely missed, cross-checks the notification log to confirm, and only then re-emits the event directly so the existing subscriber runs exactly as it would have.
Build it step by step
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.
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 backfill
// 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 backfill
List recent orders with fulfillments and inventory flags
Ask for orders with their fulfillments, items, and each item's variant manage_inventory flag, plus each fulfillment's own items. Page through with offset and limit against the {orders, count, offset, limit} envelope, newest first.
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_orders_with_fulfillments(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,*fulfillments,*items,items.variant.manage_inventory,*fulfillments.items",
"order": "-created_at",
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL;
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 listOrdersWithFulfillments(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,*fulfillments,*items,items.variant.manage_inventory,*fulfillments.items",
order: "-created_at",
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
Pull the notification log to check what actually sent
The notification module records every notification it sent, including a payload that carries the fulfillment_id it was about. Build a set of every fulfillment_id that already has a notification, so the decision function can tell a real miss from one that already went out.
def notified_fulfillment_ids(token):
ids = set()
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/notifications", {
"fields": "id,to,template,data",
"order": "-created_at",
"limit": limit,
"offset": offset,
})
for n in data["notifications"]:
fid = (n.get("data") or {}).get("fulfillment_id")
if fid:
ids.add(fid)
offset += limit
if offset >= data["count"]:
return ids
async function notifiedFulfillmentIds(token) {
const ids = new Set();
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/notifications", {
fields: "id,to,template,data",
order: "-created_at",
limit,
offset,
});
for (const n of data.notifications) {
const fid = n.data?.fulfillment_id;
if (fid) ids.add(fid);
}
offset += limit;
if (offset >= data.count) return ids;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a fulfillment, a map from line_item_id to its manage_inventory flag, and the set of already-notified fulfillment ids, and returns true or false. It is conservative on purpose: a missing lookup defaults to untracked, and a fulfillment only counts as likely missed when every one of its items is untracked and no notification exists yet for it. A mixed fulfillment, some tracked and some not, is left alone, since its reservation steps still ran and the event still fired normally.
def is_fulfillment_event_likely_missed(fulfillment, order_items_by_line_item_id, notified_fulfillment_ids):
if fulfillment["id"] in notified_fulfillment_ids:
return False
flags = [
order_items_by_line_item_id.get(item["line_item_id"], {}).get("manage_inventory", False)
for item in fulfillment["items"]
]
return len(flags) > 0 and all(flag is False for flag in flags)
export function isFulfillmentEventLikelyMissed(fulfillment, orderItemsByLineItemId, notifiedFulfillmentIds) {
if (notifiedFulfillmentIds.has(fulfillment.id)) return false;
const flags = fulfillment.items.map(
(item) => orderItemsByLineItemId[item.line_item_id]?.manage_inventory ?? false
);
return flags.length > 0 && flags.every((flag) => flag === false);
}
Re-emit the missed event, never replay the workflow
When a fulfillment is confirmed missed, do not call POST /admin/orders/{id}/fulfillments again, that would create a second fulfillment for the same shipment. Instead resolve Modules.EVENT_BUS inside a Medusa exec script and call emit directly with the same fulfillment_id and order_id the original event would have carried, so the existing subscriber runs exactly as it would have the first time.
# This step runs as a Medusa exec script (Node), not from this Python job.
# The Python and Node scripts in this guide only detect and report the
# fulfillment_id/order_id pairs; re-emitting through the event bus has to
# happen inside the Medusa process so it can resolve Modules.EVENT_BUS.
#
# Record the backfill so it never repeats:
def mark_backfilled(token, order_id, fulfillment_id):
return admin_post(
token,
f"/admin/orders/{order_id}/fulfillments/{fulfillment_id}",
{"metadata": {"fulfillment_created_event_backfilled": True}},
)
// Run with: npx medusa exec ./src/scripts/backfill-exec.js
import { Modules } from "@medusajs/framework/utils";
export default async function backfillMissedFulfillmentEvent({ container }, fulfillmentId, orderId) {
const eventBusModuleService = container.resolve(Modules.EVENT_BUS);
await eventBusModuleService.emit({
name: "order.fulfillment_created",
data: { fulfillment_id: fulfillmentId, order_id: orderId },
});
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports which order_id/fulfillment_id pairs it found. Read the report, confirm it against the admin, then switch DRY_RUN off to let it call the event bus and record the metadata flag. Run it on a schedule, since a new fulfillment made entirely of untracked items can hit this gap at any time.
Always start with DRY_RUN=true, and never call POST /admin/orders/{id}/fulfillments again for a fulfillment that already exists. The fix is to re-emit the event that was skipped, not to recreate the fulfillment it belongs to.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs a full report before writing anything, respects the dry run flag, and marks each backfilled fulfillment with a metadata flag so the same one is never re-emitted twice.
"""Find Medusa v2 fulfillments whose order.fulfillment_created event never fired.
createOrderFulfillmentWorkflow runs emitEventStep for order.fulfillment_created
near the end of its step graph, after the inventory reservation steps that only
touch line items whose variant has manage_inventory: true (tracked in
medusajs/medusa#10721). When every item on a fulfillment is untracked
inventory, those reservation steps have nothing to operate on, and the
workflow can finish before it reaches emitEventStep. The fulfillment record
is still created correctly, only the event, and anything that depended on
it like a shipment-notification email, is skipped silently.
This is a flag/report job, not an auto-fix: it never calls
POST /admin/orders/{id}/fulfillments again, since that would create a
duplicate fulfillment. By default it only reports the order_id/fulfillment_id
pairs it finds. With DRY_RUN=false it re-emits order.fulfillment_created
through your own event bus (call this from a Medusa exec script, see the
guide) and then flags the fulfillment as backfilled via a metadata patch so
the same one is never re-emitted twice.
Run on a schedule. Safe to run again and again.
Guide: https://www.allanninal.dev/medusa/fulfillment-event-skipped-untracked-items/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_missed_fulfillment_events")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
BACKFILL_FLAG = "fulfillment_created_event_backfilled"
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_post(token, path, body):
r = requests.post(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
def is_fulfillment_event_likely_missed(fulfillment, order_items_by_line_item_id, notified_fulfillment_ids):
"""Pure decision function. No I/O.
fulfillment: {"id": str, "items": [{"line_item_id": str}]}
order_items_by_line_item_id: {line_item_id: {"manage_inventory": bool}}
notified_fulfillment_ids: set of fulfillment_id already covered by a notification.
Returns True only when the fulfillment has no matching notification and
every one of its items resolves to manage_inventory False, treating a
missing lookup as untracked (conservative). A mixed fulfillment, or one
that already has a notification, returns False.
"""
if fulfillment["id"] in notified_fulfillment_ids:
return False
flags = [
order_items_by_line_item_id.get(item["line_item_id"], {}).get("manage_inventory", False)
for item in fulfillment["items"]
]
return len(flags) > 0 and all(flag is False for flag in flags)
def list_orders_with_fulfillments(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,*fulfillments,*items,items.variant.manage_inventory,*fulfillments.items",
"order": "-created_at",
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
def notified_fulfillment_ids(token):
ids = set()
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/notifications", {
"fields": "id,to,template,data",
"order": "-created_at",
"limit": limit,
"offset": offset,
})
for n in data["notifications"]:
fid = (n.get("data") or {}).get("fulfillment_id")
if fid:
ids.add(fid)
offset += limit
if offset >= data["count"]:
return ids
def order_items_by_line_item_id(order):
result = {}
for item in order.get("items") or []:
variant = item.get("variant") or {}
result[item["id"]] = {"manage_inventory": bool(variant.get("manage_inventory", False))}
return result
def mark_backfilled(token, order_id, fulfillment_id):
return admin_post(
token,
f"/admin/orders/{order_id}/fulfillments/{fulfillment_id}",
{"metadata": {BACKFILL_FLAG: True}},
)
def reemit_fulfillment_created(order_id, fulfillment_id):
# Re-emitting through the event bus has to happen inside the Medusa
# process, where Modules.EVENT_BUS can be resolved. Call your Medusa
# exec script here, for example:
# npx medusa exec ./src/scripts/backfill-exec.js
log.info("Re-emit order.fulfillment_created for order=%s fulfillment=%s (run the Medusa exec script)", order_id, fulfillment_id)
def run():
token = get_admin_token()
orders = list_orders_with_fulfillments(token)
notified = notified_fulfillment_ids(token)
missed = 0
for order in orders:
items_by_id = order_items_by_line_item_id(order)
for fulfillment in order.get("fulfillments") or []:
if fulfillment.get("metadata", {}).get(BACKFILL_FLAG):
continue
if not is_fulfillment_event_likely_missed(fulfillment, items_by_id, notified):
continue
log.warning(
"Order %s fulfillment %s likely missed order.fulfillment_created. %s",
order["id"], fulfillment["id"],
"would backfill" if DRY_RUN else "backfilling",
)
if not DRY_RUN:
reemit_fulfillment_created(order["id"], fulfillment["id"])
mark_backfilled(token, order["id"], fulfillment["id"])
missed += 1
log.info("Done. %d fulfillment(s) %s.", missed, "to backfill" if DRY_RUN else "backfilled")
if __name__ == "__main__":
run()
/**
* Find Medusa v2 fulfillments whose order.fulfillment_created event never fired.
*
* createOrderFulfillmentWorkflow runs emitEventStep for order.fulfillment_created
* near the end of its step graph, after the inventory reservation steps that only
* touch line items whose variant has manage_inventory: true (tracked in
* medusajs/medusa#10721). When every item on a fulfillment is untracked
* inventory, those reservation steps have nothing to operate on, and the
* workflow can finish before it reaches emitEventStep. The fulfillment record
* is still created correctly, only the event, and anything that depended on
* it like a shipment-notification email, is skipped silently.
*
* This is a flag/report job, not an auto-fix: it never calls
* POST /admin/orders/{id}/fulfillments again, since that would create a
* duplicate fulfillment. By default it only reports the order_id/fulfillment_id
* pairs it finds. With DRY_RUN=false it re-emits order.fulfillment_created
* through your own event bus (call this from a Medusa exec script, see the
* guide) and then flags the fulfillment as backfilled via a metadata patch so
* the same one is never re-emitted twice.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/fulfillment-event-skipped-untracked-items/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const BACKFILL_FLAG = "fulfillment_created_event_backfilled";
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, items: { line_item_id: string }[] }} fulfillment
* @param {Record} orderItemsByLineItemId
* @param {Set} notifiedFulfillmentIds
* @returns {boolean}
*
* Returns true only when the fulfillment has no matching notification and
* every one of its items resolves to manage_inventory false, treating a
* missing lookup as untracked (conservative). A mixed fulfillment, or one
* that already has a notification, returns false.
*/
export function isFulfillmentEventLikelyMissed(fulfillment, orderItemsByLineItemId, notifiedFulfillmentIds) {
if (notifiedFulfillmentIds.has(fulfillment.id)) return false;
const flags = fulfillment.items.map(
(item) => orderItemsByLineItemId[item.line_item_id]?.manage_inventory ?? false
);
return flags.length > 0 && flags.every((flag) => flag === false);
}
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 adminPost(token, path, body) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Medusa ${res.status} on POST ${path}`);
return res.json();
}
async function listOrdersWithFulfillments(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,*fulfillments,*items,items.variant.manage_inventory,*fulfillments.items",
order: "-created_at",
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
async function notifiedFulfillmentIds(token) {
const ids = new Set();
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/notifications", {
fields: "id,to,template,data",
order: "-created_at",
limit,
offset,
});
for (const n of data.notifications) {
const fid = n.data?.fulfillment_id;
if (fid) ids.add(fid);
}
offset += limit;
if (offset >= data.count) return ids;
}
}
function orderItemsByLineItemId(order) {
const result = {};
for (const item of order.items || []) {
result[item.id] = { manage_inventory: Boolean(item.variant?.manage_inventory) };
}
return result;
}
async function markBackfilled(token, orderId, fulfillmentId) {
return adminPost(token, `/admin/orders/${orderId}/fulfillments/${fulfillmentId}`, {
metadata: { [BACKFILL_FLAG]: true },
});
}
function reemitFulfillmentCreated(orderId, fulfillmentId) {
// Re-emitting through the event bus has to happen inside the Medusa
// process, where Modules.EVENT_BUS can be resolved. Call your Medusa
// exec script here, for example:
// npx medusa exec ./src/scripts/backfill-exec.js
console.log(`Re-emit order.fulfillment_created for order=${orderId} fulfillment=${fulfillmentId} (run the Medusa exec script)`);
}
export async function run() {
const token = await getAdminToken();
const orders = await listOrdersWithFulfillments(token);
const notified = await notifiedFulfillmentIds(token);
let missed = 0;
for (const order of orders) {
const itemsById = orderItemsByLineItemId(order);
for (const fulfillment of order.fulfillments || []) {
if (fulfillment.metadata?.[BACKFILL_FLAG]) continue;
if (!isFulfillmentEventLikelyMissed(fulfillment, itemsById, notified)) continue;
console.warn(
`Order ${order.id} fulfillment ${fulfillment.id} likely missed order.fulfillment_created. ${DRY_RUN ? "would backfill" : "backfilling"}`
);
if (!DRY_RUN) {
reemitFulfillmentCreated(order.id, fulfillment.id);
await markBackfilled(token, order.id, fulfillment.id);
}
missed++;
}
}
console.log(`Done. ${missed} fulfillment(s) ${DRY_RUN ? "to backfill" : "backfilled"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
is_fulfillment_event_likely_missed is the part most worth testing, because it decides which fulfillments the report calls a miss. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain fixture objects and checks the answer.
from find_missed_fulfillment_events import is_fulfillment_event_likely_missed
def fulfillment(**over):
base = {"id": "ful_1", "items": [{"line_item_id": "item_1"}]}
base.update(over)
return base
def test_missed_when_all_items_untracked_and_not_notified():
items = {"item_1": {"manage_inventory": False}}
assert is_fulfillment_event_likely_missed(fulfillment(), items, set()) is True
def test_not_missed_when_already_notified():
items = {"item_1": {"manage_inventory": False}}
assert is_fulfillment_event_likely_missed(fulfillment(), items, {"ful_1"}) is False
def test_not_missed_when_item_is_tracked():
items = {"item_1": {"manage_inventory": True}}
assert is_fulfillment_event_likely_missed(fulfillment(), items, set()) is False
def test_not_missed_when_mixed_tracked_and_untracked():
f = fulfillment(items=[{"line_item_id": "item_1"}, {"line_item_id": "item_2"}])
items = {"item_1": {"manage_inventory": False}, "item_2": {"manage_inventory": True}}
assert is_fulfillment_event_likely_missed(f, items, set()) is False
def test_missing_lookup_defaults_to_untracked():
assert is_fulfillment_event_likely_missed(fulfillment(), {}, set()) is True
def test_no_items_is_not_missed():
assert is_fulfillment_event_likely_missed(fulfillment(items=[]), {}, set()) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { isFulfillmentEventLikelyMissed } from "./find-missed-fulfillment-events.js";
const fulfillment = (over = {}) => ({
id: "ful_1",
items: [{ line_item_id: "item_1" }],
...over,
});
test("missed when all items untracked and not notified", () => {
const items = { item_1: { manage_inventory: false } };
assert.equal(isFulfillmentEventLikelyMissed(fulfillment(), items, new Set()), true);
});
test("not missed when already notified", () => {
const items = { item_1: { manage_inventory: false } };
assert.equal(isFulfillmentEventLikelyMissed(fulfillment(), items, new Set(["ful_1"])), false);
});
test("not missed when item is tracked", () => {
const items = { item_1: { manage_inventory: true } };
assert.equal(isFulfillmentEventLikelyMissed(fulfillment(), items, new Set()), false);
});
test("not missed when mixed tracked and untracked", () => {
const f = fulfillment({ items: [{ line_item_id: "item_1" }, { line_item_id: "item_2" }] });
const items = { item_1: { manage_inventory: false }, item_2: { manage_inventory: true } };
assert.equal(isFulfillmentEventLikelyMissed(f, items, new Set()), false);
});
test("missing lookup defaults to untracked", () => {
assert.equal(isFulfillmentEventLikelyMissed(fulfillment(), {}, new Set()), true);
});
test("no items is not missed", () => {
assert.equal(isFulfillmentEventLikelyMissed(fulfillment({ items: [] }), {}, new Set()), false);
});
Case studies
The course platform whose shipment emails never existed
A store selling online course access and digital certificates had every variant set to manage_inventory: false, since there was nothing physical to track. Support kept getting messages asking where the confirmation email was, and staff assumed customers were missing it in spam, since the admin clearly showed each order as fulfilled.
Running the script in dry run listed every one of those fulfillments as a likely miss, since every item on every order was untracked and no notification ever referenced the fulfillment_id. Backfilling through the event bus fired the same subscriber that should have run originally, and the metadata flag kept the job from repeating any of them on the next pass.
The gift-card order that fell through a mostly-tracked catalog
A general merchandise store tracked inventory on almost everything, so this bug had never come up. Then a customer ordered only gift cards in one transaction, which are untracked by design, and that one fulfillment quietly never sent its shipping confirmation while every other order kept working fine.
Because the detection is per-fulfillment, not per-store, the script still caught it on a routine scheduled run, listed as a single flagged pair among hundreds of orders that were correctly left alone. The team ran the backfill and moved on without having to special-case gift cards anywhere else in the store.
After this runs on a schedule, a fulfillment made entirely of untracked items no longer means a silently missing shipment email. The report shows the exact order_id/fulfillment_id pairs affected, the backfill re-emits order.fulfillment_created through your own event bus so the existing subscriber runs, and nothing about the fulfillment record itself is ever duplicated or rewritten. The metadata flag keeps every backfill a one-time event.
FAQ
Why does order.fulfillment_created never fire for some Medusa fulfillments?
createOrderFulfillmentWorkflow runs emitEventStep for order.fulfillment_created near the end of its step graph, after the inventory reservation steps. Those reservation steps only act on line items whose variant has manage_inventory set to true. When every item on a fulfillment is untracked inventory, the reservation steps have nothing to operate on, and the known gap tracked as medusajs/medusa#10721 lets the workflow finish without reaching emitEventStep. The fulfillment itself is created correctly, only the event is skipped.
How do I find fulfillments that never emitted order.fulfillment_created?
Pull recent orders with GET /admin/orders?fields=id,*fulfillments,*items,items.variant.manage_inventory,*fulfillments.items and, for each fulfillment, check whether every one of its line items resolves to manage_inventory false. Then cross-check GET /admin/notifications filtered by data.fulfillment_id. A fulfillment where every item is untracked and no notification record references its fulfillment_id is a confirmed miss.
Is it safe to fix a missed fulfillment_created event with a script?
Yes, as long as the script never replays createOrderFulfillmentWorkflow, since that would create a duplicate fulfillment. The safe repair is to resolve the event bus module directly and re-emit order.fulfillment_created with the existing fulfillment_id and order_id, which lets the existing notification subscriber fire exactly as it would have, then record that fulfillment as backfilled so the same one is never re-emitted on the next run.
Related field notes
Citations
On the problem:
- [Bug]: order.fulfillment_created Event Not Emitted for Non-Inventory-Managed Items. Medusa GitHub Issue #10721. github.com/medusajs/medusa/issues/10721
- Medusa Documentation: Events and Subscribers. docs.medusajs.com/learn/fundamentals/events-and-subscribers
- Medusa Documentation: Inventory Module in Medusa Flows. docs.medusajs.com/resources/commerce-modules/inventory/inventory-in-flows
On the solution:
- Medusa Core Workflows Reference: createOrderFulfillmentWorkflow. docs.medusajs.com/resources/references/medusa-workflows/createOrderFulfillmentWorkflow
- Medusa Documentation: Emit Workflow and Service Events. docs.medusajs.com/learn/fundamentals/events-and-subscribers/emit-event
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
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.
Did this catch a missing shipment email?
If this saved you from a quietly missing notification you could not explain, 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