Diagnostic Orders and fulfillment

Order stuck not fulfilled past SLA

The payment captured days ago. The customer is waiting. But the order's fulfillment_status still reads not_fulfilled, and nobody noticed because nothing in Medusa raised a flag on its own. Here is why placing an order and fulfilling it are two separate things in Medusa v2, how the automation that is supposed to bridge them can quietly fail, and a script that finds every paid order sitting past your SLA so a human can catch it before the customer complains.

Python and Node.js Medusa Admin API Flag and report, never auto-fulfill
The short answer

In Medusa v2, capturing a payment only changes payment_status. Nothing forces a fulfillment to be created, so the order can sit at fulfillment_status not_fulfilled forever if the automation that should trigger a fulfillment, an order.placed subscriber, a scheduled job, or a warehouse integration, silently fails. This gets worse in production without the Redis-backed Event Bus and Workflow Engine modules, since the default in-memory ones do not persist events or job state across restarts or multiple instances. The Admin API also cannot filter orders server-side by fulfillment_status or payment_status, so there is no built-in query for "paid but unfulfilled." Run a script that pages through orders, computes is_paid, is_unfulfilled, and age in hours per order, and flags every one that is paid, unfulfilled, older than your SLA, not canceled, and not already flagged. Full code, tests, and a dry run guard are below.

The problem in plain words

It is easy to assume that once an order is paid, Medusa takes it from there. In Medusa v2 it does not, on purpose. Placing an order and fulfilling an order are two separate concerns handled by two separate parts of the system. Payment capture updates payment_status to captured. That is all it does. Whether a fulfillment gets created, whether a warehouse gets notified, whether a shipping label gets generated, all of that depends on something else reacting to the order being placed.

That something else is usually an order.placed event, picked up by a subscriber, that kicks off a workflow to create the fulfillment, or a scheduled job that polls for orders ready to ship, or an integration that pushes the order to a third-party warehouse system. If that subscriber throws, if the job never runs, or if the integration is down and nobody is watching its error logs, the order just sits there. payment_status says captured. fulfillment_status still says not_fulfilled. Nothing else changes on its own, and nothing in Medusa raises an alarm, because from Medusa's point of view this is a perfectly valid, unremarkable state for an order to be in for as long as it takes someone to notice.

Payment captured payment_status: captured order.placed event should trigger a subscriber subscriber fails or event is lost No fulfillment created no error surfaced fulfillment_status stays not_fulfilled indefinitely, silently Without Redis Event Bus and Workflow Engine, the in-memory defaults do not persist events or job state across restarts or multiple instances
Payment capture only updates payment_status. Whatever should create the fulfillment can fail silently, and fulfillment_status stays not_fulfilled with no alarm raised.

Why it happens

Medusa v2 deliberately keeps "the order was placed and paid" separate from "the order was fulfilled," so a store can plug in whatever fulfillment automation fits its warehouse. A few common ways that automation breaks down:

Compounding all of this, the Admin API and Order module cannot filter orders server-side by fulfillment_status or payment_status. That is a confirmed limitation, not a workaround waiting to be discovered (see issues #11600 and #14095). There is no built-in admin query for "show me every paid order that is still unfulfilled." You have to page through orders and compute that yourself. See the citations at the end for the exact issues and docs.

The key insight

Do not auto-create a fulfillment to paper over this. Picking, packing, and shipping are real warehouse actions, and creating a fulfillment against stale or unreviewed inventory data could ship something that should not ship. The safe pattern is to detect and flag, never to fabricate the missing step. Compute the breach with a pure function, then patch metadata on the breached order so a human can review it, always behind a DRY_RUN guard that defaults to on.

The fix, as a flow

We never create a fulfillment. The job pages through orders with the fields it needs already expanded, since it cannot filter by status server-side, computes whether each order is paid, unfulfilled, and older than the SLA with one pure function, and for anything that breaches, patches the order's metadata to flag it for review. Everything else is left untouched.

Scheduled job runs on a timer Page through orders expanded fields, no server filter Pure decision fn evaluateOrderSla Paid, unfulfilled, past SLA? yes no, skip Flag metadata sla_flagged, DRY_RUN guarded
Every breached order is only flagged, never fulfilled. The metadata patch itself is guarded behind DRY_RUN so nothing writes until a human agrees.

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 SLA_HOURS="48"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install @medusajs/js-sdk

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

Page through orders with the fields the decision needs

Since fulfillment_status and payment_status cannot be used as server-side filters, ask for every order and expand exactly what you need to decide: payment_collections and fulfillments, plus created_at and metadata. Page with limit and offset until you have walked the whole list.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]

ORDER_FIELDS = (
    "id,display_id,email,created_at,status,fulfillment_status,payment_status,"
    "*payment_collections,*fulfillments,metadata"
)

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(token):
    orders = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/orders", {
            "fields": ORDER_FIELDS,
            "limit": limit,
            "offset": offset,
        })
        orders.extend(data["orders"])
        offset += limit
        if offset >= data["count"]:
            return orders
step2.js
import Medusa from "@medusajs/js-sdk";

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

const ORDER_FIELDS =
  "id,display_id,email,created_at,status,fulfillment_status,payment_status," +
  "*payment_collections,*fulfillments,metadata";

async function listOrders() {
  const orders = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const { orders: page, count } = await sdk.admin.order.list({
      fields: ORDER_FIELDS,
      limit,
      offset,
    });
    orders.push(...page);
    offset += limit;
    if (offset >= count) return orders;
  }
}
3

Decide, with one pure function

Keep the whole rule in a function that takes an order, the current time, and the SLA in hours, and returns whether it breached. It computes is_paid from payment_status being captured, or every payment_collections entry being captured, and is_unfulfilled from fulfillment_status being not_fulfilled or partially_fulfilled, or an empty fulfillments array. It excludes canceled orders and orders that already carry metadata.sla_flagged, so a second run never re-flags the same order.

decide.py
from datetime import datetime

UNFULFILLED_STATUSES = {"not_fulfilled", "partially_fulfilled"}

def _is_paid(order):
    if order.get("payment_status") == "captured":
        return True
    collections = order.get("payment_collections") or []
    if collections:
        return all(pc.get("status") == "captured" for pc in collections)
    return False

def _is_unfulfilled(order):
    if order.get("fulfillment_status") in UNFULFILLED_STATUSES:
        return True
    return len(order.get("fulfillments") or []) == 0

def evaluate_order_sla(order, now_ms, sla_hours):
    """Pure decision function. No I/O. order.created_at is an ISO 8601 string."""
    metadata = order.get("metadata") or {}
    already_flagged = metadata.get("sla_flagged") is True

    created_at = order.get("created_at")
    if not created_at:
        return {"breached": False, "already_flagged": already_flagged, "age_hours": 0.0, "reason": "missing created_at"}

    created_ms = datetime.fromisoformat(created_at.replace("Z", "+00:00")).timestamp() * 1000
    age_hours = (now_ms - created_ms) / 3_600_000

    if order.get("status") == "canceled":
        return {"breached": False, "already_flagged": already_flagged, "age_hours": age_hours, "reason": "canceled"}
    if already_flagged:
        return {"breached": False, "already_flagged": True, "age_hours": age_hours, "reason": "already flagged"}
    if not _is_paid(order):
        return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "not paid"}
    if not _is_unfulfilled(order):
        return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "already fulfilled"}
    if age_hours <= sla_hours:
        return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "within SLA"}

    return {"breached": True, "already_flagged": False, "age_hours": age_hours}
decide.js
const UNFULFILLED_STATUSES = new Set(["not_fulfilled", "partially_fulfilled"]);

function isPaid(order) {
  if (order.payment_status === "captured") return true;
  const collections = order.payment_collections || [];
  if (collections.length) return collections.every((pc) => pc.status === "captured");
  return false;
}

function isUnfulfilled(order) {
  if (UNFULFILLED_STATUSES.has(order.fulfillment_status)) return true;
  return (order.fulfillments || []).length === 0;
}

/** Pure decision function. No I/O. order.created_at is an ISO 8601 string. */
export function evaluateOrderSla(order, nowMs, slaHours) {
  const metadata = order.metadata || {};
  const alreadyFlagged = metadata.sla_flagged === true;

  if (!order.created_at) {
    return { breached: false, alreadyFlagged, ageHours: 0, reason: "missing created_at" };
  }

  const ageHours = (nowMs - Date.parse(order.created_at)) / 3_600_000;

  if (order.status === "canceled") {
    return { breached: false, alreadyFlagged, ageHours, reason: "canceled" };
  }
  if (alreadyFlagged) {
    return { breached: false, alreadyFlagged: true, ageHours, reason: "already flagged" };
  }
  if (!isPaid(order)) {
    return { breached: false, alreadyFlagged: false, ageHours, reason: "not paid" };
  }
  if (!isUnfulfilled(order)) {
    return { breached: false, alreadyFlagged: false, ageHours, reason: "already fulfilled" };
  }
  if (ageHours <= slaHours) {
    return { breached: false, alreadyFlagged: false, ageHours, reason: "within SLA" };
  }

  return { breached: true, alreadyFlagged: false, ageHours };
}
4

Flag a breached order, never fulfill it

When an order breaches, patch its metadata with sla_flagged, a timestamp, and the breach hours, so the team can find it and so the script does not flag it again next run. Medusa replaces the whole metadata object on a write rather than deep-merging, so always spread the existing metadata first.

flag.py
def admin_post(token, path, body):
    r = requests.post(
        f"{BACKEND_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def flag_order(token, order, age_hours):
    metadata = dict(order.get("metadata") or {})   # spread first, never overwrite blindly
    metadata["sla_flagged"] = True
    metadata["sla_flagged_at"] = datetime.utcnow().isoformat() + "Z"
    metadata["sla_breach_hours"] = int(age_hours)
    return admin_post(token, f"/admin/orders/{order['id']}", {"metadata": metadata})
flag.js
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}`);
  return res.json();
}

async function flagOrder(token, order, ageHours) {
  const metadata = { ...(order.metadata || {}) };   // spread first, never overwrite blindly
  metadata.sla_flagged = true;
  metadata.sla_flagged_at = new Date().toISOString();
  metadata.sla_breach_hours = Math.floor(ageHours);
  return adminPost(token, `/admin/orders/${order.id}`, { metadata });
}
5

Wire it together with a dry run guard

The loop ties every piece together: authenticate, page through orders, run the pure decision function on each one, and only call the metadata patch when DRY_RUN is off. On the first few runs, leave DRY_RUN=true so the script only logs which orders it would flag. Read the output, agree with it, then switch it off. Run it on a schedule that fits your SLA, for example every hour if your SLA is measured in hours.

Run it safe

Always start with DRY_RUN=true. This script never creates a fulfillment and never touches inventory. The only write it will ever make is a metadata patch on the specific order that breached, so it is safe to run again and again, even hourly, without risk of shipping against stale data.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through every order, flags every order that is paid, unfulfilled, past your SLA, not canceled, and not already flagged, and only writes when DRY_RUN is off.

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

flag_sla_breached_orders.py
"""Flag Medusa orders that are paid but still not fulfilled past your SLA.

In Medusa v2, placing an order and fulfilling an order are decoupled. Capturing
a payment only updates payment_status. Nothing forces a fulfillment to be
created, so an order can sit with fulfillment_status "not_fulfilled" (or
"partially_fulfilled") indefinitely if the automation that should create a
fulfillment, an order.placed subscriber, a scheduled job, or a warehouse
integration, silently fails. This is worse in production when the default
in-memory Event Bus and Workflow Engine modules are used instead of their
Redis-backed equivalents, because events and job runs do not persist across
process restarts or multiple instances.

The Admin API cannot filter orders server-side by fulfillment_status or
payment_status, so this pages through orders and computes the SLA breach
client-side. It never creates a fulfillment. Picking, packing, and shipping
are real-world actions this script cannot safely fabricate. It only patches
metadata to flag a breached order for human review, and only when DRY_RUN is
off. Run on a schedule. 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("flag_sla_breached_orders")

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
SLA_HOURS = float(os.environ.get("SLA_HOURS", "48"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

UNFULFILLED_STATUSES = {"not_fulfilled", "partially_fulfilled"}

ORDER_FIELDS = (
    "id,display_id,email,created_at,status,fulfillment_status,payment_status,"
    "*payment_collections,*fulfillments,metadata"
)


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}", "Content-Type": "application/json"},
        json=body,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def _is_paid(order):
    if order.get("payment_status") == "captured":
        return True
    collections = order.get("payment_collections") or []
    if collections:
        return all(pc.get("status") == "captured" for pc in collections)
    return False


def _is_unfulfilled(order):
    if order.get("fulfillment_status") in UNFULFILLED_STATUSES:
        return True
    return len(order.get("fulfillments") or []) == 0


def evaluate_order_sla(order, now_ms, sla_hours):
    """Pure decision function. No I/O.

    order: {status, payment_status, fulfillment_status, fulfillments?, created_at,
            metadata?}
    now_ms: current time in epoch milliseconds (passed in, not read from the clock)
    sla_hours: hours after which a paid, unfulfilled order is considered breached

    Returns {breached, already_flagged, age_hours, reason?}.
    """
    metadata = order.get("metadata") or {}
    already_flagged = metadata.get("sla_flagged") is True

    created_at = order.get("created_at")
    if not created_at:
        return {"breached": False, "already_flagged": already_flagged, "age_hours": 0.0, "reason": "missing created_at"}

    created_ms = _parse_iso_to_ms(created_at)
    age_hours = (now_ms - created_ms) / 3_600_000

    if order.get("status") == "canceled":
        return {"breached": False, "already_flagged": already_flagged, "age_hours": age_hours, "reason": "canceled"}

    is_paid = _is_paid(order)
    is_unfulfilled = _is_unfulfilled(order)

    if already_flagged:
        return {"breached": False, "already_flagged": True, "age_hours": age_hours, "reason": "already flagged"}
    if not is_paid:
        return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "not paid"}
    if not is_unfulfilled:
        return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "already fulfilled"}
    if age_hours <= sla_hours:
        return {"breached": False, "already_flagged": False, "age_hours": age_hours, "reason": "within SLA"}

    return {"breached": True, "already_flagged": False, "age_hours": age_hours}


def _parse_iso_to_ms(iso):
    from datetime import datetime
    return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000


def list_orders(token):
    orders = []
    offset = 0
    limit = 100
    while True:
        data = admin_get(token, "/admin/orders", {
            "fields": ORDER_FIELDS,
            "limit": limit,
            "offset": offset,
        })
        orders.extend(data["orders"])
        offset += limit
        if offset >= data["count"]:
            return orders


def flag_order(token, order):
    metadata = dict(order.get("metadata") or {})
    metadata["sla_flagged"] = True
    metadata["sla_flagged_at"] = _now_iso()
    metadata["sla_breach_hours"] = int(evaluate_order_sla(order, _now_ms(), SLA_HOURS)["age_hours"])
    return admin_post(token, f"/admin/orders/{order['id']}", {"metadata": metadata})


def _now_ms():
    import time
    return time.time() * 1000


def _now_iso():
    from datetime import datetime, timezone
    return datetime.now(timezone.utc).isoformat()


def run():
    token = get_admin_token()
    now_ms = _now_ms()

    flagged = 0
    for order in list_orders(token):
        result = evaluate_order_sla(order, now_ms, SLA_HOURS)
        if not result["breached"]:
            continue
        log.warning(
            "Order %s (%s) breached SLA: paid but %s for %.1fh. %s",
            order.get("display_id"), order["id"], order.get("fulfillment_status"),
            result["age_hours"], "would flag" if DRY_RUN else "flagging",
        )
        if not DRY_RUN:
            flag_order(token, order)
        flagged += 1

    log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")


if __name__ == "__main__":
    run()
flag-sla-breached-orders.js
/**
 * Flag Medusa orders that are paid but still not fulfilled past your SLA.
 *
 * In Medusa v2, placing an order and fulfilling an order are decoupled. Capturing
 * a payment only updates payment_status. Nothing forces a fulfillment to be
 * created, so an order can sit with fulfillment_status "not_fulfilled" (or
 * "partially_fulfilled") indefinitely if the automation that should create a
 * fulfillment, an order.placed subscriber, a scheduled job, or a warehouse
 * integration, silently fails. This is worse in production when the default
 * in-memory Event Bus and Workflow Engine modules are used instead of their
 * Redis-backed equivalents, because events and job runs do not persist across
 * process restarts or multiple instances.
 *
 * The Admin API cannot filter orders server-side by fulfillment_status or
 * payment_status, so this pages through orders and computes the SLA breach
 * client-side. It never creates a fulfillment. Picking, packing, and shipping
 * are real-world actions this script cannot safely fabricate. It only patches
 * metadata to flag a breached order for human review, and only when DRY_RUN is
 * off. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/order-stuck-not-fulfilled-past-sla/
 */
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 SLA_HOURS = Number(process.env.SLA_HOURS || 48);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const UNFULFILLED_STATUSES = new Set(["not_fulfilled", "partially_fulfilled"]);

const ORDER_FIELDS =
  "id,display_id,email,created_at,status,fulfillment_status,payment_status," +
  "*payment_collections,*fulfillments,metadata";

function isPaid(order) {
  if (order.payment_status === "captured") return true;
  const collections = order.payment_collections || [];
  if (collections.length) return collections.every((pc) => pc.status === "captured");
  return false;
}

function isUnfulfilled(order) {
  if (UNFULFILLED_STATUSES.has(order.fulfillment_status)) return true;
  return (order.fulfillments || []).length === 0;
}

/**
 * Pure decision function. No I/O.
 *
 * @param {{ status: string, payment_status: string, fulfillment_status: string,
 *   fulfillments?: Array<{ id: string }>, created_at: string,
 *   metadata?: Record | null,
 *   payment_collections?: Array<{ status: string }> }} order
 * @param {number} nowMs current time in epoch milliseconds, passed in
 * @param {number} slaHours hours after which a paid, unfulfilled order is breached
 * @returns {{ breached: boolean, alreadyFlagged: boolean, ageHours: number, reason?: string }}
 */
export function evaluateOrderSla(order, nowMs, slaHours) {
  const metadata = order.metadata || {};
  const alreadyFlagged = metadata.sla_flagged === true;

  if (!order.created_at) {
    return { breached: false, alreadyFlagged, ageHours: 0, reason: "missing created_at" };
  }

  const ageHours = (nowMs - Date.parse(order.created_at)) / 3_600_000;

  if (order.status === "canceled") {
    return { breached: false, alreadyFlagged, ageHours, reason: "canceled" };
  }
  if (alreadyFlagged) {
    return { breached: false, alreadyFlagged: true, ageHours, reason: "already flagged" };
  }
  if (!isPaid(order)) {
    return { breached: false, alreadyFlagged: false, ageHours, reason: "not paid" };
  }
  if (!isUnfulfilled(order)) {
    return { breached: false, alreadyFlagged: false, ageHours, reason: "already fulfilled" };
  }
  if (ageHours <= slaHours) {
    return { breached: false, alreadyFlagged: false, ageHours, reason: "within SLA" };
  }

  return { breached: true, alreadyFlagged: false, ageHours };
}

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}`);
  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}`);
  return res.json();
}

async function listOrders(token) {
  const orders = [];
  let offset = 0;
  const limit = 100;
  while (true) {
    const data = await adminGet(token, "/admin/orders", { fields: ORDER_FIELDS, limit, offset });
    orders.push(...data.orders);
    offset += limit;
    if (offset >= data.count) return orders;
  }
}

async function flagOrder(token, order, ageHours) {
  const metadata = { ...(order.metadata || {}) };
  metadata.sla_flagged = true;
  metadata.sla_flagged_at = new Date().toISOString();
  metadata.sla_breach_hours = Math.floor(ageHours);
  return adminPost(token, `/admin/orders/${order.id}`, { metadata });
}

export async function run() {
  const token = await getAdminToken();
  const nowMs = Date.now();

  let flagged = 0;
  for (const order of await listOrders(token)) {
    const result = evaluateOrderSla(order, nowMs, SLA_HOURS);
    if (!result.breached) continue;
    console.warn(
      `Order ${order.display_id} (${order.id}) breached SLA: paid but ${order.fulfillment_status} for ${result.ageHours.toFixed(1)}h. ${DRY_RUN ? "would flag" : "flagging"}`
    );
    if (!DRY_RUN) await flagOrder(token, order, result.ageHours);
    flagged++;
  }

  console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}

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

Add a test

evaluate_order_sla is the part most worth testing, because it decides which orders get flagged for review. It is pure, taking the current time as an argument instead of reading the clock, so the test needs no network and no Medusa backend. It just feeds in plain order objects and a fixed timestamp, and checks the answer.

test_stuck_sla.py
from datetime import datetime

from flag_sla_breached_orders import evaluate_order_sla

NOW_MS = datetime.fromisoformat("2026-07-10T00:00:00+00:00").timestamp() * 1000


def order(**over):
    base = {
        "status": "completed",
        "payment_status": "captured",
        "fulfillment_status": "not_fulfilled",
        "fulfillments": [],
        "created_at": "2026-07-06T00:00:00Z",  # 96h before NOW_MS
        "metadata": {},
    }
    base.update(over)
    return base


def test_breached_when_paid_unfulfilled_and_past_sla():
    result = evaluate_order_sla(order(), NOW_MS, 48)
    assert result["breached"] is True
    assert result["already_flagged"] is False
    assert round(result["age_hours"]) == 96


def test_not_breached_when_within_sla():
    o = order(created_at="2026-07-09T12:00:00Z")  # 12h before NOW_MS
    assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False


def test_not_breached_when_not_paid():
    o = order(payment_status="not_paid")
    assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False


def test_breached_when_paid_via_payment_collections():
    o = order(payment_status=None, payment_collections=[{"status": "captured"}])
    assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is True


def test_not_breached_when_already_fulfilled():
    o = order(fulfillment_status="fulfilled", fulfillments=[{"id": "ful_1"}])
    assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False


def test_not_breached_when_canceled():
    o = order(status="canceled")
    assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False


def test_not_breached_when_already_flagged():
    o = order(metadata={"sla_flagged": True})
    result = evaluate_order_sla(o, NOW_MS, 48)
    assert result["breached"] is False
    assert result["already_flagged"] is True


def test_exactly_at_sla_boundary_is_not_breached():
    o = order(created_at="2026-07-08T00:00:00Z")  # exactly 48h before NOW_MS
    assert evaluate_order_sla(o, NOW_MS, 48)["breached"] is False
flag-sla-breached-orders.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateOrderSla } from "./flag-sla-breached-orders.js";

const NOW_MS = Date.parse("2026-07-10T00:00:00Z");

const order = (over = {}) => ({
  status: "completed",
  payment_status: "captured",
  fulfillment_status: "not_fulfilled",
  fulfillments: [],
  created_at: "2026-07-06T00:00:00Z", // 96h before NOW_MS
  metadata: {},
  ...over,
});

test("breached when paid, unfulfilled, and past SLA", () => {
  const result = evaluateOrderSla(order(), NOW_MS, 48);
  assert.equal(result.breached, true);
  assert.equal(result.alreadyFlagged, false);
  assert.equal(Math.round(result.ageHours), 96);
});

test("not breached when within SLA", () => {
  const result = evaluateOrderSla(order({ created_at: "2026-07-09T12:00:00Z" }), NOW_MS, 48);
  assert.equal(result.breached, false);
});

test("not breached when not paid", () => {
  const result = evaluateOrderSla(order({ payment_status: "not_paid" }), NOW_MS, 48);
  assert.equal(result.breached, false);
});

test("breached when paid via payment_collections", () => {
  const result = evaluateOrderSla(
    order({ payment_status: null, payment_collections: [{ status: "captured" }] }),
    NOW_MS,
    48
  );
  assert.equal(result.breached, true);
});

test("not breached when already fulfilled", () => {
  const result = evaluateOrderSla(
    order({ fulfillment_status: "fulfilled", fulfillments: [{ id: "ful_1" }] }),
    NOW_MS,
    48
  );
  assert.equal(result.breached, false);
});

test("not breached when canceled", () => {
  const result = evaluateOrderSla(order({ status: "canceled" }), NOW_MS, 48);
  assert.equal(result.breached, false);
});

test("not breached when already flagged", () => {
  const result = evaluateOrderSla(order({ metadata: { sla_flagged: true } }), NOW_MS, 48);
  assert.equal(result.breached, false);
  assert.equal(result.alreadyFlagged, true);
});

test("exactly at SLA boundary is not breached", () => {
  const result = evaluateOrderSla(order({ created_at: "2026-07-08T00:00:00Z" }), NOW_MS, 48);
  assert.equal(result.breached, false);
});

Case studies

Silent subscriber failure

A deploy broke the order.placed subscriber and nobody knew for four days

A team shipped an unrelated schema change that broke a field the fulfillment-creating order.placed subscriber depended on. The subscriber started throwing on every order, but the error only landed in a log stream nobody was tailing that week. Orders kept coming in, payments kept capturing, and a growing pile of paid orders sat at not_fulfilled.

The team started running the flag script hourly with SLA_HOURS=24. On the very first run it surfaced ninety-plus breached orders at once, which was alarming enough to get someone to check the subscriber logs immediately, find the schema mismatch, and fix it the same day.

In-memory Event Bus in production

Every deploy quietly dropped a few order.placed events

A smaller store ran Medusa without the Redis-backed Event Bus and Workflow Engine modules, relying on the in-memory defaults. Every time a deploy restarted the app, any order.placed event or workflow step that was in flight at that exact moment was simply gone. It only affected a handful of orders per deploy, so it never showed up as an obvious outage, just a slow drip of orders that never got fulfilled.

Running this script on a daily schedule turned that invisible drip into a short, visible list the ops team could review and manually push through, while the underlying fix, moving to the Redis-backed modules, got scheduled and shipped.

What good looks like

After this runs on a schedule, a paid order that falls through the fulfillment automation gets caught within one SLA window instead of sitting there until a customer complains. Nothing was auto-fulfilled and no inventory was touched. A human sees the exact list of breached orders, with how long each one has been waiting, and decides what to do next, whether that is retrying the automation, fulfilling manually, or digging into why the subscriber or job failed in the first place.

FAQ

Why does a paid Medusa order stay not_fulfilled?

In Medusa v2, placing an order and fulfilling an order are decoupled. Capturing a payment only updates payment_status. Nothing forces a fulfillment to be created, so the order can sit at fulfillment_status not_fulfilled indefinitely if the automation meant to trigger fulfillment, an order.placed subscriber, a scheduled job, or a warehouse integration, silently fails or never ran.

Can I use the Admin API to filter orders by fulfillment_status or payment_status?

No, not server-side. This is a confirmed limitation in Medusa v2's Admin API and Order module. You have to page through orders with the Admin API, expand the fields you need such as payment_collections and fulfillments, and compute which ones are paid but unfulfilled and how old they are on the client side.

Should a script auto-create the missing fulfillment?

No. Picking, packing, and shipping are real warehouse actions a script cannot safely fabricate, and creating a fulfillment against stale inventory data could make things worse. The safe pattern is to flag the breached order, for example by patching its metadata for human review, guarded behind a DRY_RUN flag, rather than ever creating a fulfillment automatically.

Related field notes

Citations

On the problem:

  1. Medusa Documentation: Events and Subscribers. docs.medusajs.com/learn/fundamentals/events-and-subscribers
  2. [Bug]: Unable to filter or order by fulfillment / payment status in admin UI and with graph query. Medusa GitHub Issue #14095. github.com/medusajs/medusa/issues/14095
  3. [Bug]: API Admin order filter on payment_status & fulfillment_status especially. Medusa GitHub Issue #11600. github.com/medusajs/medusa/issues/11600

On the solution:

  1. Manage Order Fulfillments in Medusa Admin. Medusa Admin User Guide. docs.medusajs.com/user-guide/orders/fulfillments
  2. Medusa Documentation: order, JS SDK Admin Reference. docs.medusajs.com/resources/references/js-sdk/admin/order
  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 a stuck order before your customer did?

If this saved you from a support ticket about an order that never shipped, or from writing this SLA sweep from scratch, 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