Diagnostic Orders and fulfillment

Fulfillment without a tracking number

Someone clicked Mark as Shipped in the admin. The order looks done. But the fulfillment carries no tracking number at all, so nobody, not support, not the customer, can trace the package once it leaves the building. Here is why Medusa v2 lets a fulfillment become shipped with an empty labels array, and a script that finds every one of these so a human can chase down the real tracking number with the carrier.

Python and Node.js Medusa Admin API Flag and report, never invent tracking
The short answer

In Medusa v2, a fulfillment's tracking data lives in Fulfillment.labels[], each label carrying tracking_number, tracking_url, and label_url, separate from the shipped_at timestamp that actually marks it as shipped. The Admin dashboard's Create Shipment flow has historically built the shipment's labels solely from whatever was typed into that form's tracking number input, discarding any labels a fulfillment provider had already attached in createFulfillment(). Because tracking entry is optional and provider labels can be silently dropped, a merchant can click Mark as Shipped, setting shipped_at, while labels stays empty. Run a script that pages through orders, expands fulfillments and fulfillments.labels, and flags every fulfillment that is shipped, not canceled, and has no non-empty tracking_number on any label. Full code, tests, and a dry run guard are below.

The problem in plain words

It feels like tracking a Medusa order should be simple. A fulfillment gets created, a label gets attached, the package ships, done. But in Medusa v2 those are three separate facts living in three separate places, and nothing forces them to agree. The tracking number lives on a label inside Fulfillment.labels[]. Whether the fulfillment has shipped lives in shipped_at, a plain timestamp. A fulfillment provider can attach a real label with a real tracking number the moment createFulfillment() runs, well before anyone marks the order shipped.

The trouble starts in the Admin dashboard. When a staff member clicks Mark as Shipped, that flow calls createShipmentWorkflow, and historically it built the shipment's labels solely from whatever was typed into that form's tracking number field. If the field was left blank, because someone forgot, or because the shipment was created via API and nobody expected a manual form to matter, the workflow could discard the labels the fulfillment provider had already attached, rather than keeping them. The order looks perfectly normal, shipped_at is set, the status reads Shipped, but labels is empty and nobody can trace the package.

createFulfillment() provider attaches a real label Mark as Shipped admin Create Shipment form form field left blank, provider label dropped labels stays empty no tracking_number shipped_at is set but no tracking package cannot be traced Tracking data (labels) and the shipped flag (shipped_at) live in different places and nothing forces them to stay in sync
The provider's label can be discarded when the admin's Create Shipment form is submitted without its own tracking number, leaving shipped_at set and labels empty.

Why it happens

Medusa v2 models a fulfillment's shipped state and its tracking data as two independent fields, so a store can plug in any shipping workflow it wants. A few common ways this leaves a fulfillment untraceable:

This is a confirmed bug, not a one-off misconfiguration. See the citations at the end for the exact issue, the partial fix, and the docs on how labels and shipping actually work.

The key insight

Do not try to synthesize a tracking number. There is no legitimate value a script could invent, and writing a fake one would actively mislead a customer trying to trace their package. The safe pattern is to detect and flag, never to fabricate. Compute the flag with a pure function, then hand the list to support so a human can get the real tracking number from the carrier or fulfillment provider and attach it through the same shipment endpoint the admin uses.

The fix, as a flow

We never invent a tracking number. The job pages through orders with fulfillments and fulfillments.labels expanded, since list responses can silently omit labels otherwise, decides with one pure function whether each fulfillment is shipped but untraceable, and for anything that matches, writes it to a report for support to chase down. The only legitimate write, attaching a real label once support has the tracking number, stays a separate, human-triggered step behind its own dry run guard.

Scheduled job runs on a timer Page through orders expand fulfillments.labels Pure decision fn findUntrackedShipments Shipped, no tracking? yes no, skip Add to report support chases the carrier
The script only ever reports untracked shipments. Attaching a real tracking number is a separate, human-triggered step, never something the sweep invents on its 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 only for a human-supplied fix
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 DRY_RUN="true"   // start safe, change to false only for a human-supplied fix
2

Page through orders with labels explicitly expanded

List responses can omit nested labels unless you ask for them, so always request *fulfillments and *fulfillments.labels together. Page with limit and offset until the whole list has been walked.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]

ORDER_FIELDS = "id,display_id,email,*fulfillments,*fulfillments.labels"

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,*fulfillments,*fulfillments.labels";

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 a list of fulfillments and returns the ones with a reason. A fulfillment counts as shipped without tracking only when shipped_at is set, canceled_at is not set, and every label on it, if there are any at all, has a blank or missing tracking_number.

decide.py
def find_untracked_shipments(fulfillments):
    """Pure decision function. No I/O.

    A fulfillment is "shipped without tracking" iff:
      1. shipped_at is set (truthy) -> it has actually been marked shipped
      2. canceled_at is NOT set -> ignore canceled fulfillments
      3. labels is missing/empty OR every label has a blank tracking_number
    """
    flagged = []
    for f in fulfillments:
        is_shipped = bool(f.get("shipped_at"))
        is_canceled = bool(f.get("canceled_at"))
        labels = f.get("labels") or []
        has_tracking_number = any(
            (l.get("tracking_number") or "").strip() for l in labels
        )
        if is_shipped and not is_canceled and not has_tracking_number:
            flagged.append({
                "id": f["id"],
                "reason": "shipped_at set but no non-empty tracking_number on any label",
            })
    return flagged
decide.js
/**
 * Pure decision function. No I/O.
 *
 * A fulfillment is "shipped without tracking" iff:
 *   1. shipped_at is set (truthy) -> it has actually been marked shipped
 *   2. canceled_at is NOT set -> ignore canceled fulfillments
 *   3. labels is missing/empty OR every label has a blank tracking_number
 *
 * @param {Array<{ id: string, shipped_at: string | null, canceled_at: string | null,
 *   labels?: Array<{ tracking_number?: string | null }> }>} fulfillments
 * @returns {Array<{ id: string, reason: string }>}
 */
export function findUntrackedShipments(fulfillments) {
  return fulfillments
    .filter((f) => {
      const isShipped = !!f.shipped_at;
      const isCanceled = !!f.canceled_at;
      const hasLabels = Array.isArray(f.labels) && f.labels.length > 0;
      const hasTrackingNumber =
        hasLabels && f.labels.some((l) => !!(l.tracking_number && l.tracking_number.trim().length > 0));
      return isShipped && !isCanceled && !hasTrackingNumber;
    })
    .map((f) => ({ id: f.id, reason: "shipped_at set but no non-empty tracking_number on any label" }));
}
4

Cross-check with a focused re-read

List responses can omit nested labels even when you asked for them if something upstream trims the payload, so before you flag a fulfillment, re-read it directly with GET /admin/orders/{"{order_id}"}/fulfillments/{"{fulfillment_id}"}. This costs one extra call per suspect fulfillment, but it means the report never wrongly accuses a fulfillment that actually has a label.

recheck.py
def refetch_fulfillment(token, order_id, fulfillment_id):
    path = f"/admin/orders/{order_id}/fulfillments/{fulfillment_id}"
    data = admin_get(token, path, {"fields": "id,shipped_at,canceled_at,*labels"})
    return data["fulfillment"]
recheck.js
async function adminGet(token, path, params = {}) {
  const url = new URL(`${process.env.MEDUSA_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 refetchFulfillment(token, orderId, fulfillmentId) {
  const path = `/admin/orders/${orderId}/fulfillments/${fulfillmentId}`;
  const data = await adminGet(token, path, { fields: "id,shipped_at,canceled_at,*labels" });
  return data.fulfillment;
}
5

Wire it together into a report, never an auto-fix

The loop ties every piece together: authenticate, page through orders, run the pure decision function on each order's fulfillments, re-check any suspect fulfillment, and write confirmed ones to a report. Attaching a real tracking number is a separate path, only taken when DRY_RUN is off and a human has supplied a real value obtained from the carrier, via POST /admin/orders/{"{order_id}"}/fulfillments/{"{fulfillment_id}"}/shipment with {"{ labels: [{ tracking_number, tracking_url, label_url }] }"}, the same route the admin's Mark as Shipped form calls.

Run it safe

Always start with DRY_RUN=true. This script never writes a tracking number on its own. It only ever logs the order id, fulfillment id, and proposed label payload. The actual POST .../shipment call only happens when DRY_RUN=false and a human has supplied the real tracking number obtained from the carrier, never a synthesized one.

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 fulfillment that is shipped, not canceled, and has no tracking number on any label, and writes a report. It only performs the corrective write when DRY_RUN is off and a real tracking number has been supplied by a human.

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

flag_untracked_shipments.py
"""Flag Medusa fulfillments that are shipped but carry no tracking number.

In Medusa v2, a fulfillment's tracking data lives in Fulfillment.labels[], each
label carrying tracking_number, tracking_url, and label_url, separate from the
shipped_at timestamp that actually marks it as shipped. The Admin dashboard's
Create Shipment flow, backed by createShipmentWorkflow, has historically built
the shipment's labels solely from whatever was typed into that form's tracking
number input, discarding any labels a fulfillment provider had already
attached in createFulfillment(). Because tracking entry is optional, a
merchant can click Mark as Shipped, setting shipped_at, while labels stays
empty (see medusajs/medusa issue #11160, partially addressed in PR #11775).

There is no legitimate value this script could invent for a missing tracking
number, so this only flags and reports. The only write it will ever make is
attaching a real label a human has already obtained from the carrier or
fulfillment provider, and only when DRY_RUN is off. Run on a schedule. Safe to
run again and again.
"""
import csv
import io
import os
import logging
import requests

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

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"

ORDER_FIELDS = "id,display_id,email,*fulfillments,*fulfillments.labels"


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 find_untracked_shipments(fulfillments):
    """Pure decision function. No I/O.

    fulfillments: list of {id, shipped_at, canceled_at, labels?: [{tracking_number?}]}

    A fulfillment is "shipped without tracking" iff:
      1. shipped_at is set (truthy) -> it has actually been marked shipped
      2. canceled_at is NOT set -> ignore canceled fulfillments
      3. labels is missing/empty OR every label has a blank tracking_number

    Returns a list of {id, reason}.
    """
    flagged = []
    for f in fulfillments:
        is_shipped = bool(f.get("shipped_at"))
        is_canceled = bool(f.get("canceled_at"))
        labels = f.get("labels") or []
        has_tracking_number = any(
            (l.get("tracking_number") or "").strip() for l in labels
        )
        if is_shipped and not is_canceled and not has_tracking_number:
            flagged.append({
                "id": f["id"],
                "reason": "shipped_at set but no non-empty tracking_number on any label",
            })
    return flagged


def refetch_fulfillment(token, order_id, fulfillment_id):
    path = f"/admin/orders/{order_id}/fulfillments/{fulfillment_id}"
    data = admin_get(token, path, {"fields": "id,shipped_at,canceled_at,*labels"})
    return data["fulfillment"]


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 attach_tracking_number(token, order_id, fulfillment_id, tracking_number, tracking_url=None, label_url=None):
    """The only legitimate corrective write. Never call this with a synthesized value."""
    path = f"/admin/orders/{order_id}/fulfillments/{fulfillment_id}/shipment"
    body = {"labels": [{
        "tracking_number": tracking_number,
        "tracking_url": tracking_url,
        "label_url": label_url,
    }]}
    return admin_post(token, path, body)


def write_report(rows):
    buf = io.StringIO()
    writer = csv.DictWriter(buf, fieldnames=["order_id", "display_id", "fulfillment_id", "shipped_at", "provider_id"])
    writer.writeheader()
    for row in rows:
        writer.writerow(row)
    return buf.getvalue()


def run():
    token = get_admin_token()

    rows = []
    for order in list_orders(token):
        fulfillments = order.get("fulfillments") or []
        for flagged in find_untracked_shipments(fulfillments):
            fulfillment = next(f for f in fulfillments if f["id"] == flagged["id"])
            # Re-read directly, since list responses can omit nested labels.
            rechecked = refetch_fulfillment(token, order["id"], fulfillment["id"])
            if find_untracked_shipments([rechecked]):
                rows.append({
                    "order_id": order["id"],
                    "display_id": order.get("display_id"),
                    "fulfillment_id": fulfillment["id"],
                    "shipped_at": fulfillment.get("shipped_at"),
                    "provider_id": fulfillment.get("provider_id"),
                })
                log.warning(
                    "Order %s fulfillment %s shipped with no tracking number. %s",
                    order.get("display_id"), fulfillment["id"],
                    "would report" if DRY_RUN else "reporting",
                )

    report = write_report(rows)
    log.info("Done. %d fulfillment(s) shipped without tracking.", len(rows))
    return report


if __name__ == "__main__":
    run()
flag-untracked-shipments.js
/**
 * Flag Medusa fulfillments that are shipped but carry no tracking number.
 *
 * In Medusa v2, a fulfillment's tracking data lives in Fulfillment.labels[], each
 * label carrying tracking_number, tracking_url, and label_url, separate from the
 * shipped_at timestamp that actually marks it as shipped. The Admin dashboard's
 * Create Shipment flow, backed by createShipmentWorkflow, has historically built
 * the shipment's labels solely from whatever was typed into that form's tracking
 * number input, discarding any labels a fulfillment provider had already
 * attached in createFulfillment(). Because tracking entry is optional, a
 * merchant can click Mark as Shipped, setting shipped_at, while labels stays
 * empty (see medusajs/medusa issue #11160, partially addressed in PR #11775).
 *
 * There is no legitimate value this script could invent for a missing tracking
 * number, so this only flags and reports. The only write it will ever make is
 * attaching a real label a human has already obtained from the carrier or
 * fulfillment provider, and only when DRY_RUN is off. Run on a schedule. Safe to
 * run again and again.
 *
 * Guide: https://www.allanninal.dev/medusa/fulfillment-without-a-tracking-number/
 */
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 ORDER_FIELDS = "id,display_id,email,*fulfillments,*fulfillments.labels";

/**
 * Pure decision function. No I/O.
 *
 * A fulfillment is "shipped without tracking" iff:
 *   1. shipped_at is set (truthy) -> it has actually been marked shipped
 *   2. canceled_at is NOT set -> ignore canceled fulfillments
 *   3. labels is missing/empty OR every label has a blank tracking_number
 *
 * @param {Array<{ id: string, shipped_at: string | null, canceled_at: string | null,
 *   labels?: Array<{ tracking_number?: string | null }> }>} fulfillments
 * @returns {Array<{ id: string, reason: string }>}
 */
export function findUntrackedShipments(fulfillments) {
  return fulfillments
    .filter((f) => {
      const isShipped = !!f.shipped_at;
      const isCanceled = !!f.canceled_at;
      const hasLabels = Array.isArray(f.labels) && f.labels.length > 0;
      const hasTrackingNumber =
        hasLabels && f.labels.some((l) => !!(l.tracking_number && l.tracking_number.trim().length > 0));
      return isShipped && !isCanceled && !hasTrackingNumber;
    })
    .map((f) => ({ id: f.id, reason: "shipped_at set but no non-empty tracking_number on any label" }));
}

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 refetchFulfillment(token, orderId, fulfillmentId) {
  const path = `/admin/orders/${orderId}/fulfillments/${fulfillmentId}`;
  const data = await adminGet(token, path, { fields: "id,shipped_at,canceled_at,*labels" });
  return data.fulfillment;
}

/** The only legitimate corrective write. Never call this with a synthesized value. */
async function attachTrackingNumber(token, orderId, fulfillmentId, trackingNumber, trackingUrl, labelUrl) {
  const path = `/admin/orders/${orderId}/fulfillments/${fulfillmentId}/shipment`;
  const body = { labels: [{ tracking_number: trackingNumber, tracking_url: trackingUrl, label_url: labelUrl }] };
  return adminPost(token, path, body);
}

function toCsv(rows) {
  const header = "order_id,display_id,fulfillment_id,shipped_at,provider_id";
  const lines = rows.map((r) =>
    [r.order_id, r.display_id, r.fulfillment_id, r.shipped_at, r.provider_id].map((v) => v ?? "").join(",")
  );
  return [header, ...lines].join("\n");
}

export async function run() {
  const token = await getAdminToken();

  const rows = [];
  for (const order of await listOrders(token)) {
    const fulfillments = order.fulfillments || [];
    for (const flagged of findUntrackedShipments(fulfillments)) {
      const fulfillment = fulfillments.find((f) => f.id === flagged.id);
      // Re-read directly, since list responses can omit nested labels.
      const rechecked = await refetchFulfillment(token, order.id, fulfillment.id);
      if (findUntrackedShipments([rechecked]).length) {
        rows.push({
          order_id: order.id,
          display_id: order.display_id,
          fulfillment_id: fulfillment.id,
          shipped_at: fulfillment.shipped_at,
          provider_id: fulfillment.provider_id,
        });
        console.warn(
          `Order ${order.display_id} fulfillment ${fulfillment.id} shipped with no tracking number. ${DRY_RUN ? "would report" : "reporting"}`
        );
      }
    }
  }

  const report = toCsv(rows);
  console.log(`Done. ${rows.length} fulfillment(s) shipped without tracking.`);
  return report;
}

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

Add a test

find_untracked_shipments is the part most worth testing, because it decides which fulfillments end up on support's chase list. It is pure, taking a plain array of fulfillments and returning a plain array of flagged ones, so the test needs no network and no Medusa backend.

test_tracking_number.py
from flag_untracked_shipments import find_untracked_shipments


def fulfillment(**over):
    base = {
        "id": "ful_1",
        "shipped_at": "2026-07-08T00:00:00Z",
        "canceled_at": None,
        "labels": [],
    }
    base.update(over)
    return base


def test_flagged_when_shipped_and_no_labels():
    result = find_untracked_shipments([fulfillment()])
    assert len(result) == 1
    assert result[0]["id"] == "ful_1"


def test_flagged_when_labels_have_blank_tracking_number():
    f = fulfillment(labels=[{"tracking_number": ""}, {"tracking_number": None}])
    assert len(find_untracked_shipments([f])) == 1


def test_not_flagged_when_a_label_has_a_tracking_number():
    f = fulfillment(labels=[{"tracking_number": "1Z999AA10123456784"}])
    assert find_untracked_shipments([f]) == []


def test_not_flagged_when_not_shipped():
    f = fulfillment(shipped_at=None)
    assert find_untracked_shipments([f]) == []


def test_not_flagged_when_canceled():
    f = fulfillment(canceled_at="2026-07-09T00:00:00Z")
    assert find_untracked_shipments([f]) == []


def test_not_flagged_when_tracking_number_is_whitespace_only():
    f = fulfillment(labels=[{"tracking_number": "   "}])
    assert len(find_untracked_shipments([f])) == 1


def test_multiple_fulfillments_only_flags_the_untracked_one():
    tracked = fulfillment(id="ful_2", labels=[{"tracking_number": "TRACK123"}])
    untracked = fulfillment(id="ful_3")
    result = find_untracked_shipments([tracked, untracked])
    assert [r["id"] for r in result] == ["ful_3"]
flag-untracked-shipments.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findUntrackedShipments } from "./flag-untracked-shipments.js";

const fulfillment = (over = {}) => ({
  id: "ful_1",
  shipped_at: "2026-07-08T00:00:00Z",
  canceled_at: null,
  labels: [],
  ...over,
});

test("flagged when shipped and no labels", () => {
  const result = findUntrackedShipments([fulfillment()]);
  assert.equal(result.length, 1);
  assert.equal(result[0].id, "ful_1");
});

test("flagged when labels have blank tracking_number", () => {
  const f = fulfillment({ labels: [{ tracking_number: "" }, { tracking_number: null }] });
  assert.equal(findUntrackedShipments([f]).length, 1);
});

test("not flagged when a label has a tracking_number", () => {
  const f = fulfillment({ labels: [{ tracking_number: "1Z999AA10123456784" }] });
  assert.deepEqual(findUntrackedShipments([f]), []);
});

test("not flagged when not shipped", () => {
  const f = fulfillment({ shipped_at: null });
  assert.deepEqual(findUntrackedShipments([f]), []);
});

test("not flagged when canceled", () => {
  const f = fulfillment({ canceled_at: "2026-07-09T00:00:00Z" });
  assert.deepEqual(findUntrackedShipments([f]), []);
});

test("not flagged when tracking_number is whitespace only", () => {
  const f = fulfillment({ labels: [{ tracking_number: "   " }] });
  assert.equal(findUntrackedShipments([f]).length, 1);
});

test("multiple fulfillments only flags the untracked one", () => {
  const tracked = fulfillment({ id: "ful_2", labels: [{ tracking_number: "TRACK123" }] });
  const untracked = fulfillment({ id: "ful_3" });
  const result = findUntrackedShipments([tracked, untracked]);
  assert.deepEqual(result.map((r) => r.id), ["ful_3"]);
});

Case studies

Manual admin shipment

Support kept getting "where is my order" tickets for orders marked Shipped

A mid-size store fulfilled through a third-party 3PL that attached a real label with a tracking number as soon as the fulfillment was created. Staff still clicked through the admin's Mark as Shipped form afterward as part of their daily routine, and left the form's own tracking number field blank since they assumed the existing label would be kept. Customers started emailing asking where their tracking number was, even though the order clearly said Shipped.

Running the flag script weekly surfaced the exact list of shipped-but-untracked fulfillments. Support cross-checked each one against the 3PL's dashboard, found the real tracking numbers had existed the whole time, and attached them through the shipment endpoint. They also stopped resubmitting the admin form once a label already existed.

Async label from the provider

A batch of orders shipped before the courier's label ever arrived

A store's fulfillment provider generated labels asynchronously, sometimes minutes after createFulfillment() returned. During a busy sale weekend, staff worked through a queue of orders and marked several as shipped before the provider's webhook had delivered the label back to Medusa, so those fulfillments went out with shipped_at set and no label at all.

The daily sweep flagged the batch the next morning. The provider's own portal still had the tracking numbers on file, so support pulled them from there and attached them through the same shipment endpoint the admin form uses, closing every ticket before the customer even noticed.

What good looks like

After this runs on a schedule, a fulfillment that goes out untraceable gets caught within one sweep instead of surfacing as a support ticket days later. Nothing was invented and no tracking number was guessed. Support gets a clean list of order id, display id, fulfillment id, shipped_at, and provider id, and can chase the real tracking number down with the carrier or the fulfillment provider's own dashboard, then attach it the same way the admin's Mark as Shipped form would.

FAQ

Why does a Medusa fulfillment show shipped with no tracking number?

In Medusa v2, tracking data lives in the fulfillment's labels array, separate from the shipped_at timestamp that marks it shipped. The Admin dashboard's Create Shipment form has historically built the shipment's labels solely from whatever was typed into its own tracking number field, discarding any labels a fulfillment provider already attached in createFulfillment. Since tracking entry is optional, a merchant can click Mark as Shipped and set shipped_at while labels stays empty.

Can I safely auto-fix a fulfillment with no tracking number?

No. A script cannot invent a real tracking number, so this is a flag and report action, not an automatic repair. The only legitimate fix is to attach a tracking number once support or ops has actually obtained it from the carrier or fulfillment provider, and that write should always be guarded behind a DRY_RUN flag and a human-supplied value.

How do I detect fulfillments that are shipped without tracking?

List orders with the Admin API and expand fields=id,display_id,email,*fulfillments,*fulfillments.labels, then check each fulfillment: it is shipped without tracking when shipped_at is set, canceled_at is not set, and labels is empty or every label has a blank tracking_number. List responses can omit nested labels unless you ask for them explicitly in fields, so always request that field.

Related field notes

Citations

On the problem:

  1. [Bug]: Labels from createFulfillment() Are Overwritten When Marking as Shipped. Medusa GitHub Issue #11160. github.com/medusajs/medusa/issues/11160
  2. fix: Ensure shipment includes tracking numbers from fulfillment. Medusa GitHub PR #11775. github.com/medusajs/medusa/pull/11775
  3. Manage Order Fulfillments in Medusa Admin. Medusa Admin User Guide. docs.medusajs.com/user-guide/orders/fulfillments

On the solution:

  1. Item Fulfillment Concepts: Fulfillment and FulfillmentLabel data models. Medusa Documentation. docs.medusajs.com/resources/commerce-modules/fulfillment/item-fulfillment
  2. createShipmentWorkflow. Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/createShipmentWorkflow
  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 help you trace a shipment that had gone dark?

If this saved you from a support ticket about a package nobody could trace, or from writing this 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