Skip to content

Reconciler Inventory & Reservations

Reservation update event never fires

You change a reservation's quantity, move it to a different line item, or shift it to another location, and your subscriber never wakes up. No error, no log line, nothing. Here is why Medusa v2 silently fired the wrong event on every reservation update and a small reconciler that finds every reservation that drifted out of sync because that event never arrived.

Python and Node.js Medusa Admin API Safe by default (dry run)
A warehouse of boxes and pallets
Photo by Arum Visuals on Unsplash
The short answer

GitHub issue #11704 confirms that Medusa v2's InventoryModuleService.updateReservationItem emitted the wrong event constant. Whenever a reservation's quantity, line item, or location changed, whether from the admin UI, the Admin API, or an internal workflow like order fulfillment or cancellation, it fired inventory-item.updated (InventoryEvents.INVENTORY_ITEM_UPDATED) instead of reservation-item.updated (RESERVATION_ITEM_UPDATED). PR #11714 fixes the constant, but any store still on an affected version, or any store that needs to check whether it already drifted, needs to reconcile what the missing events should have delivered. Run a script that pages through every reservation per stock location, compares the live quantity against your stock-sync script's last-synced snapshot, and cross-checks each inventory item's reserved_quantity against the sum of its live reservations, then reports the mismatches. Full code, tests, and a dry run guard are below.

The problem in plain words

Medusa's event system works on an honor system between the emitter and the subscriber. A subscriber registers for one exact event name, for example reservation-item.updated, and Medusa's workflow engine emits an event under some name whenever a service method runs. If those two names do not match character for character, the subscriber simply never fires. No exception is thrown anywhere, because nothing is wrong from the emitter's point of view. It emitted a valid event, just the wrong one.

That is exactly what happened here. InventoryModuleService.updateReservationItem is the method that runs whenever a reservation's quantity, line_item_id, or location_id changes. Instead of emitting RESERVATION_ITEM_UPDATED, it emitted INVENTORY_ITEM_UPDATED, the constant meant for changes to the inventory item itself. A subscriber written against RESERVATION_ITEM_UPDATED, which is the only event name that makes semantic sense to listen for here, gets nothing. Any stock-sync integration, cache invalidation, or downstream notification built purely on that subscriber quietly stops keeping up with reservation changes, and nobody notices until the numbers stop matching.

Reservation quantity changed by admin or API updateReservationItem runs and emits an event wrong constant emitted inventory-item.updated not reservation-item.updated Subscriber never fires Stock sync drifts
The reservation really changed, and Medusa really emitted an event. It was just the wrong event, so a subscriber listening for RESERVATION_ITEM_UPDATED never hears about it.

Why it happens

The bug lives in the event constant chosen inside one service method, and it triggers on any path that ends up calling it. A few common ways stores hit this:

None of this throws an error anywhere. The reservation update itself succeeds and the database is correct. Only the event that was supposed to tell everyone else about it goes to the wrong listener. See the citations at the end for the exact issue and fix.

The key insight

You cannot fix a missed event by listening harder. If RESERVATION_ITEM_UPDATED was never emitted, no subscriber configuration change makes it appear after the fact. Detection has to be a reconciliation pass, not an event listener: compare what the live reservations actually say right now against what your last-synced snapshot believes, and treat any mismatch as a symptom of the gap. That is also why the safe repair is to update your own stock-sync baseline and forward the corrected delta, not to poke the Medusa reservation itself, since a no-op re-save will not retrigger the buggy emit path anyway.

The fix, as a flow

We do not touch live reservations by default. The job pulls every reservation per stock location, runs a pure diff against the last-synced quantities your own stock-sync script keeps, and reports every reservation whose live quantity has moved. Only with an explicit apply flag does it also cross-check inventory-item reserved_quantity for a deeper drift signal and let the caller update its own baseline.

List reservations per stock location Load last-synced quantity snapshot Diff live vs synced pure function, per res_id Quantity diverged? yes, --apply no, in sync Left alone nothing to fix Update baseline forward delta downstream
The reconciler only ever reports drift by default. With DRY_RUN off, it updates its own last-synced baseline and forwards the corrected delta, it never mutates a Medusa reservation it does not own.

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 DRY_RUN="true"   # start safe, change to false to apply
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 apply
2

List every reservation for a stock location

Ask for reservations at one sloc_... location at a time, and read back the fields the diff needs: the id, quantity, line item id, inventory item id, location id, and when it was last updated. Page through with offset and limit against the {reservations, count, offset, limit} envelope.

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_for_location(token, location_id):
    reservations = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/reservations", {
            "location_id": location_id,
            "fields": "id,quantity,line_item_id,inventory_item_id,location_id,updated_at,*inventory_item",
            "limit": limit,
            "offset": offset,
        })
        reservations.extend(data["reservations"])
        offset += limit
        if offset >= data["count"]:
            return reservations
step2.js
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 listReservationsForLocation(token, locationId) {
  const reservations = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const data = await adminGet(token, "/admin/reservations", {
      location_id: locationId,
      fields: "id,quantity,line_item_id,inventory_item_id,location_id,updated_at,*inventory_item",
      limit,
      offset,
    });
    reservations.push(...data.reservations);
    offset += limit;
    if (offset >= data.count) return reservations;
  }
}
3

Decide, with one pure function

Keep the diff in its own function that takes the live reservations and the last-synced quantity map your stock-sync script persists, keyed by res_... id, and returns only the ones that diverged. This is the exact recomputation a working RESERVATION_ITEM_UPDATED subscriber would have done incrementally, one event at a time. Since those events never arrived, the reconciler has to run the same comparison as a full diff, every time.

decide.py
def diff_reservation_sync(live, last_synced):
    """live: [{"id", "quantity", "location_id", "updated_at"}]
    last_synced: {res_id: {"quantity", "updated_at"}}
    Returns [{"id", "drift", "stale_since"}] for every reservation whose
    live quantity differs from (or is missing from) the last-synced map.
    """
    drifted = []
    for r in live:
        prev = last_synced.get(r["id"])
        if prev is not None and prev["quantity"] == r["quantity"]:
            continue
        prev_quantity = prev["quantity"] if prev else 0
        stale_since = prev["updated_at"] if prev else r["updated_at"]
        drifted.append({
            "id": r["id"],
            "drift": r["quantity"] - prev_quantity,
            "stale_since": stale_since,
        })
    return drifted
decide.js
export function diffReservationSync(live, lastSynced) {
  return live
    .filter((r) => {
      const prev = lastSynced[r.id];
      return !prev || prev.quantity !== r.quantity;
    })
    .map((r) => ({
      id: r.id,
      drift: r.quantity - (lastSynced[r.id]?.quantity ?? 0),
      staleSince: lastSynced[r.id]?.updated_at ?? r.updated_at,
    }));
}
4

Cross-check reserved_quantity at each location level

A drifted reservation is one signal. A second, independent signal is when an inventory item's reserved_quantity at a location no longer equals the sum of its live reservation quantities there. An external system built purely on the missing subscriber may have drifted from Medusa's authoritative reserved_quantity even for a reservation that has not changed again since. Pull the location levels and compare.

step4.py
def location_level_mismatches(reservations_by_location, location_levels):
    """location_levels: [{"location_id", "reserved_quantity", "inventory_item_id"}]
    Flags a location level whose reserved_quantity does not equal the sum
    of live reservation quantities Medusa reports for that location.
    """
    mismatches = []
    for level in location_levels:
        live_sum = sum(
            r["quantity"] for r in reservations_by_location.get(level["location_id"], [])
            if r["inventory_item_id"] == level["inventory_item_id"]
        )
        if live_sum != level["reserved_quantity"]:
            mismatches.append({
                "location_id": level["location_id"],
                "inventory_item_id": level["inventory_item_id"],
                "reserved_quantity": level["reserved_quantity"],
                "live_sum": live_sum,
            })
    return mismatches

def fetch_location_levels(token, inventory_item_id):
    data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels")
    return data["inventory_item"]["location_levels"]
step4.js
export function locationLevelMismatches(reservationsByLocation, locationLevels) {
  const mismatches = [];
  for (const level of locationLevels) {
    const liveReservations = reservationsByLocation[level.location_id] || [];
    const liveSum = liveReservations
      .filter((r) => r.inventory_item_id === level.inventory_item_id)
      .reduce((sum, r) => sum + r.quantity, 0);
    if (liveSum !== level.reserved_quantity) {
      mismatches.push({
        location_id: level.location_id,
        inventory_item_id: level.inventory_item_id,
        reserved_quantity: level.reserved_quantity,
        live_sum: liveSum,
      });
    }
  }
  return mismatches;
}

async function fetchLocationLevels(token, inventoryItemId) {
  const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`);
  return data.inventory_item.location_levels;
}
5

Update your own baseline, do not silently rewrite Medusa

When run with --apply, the script does not call POST /admin/reservations/{id} to re-save the same quantity, since that will not retrigger the buggy emit path anyway and is not a fix for anything. Instead it takes the confirmed live quantity as the new last-synced baseline for that res_... id and forwards the corrected reserved delta to whatever external system your stock-sync consumer feeds. It never mutates a Medusa reservation it does not own.

step5.py
def apply_baseline_update(sync_store, drifted_entry, live_quantity, live_updated_at, forward_delta):
    """sync_store is your own last-synced persistence, not Medusa.
    forward_delta(res_id, drift) pushes the corrected change to whatever
    external system your stock-sync consumer feeds.
    """
    sync_store[drifted_entry["id"]] = {"quantity": live_quantity, "updated_at": live_updated_at}
    forward_delta(drifted_entry["id"], drifted_entry["drift"])
step5.js
function applyBaselineUpdate(syncStore, drifted, liveQuantity, liveUpdatedAt, forwardDelta) {
  syncStore[drifted.id] = { quantity: liveQuantity, updated_at: liveUpdatedAt };
  forwardDelta(drifted.id, drifted.drift);
}
6

Wire it together with a dry run guard and an explicit apply flag

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only prints a report of every drifted reservation and every location level mismatch it found. Read the report, agree with it, then rerun with DRY_RUN=false and --apply to let it update your own sync baseline. Run it as a scheduled reconciler, for example hourly, since without a working event you cannot know when the next change lands.

Run it safe

Always start with DRY_RUN=true, and never let the script write to Medusa reservations it does not own. Re-emitting the missed transition means correcting your own stock-sync baseline and forwarding the delta downstream, not calling POST /admin/reservations/{id} as a no-op re-save.

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 requires an explicit --apply flag before it updates its own last-synced baseline for any drifted reservation.

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.
reconcile_reservation_sync.py
"""Reconcile Medusa reservations whose update event never fired.

Medusa v2's InventoryModuleService.updateReservationItem emitted the wrong
event constant (confirmed in medusajs/medusa#11704, fixed in PR #11714): it
fired inventory-item.updated instead of reservation-item.updated whenever a
reservation's quantity, line item, or location changed, whether from the
admin UI, the Admin API, or an internal workflow like order fulfillment or
cancellation. A subscriber registered for RESERVATION_ITEM_UPDATED never
receives that change, so a stock-sync integration built on it silently
drifts. This lists reservations per stock location, diffs their live
quantity against a last-synced snapshot, and cross-checks reserved_quantity
at each location level against the sum of live reservations there.
By default it only reports drift. Pass --apply to also update the sync
baseline and forward the corrected delta downstream.
Run as a scheduled reconciler. Safe to run again and again.

Guide: https://www.allanninal.dev/medusa/reservation-updated-event-not-firing/
"""
import os
import sys
import json
import logging
import requests

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

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"
SYNC_STATE_PATH = os.environ.get("SYNC_STATE_PATH", "reservation_sync_state.json")


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 diff_reservation_sync(live, last_synced):
    """Pure decision function. No I/O.

    live: [{"id": str, "quantity": int, "location_id": str, "updated_at": str}]
    last_synced: {res_id: {"quantity": int, "updated_at": str}}

    Returns [{"id", "drift", "stale_since"}] for every reservation whose
    live quantity differs from (or is missing from) the last-synced map.
    """
    drifted = []
    for r in live:
        prev = last_synced.get(r["id"])
        if prev is not None and prev["quantity"] == r["quantity"]:
            continue
        prev_quantity = prev["quantity"] if prev else 0
        stale_since = prev["updated_at"] if prev else r["updated_at"]
        drifted.append({
            "id": r["id"],
            "drift": r["quantity"] - prev_quantity,
            "stale_since": stale_since,
        })
    return drifted


def location_level_mismatches(reservations_by_location, location_levels):
    """Pure decision function. No I/O.

    Flags a location level whose reserved_quantity does not equal the sum
    of live reservation quantities for that location and inventory item.
    """
    mismatches = []
    for level in location_levels:
        live_sum = sum(
            r["quantity"] for r in reservations_by_location.get(level["location_id"], [])
            if r["inventory_item_id"] == level["inventory_item_id"]
        )
        if live_sum != level["reserved_quantity"]:
            mismatches.append({
                "location_id": level["location_id"],
                "inventory_item_id": level["inventory_item_id"],
                "reserved_quantity": level["reserved_quantity"],
                "live_sum": live_sum,
            })
    return mismatches


def list_stock_locations(token):
    data = admin_get(token, "/admin/stock-locations", {"limit": 200})
    return data["stock_locations"]


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


def fetch_location_levels(token, inventory_item_id):
    data = admin_get(token, f"/admin/inventory-items/{inventory_item_id}/location-levels")
    return data["inventory_item"]["location_levels"]


def load_sync_state(path):
    if not os.path.exists(path):
        return {}
    with open(path) as f:
        return json.load(f)


def save_sync_state(path, state):
    with open(path, "w") as f:
        json.dump(state, f, indent=2, sort_keys=True)


def forward_delta(res_id, drift):
    log.info("Forwarding corrected delta for %s: %+d to downstream stock system", res_id, drift)


def run():
    apply = "--apply" in sys.argv
    token = get_admin_token()
    sync_state = load_sync_state(SYNC_STATE_PATH)

    locations = list_stock_locations(token)
    all_reservations = []
    reservations_by_location = {}
    for location in locations:
        loc_id = location["id"]
        res_list = list_reservations_for_location(token, loc_id)
        reservations_by_location[loc_id] = res_list
        all_reservations.extend(res_list)

    drifted = diff_reservation_sync(all_reservations, sync_state)

    for entry in drifted:
        log.warning(
            "Reservation %s drifted (%+d) stale since %s. %s",
            entry["id"], entry["drift"], entry["stale_since"],
            "Would update baseline" if DRY_RUN or not apply else "Updating baseline",
        )
        if not DRY_RUN and apply:
            live = next(r for r in all_reservations if r["id"] == entry["id"])
            sync_state[entry["id"]] = {"quantity": live["quantity"], "updated_at": live["updated_at"]}
            forward_delta(entry["id"], entry["drift"])

    inventory_item_ids = {r["inventory_item_id"] for r in all_reservations}
    all_mismatches = []
    for iitem_id in inventory_item_ids:
        levels = fetch_location_levels(token, iitem_id)
        mismatches = location_level_mismatches(reservations_by_location, levels)
        all_mismatches.extend(mismatches)

    for mismatch in all_mismatches:
        log.warning(
            "Location level mismatch at %s for %s: reserved_quantity=%s live_sum=%s",
            mismatch["location_id"], mismatch["inventory_item_id"],
            mismatch["reserved_quantity"], mismatch["live_sum"],
        )

    if not DRY_RUN and apply:
        save_sync_state(SYNC_STATE_PATH, sync_state)

    log.info(
        "Done. %d drifted reservation(s), %d location level mismatch(es). %s",
        len(drifted), len(all_mismatches),
        "Baseline updated" if (not DRY_RUN and apply) else "Report only",
    )


if __name__ == "__main__":
    run()
reconcile-reservation-sync.js
/**
 * Reconcile Medusa reservations whose update event never fired.
 *
 * Medusa v2's InventoryModuleService.updateReservationItem emitted the wrong
 * event constant (confirmed in medusajs/medusa#11704, fixed in PR #11714): it
 * fired inventory-item.updated instead of reservation-item.updated whenever a
 * reservation's quantity, line item, or location changed, whether from the
 * admin UI, the Admin API, or an internal workflow like order fulfillment or
 * cancellation. A subscriber registered for RESERVATION_ITEM_UPDATED never
 * receives that change, so a stock-sync integration built on it silently
 * drifts. This lists reservations per stock location, diffs their live
 * quantity against a last-synced snapshot, and cross-checks reserved_quantity
 * at each location level against the sum of live reservations there.
 * By default it only reports drift. Pass --apply to also update the sync
 * baseline and forward the corrected delta downstream.
 * Run as a scheduled reconciler. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/reservation-updated-event-not-firing/
 */
import { pathToFileURL } from "node:url";
import { readFile, writeFile } from "node:fs/promises";

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 SYNC_STATE_PATH = process.env.SYNC_STATE_PATH || "reservation_sync_state.json";

/**
 * Pure decision function. No I/O.
 *
 * @param {{ id: string, quantity: number, location_id: string, updated_at: string }[]} live
 * @param {Record} lastSynced
 * @returns {{ id: string, drift: number, staleSince: string }[]}
 *
 * Returns one entry for every reservation whose live quantity diverged from
 * (or is missing from) the last-synced map, with the signed drift and how
 * long it has been stale.
 */
export function diffReservationSync(live, lastSynced) {
  return live
    .filter((r) => {
      const prev = lastSynced[r.id];
      return !prev || prev.quantity !== r.quantity;
    })
    .map((r) => ({
      id: r.id,
      drift: r.quantity - (lastSynced[r.id]?.quantity ?? 0),
      staleSince: lastSynced[r.id]?.updated_at ?? r.updated_at,
    }));
}

/**
 * Pure decision function. No I/O.
 *
 * Flags a location level whose reserved_quantity does not equal the sum of
 * live reservation quantities for that location and inventory item.
 */
export function locationLevelMismatches(reservationsByLocation, locationLevels) {
  const mismatches = [];
  for (const level of locationLevels) {
    const liveReservations = reservationsByLocation[level.location_id] || [];
    const liveSum = liveReservations
      .filter((r) => r.inventory_item_id === level.inventory_item_id)
      .reduce((sum, r) => sum + r.quantity, 0);
    if (liveSum !== level.reserved_quantity) {
      mismatches.push({
        location_id: level.location_id,
        inventory_item_id: level.inventory_item_id,
        reserved_quantity: level.reserved_quantity,
        live_sum: liveSum,
      });
    }
  }
  return mismatches;
}

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 listStockLocations(token) {
  const data = await adminGet(token, "/admin/stock-locations", { limit: 200 });
  return data.stock_locations;
}

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

async function fetchLocationLevels(token, inventoryItemId) {
  const data = await adminGet(token, `/admin/inventory-items/${inventoryItemId}/location-levels`);
  return data.inventory_item.location_levels;
}

async function loadSyncState(path) {
  try {
    const text = await readFile(path, "utf8");
    return JSON.parse(text);
  } catch {
    return {};
  }
}

async function saveSyncState(path, state) {
  await writeFile(path, JSON.stringify(state, null, 2));
}

function forwardDelta(resId, drift) {
  console.log(`Forwarding corrected delta for ${resId}: ${drift >= 0 ? "+" : ""}${drift} to downstream stock system`);
}

export async function run() {
  const apply = process.argv.includes("--apply");
  const token = await getAdminToken();
  const syncState = await loadSyncState(SYNC_STATE_PATH);

  const locations = await listStockLocations(token);
  const allReservations = [];
  const reservationsByLocation = {};
  for (const location of locations) {
    const resList = await listReservationsForLocation(token, location.id);
    reservationsByLocation[location.id] = resList;
    allReservations.push(...resList);
  }

  const drifted = diffReservationSync(allReservations, syncState);

  for (const entry of drifted) {
    console.warn(
      `Reservation ${entry.id} drifted (${entry.drift >= 0 ? "+" : ""}${entry.drift}) stale since ${entry.staleSince}. ${
        DRY_RUN || !apply ? "Would update baseline" : "Updating baseline"
      }`
    );
    if (!DRY_RUN && apply) {
      const live = allReservations.find((r) => r.id === entry.id);
      syncState[entry.id] = { quantity: live.quantity, updated_at: live.updated_at };
      forwardDelta(entry.id, entry.drift);
    }
  }

  const inventoryItemIds = new Set(allReservations.map((r) => r.inventory_item_id));
  const allMismatches = [];
  for (const iitemId of inventoryItemIds) {
    const levels = await fetchLocationLevels(token, iitemId);
    allMismatches.push(...locationLevelMismatches(reservationsByLocation, levels));
  }

  for (const mismatch of allMismatches) {
    console.warn(
      `Location level mismatch at ${mismatch.location_id} for ${mismatch.inventory_item_id}: reserved_quantity=${mismatch.reserved_quantity} live_sum=${mismatch.live_sum}`
    );
  }

  if (!DRY_RUN && apply) {
    await saveSyncState(SYNC_STATE_PATH, syncState);
  }

  console.log(
    `Done. ${drifted.length} drifted reservation(s), ${allMismatches.length} location level mismatch(es). ${
      !DRY_RUN && apply ? "Baseline updated" : "Report only"
    }.`
  );
}

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

Add a test

diffReservationSync is the part most worth testing, because it decides which reservations the report calls drifted. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain fixture arrays and a plain object map and checks the answer.

test_reservation_diff.py
from reconcile_reservation_sync import diff_reservation_sync


def res(**over):
    base = {"id": "res_1", "quantity": 5, "location_id": "sloc_1", "updated_at": "2026-07-10T00:00:00Z"}
    base.update(over)
    return base


def test_flags_reservation_missing_from_last_synced():
    result = diff_reservation_sync([res()], {})
    assert result == [{"id": "res_1", "drift": 5, "stale_since": "2026-07-10T00:00:00Z"}]


def test_flags_reservation_when_quantity_changed():
    last_synced = {"res_1": {"quantity": 3, "updated_at": "2026-07-01T00:00:00Z"}}
    result = diff_reservation_sync([res(quantity=7)], last_synced)
    assert result == [{"id": "res_1", "drift": 4, "stale_since": "2026-07-01T00:00:00Z"}]


def test_no_drift_when_quantity_matches():
    last_synced = {"res_1": {"quantity": 5, "updated_at": "2026-07-01T00:00:00Z"}}
    result = diff_reservation_sync([res()], last_synced)
    assert result == []


def test_negative_drift_when_quantity_decreased():
    last_synced = {"res_1": {"quantity": 9, "updated_at": "2026-07-01T00:00:00Z"}}
    result = diff_reservation_sync([res(quantity=2)], last_synced)
    assert result == [{"id": "res_1", "drift": -7, "stale_since": "2026-07-01T00:00:00Z"}]


def test_multiple_reservations_only_flags_changed_ones():
    last_synced = {
        "res_1": {"quantity": 5, "updated_at": "2026-07-01T00:00:00Z"},
        "res_2": {"quantity": 1, "updated_at": "2026-07-02T00:00:00Z"},
    }
    live = [res(id="res_1", quantity=5), res(id="res_2", quantity=3)]
    result = diff_reservation_sync(live, last_synced)
    assert result == [{"id": "res_2", "drift": 2, "stale_since": "2026-07-02T00:00:00Z"}]


def test_empty_live_list_returns_empty():
    assert diff_reservation_sync([], {"res_1": {"quantity": 5, "updated_at": "2026-07-01T00:00:00Z"}}) == []
reservation-diff.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffReservationSync } from "./reconcile-reservation-sync.js";

const res = (over = {}) => ({
  id: "res_1",
  quantity: 5,
  location_id: "sloc_1",
  updated_at: "2026-07-10T00:00:00Z",
  ...over,
});

test("flags reservation missing from last synced", () => {
  const result = diffReservationSync([res()], {});
  assert.deepEqual(result, [{ id: "res_1", drift: 5, staleSince: "2026-07-10T00:00:00Z" }]);
});

test("flags reservation when quantity changed", () => {
  const lastSynced = { res_1: { quantity: 3, updated_at: "2026-07-01T00:00:00Z" } };
  const result = diffReservationSync([res({ quantity: 7 })], lastSynced);
  assert.deepEqual(result, [{ id: "res_1", drift: 4, staleSince: "2026-07-01T00:00:00Z" }]);
});

test("no drift when quantity matches", () => {
  const lastSynced = { res_1: { quantity: 5, updated_at: "2026-07-01T00:00:00Z" } };
  const result = diffReservationSync([res()], lastSynced);
  assert.deepEqual(result, []);
});

test("negative drift when quantity decreased", () => {
  const lastSynced = { res_1: { quantity: 9, updated_at: "2026-07-01T00:00:00Z" } };
  const result = diffReservationSync([res({ quantity: 2 })], lastSynced);
  assert.deepEqual(result, [{ id: "res_1", drift: -7, staleSince: "2026-07-01T00:00:00Z" }]);
});

test("multiple reservations only flags changed ones", () => {
  const lastSynced = {
    res_1: { quantity: 5, updated_at: "2026-07-01T00:00:00Z" },
    res_2: { quantity: 1, updated_at: "2026-07-02T00:00:00Z" },
  };
  const live = [res({ id: "res_1", quantity: 5 }), res({ id: "res_2", quantity: 3 })];
  const result = diffReservationSync(live, lastSynced);
  assert.deepEqual(result, [{ id: "res_2", drift: 2, staleSince: "2026-07-02T00:00:00Z" }]);
});

test("empty live list returns empty", () => {
  const result = diffReservationSync([], { res_1: { quantity: 5, updated_at: "2026-07-01T00:00:00Z" } });
  assert.deepEqual(result, []);
});

Case studies

Partial fulfillment

The 3PL integration that quietly stopped matching

A homeware brand ran a third-party logistics integration that kept its own count of reserved stock, fed entirely by a subscriber on RESERVATION_ITEM_UPDATED. When staff partially fulfilled orders, Medusa's own workflow shrank the remaining reservation quantity, but because that update fired inventory-item.updated instead, the 3PL's count never moved. Within a few weeks its reserved totals were meaningfully higher than Medusa's.

Running the reconciler in dry run surfaced every reservation whose live quantity no longer matched the 3PL's last-synced snapshot, each one tied to a partial fulfillment that had happened days or weeks earlier. The team reviewed the report, ran it with --apply, and the 3PL's baseline caught up the same day.

Admin edit

The manual quantity edit nobody downstream heard about

A support agent edited a reservation's quantity directly in the Medusa admin to correct a mistaken order change. The edit saved cleanly and looked correct in the admin UI. But the store's stock-sync worker, which existed specifically to catch changes like this, never ran, because the event it was listening for was never the one Medusa emitted.

The location level cross-check in the reconciler caught it independently of the reservation diff, since reserved_quantity no longer summed to the live reservations at that location. That flag led the team straight to the one edited reservation, and a manual baseline correction closed the gap without touching the reservation itself.

What good looks like

After this runs as a scheduled reconciler, a reservation change that Medusa's own event never announced still gets caught within one run. The report shows exactly which res_... ids drifted, by how much, and since when, plus any location level whose reserved_quantity no longer matches its live reservations. Nothing about a live Medusa reservation is ever rewritten. Only your own stock-sync baseline moves, and only with --apply.

FAQ

Why does my subscriber for RESERVATION_ITEM_UPDATED never run in Medusa v2?

Medusa v2's InventoryModuleService.updateReservationItem emitted the wrong event constant. Whenever a reservation's quantity, line item, or location changed, whether through the admin UI, the Admin API, or an internal workflow like order fulfillment or cancellation, it fired inventory-item.updated instead of reservation-item.updated. Since a subscriber registers for the exact event name RESERVATION_ITEM_UPDATED, it never receives a notification for these changes. This is confirmed in medusajs/medusa#11704 and fixed in PR #11714.

How do I detect reservations that drifted because the update event never fired?

Run a reconciliation pass rather than relying on the missing event. For each stock location, page through GET /admin/reservations with fields for quantity, line_item_id, inventory_item_id, location_id, and updated_at, and compare each reservation's live quantity against the last-synced snapshot your stock-sync script persisted. Cross-check GET /admin/inventory-items/{id}/location-levels so a reserved_quantity that drifted from the sum of live reservations at that location is also flagged, since an external system built on the subscriber may never have heard about the change.

Is it safe to auto-correct drifted reservations with a script?

By default the script only reports drifted reservation ids with their old and new quantity and the computed drift, it does not silently rewrite anything. Re-saving a reservation with the same quantity will not retrigger the buggy emit path, so the safe repair is for your own stock-sync consumer to adopt the live quantity as its new last-synced baseline and forward the corrected delta to whatever system it feeds, rather than mutating Medusa reservations the script does not own. Writing anything requires an explicit --apply flag.

Related field notes

Citations

On the problem:

  1. [Bug]: RESERVATION_ITEM_UPDATED event not triggering. Medusa GitHub Issue #11704. github.com/medusajs/medusa/issues/11704
  2. [Bug]: Inventory updates do not emit Events, preventing Cache Revalidation. Medusa GitHub Issue #11691. github.com/medusajs/medusa/issues/11691
  3. fix(inventory): Wrong event emitted on reservation update. Medusa GitHub PR #11714. github.com/medusajs/medusa/pull/11714

On the solution:

  1. Medusa Documentation: Events and Subscribers. docs.medusajs.com/learn/fundamentals/events-and-subscribers
  2. Medusa Documentation: Emit Workflow and Service Events. docs.medusajs.com/learn/fundamentals/events-and-subscribers/emit-event
  3. 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.

Contact me on LinkedIn

Did this catch your drift?

If this saved you from a stock-sync integration that quietly stopped matching Medusa, 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