Diagnostic Shipments
Shipment tracking number dropped when created via API
You call the ship endpoint with a carrier and a tracking number attached, Magento answers with a 200 and a brand new shipment ID, and everything looks fine. Then you open the shipment and the tracking number is not there. No error was ever raised. The customer gets a shipped email with nothing to track, and support has to go dig through the original request to figure out what carrier was even used. Here is why the tracks array can vanish between your request and the database, and a small script that finds every shipment this happened to and reports or repairs it safely.
When you POST /V1/order/{orderId}/ship with a tracks array, Magento's ShipOrder service builds the shipment and calls $shipment->setTracks($tracksData), which only assigns plain track data to the model. It never adds entries to the shipment's internal tracks collection. Magento\Sales\Model\ResourceModel\Order\Shipment\Relation::processRelation(), which runs on save, persists tracks by iterating $shipment->getTracksCollection(), and since setTracks() never touched that collection, the track rows are silently never written to sales_shipment_track even though the shipment itself saves and returns a shipment ID. This is a documented core defect, not user error. A script can list shipments through GET /rest/V1/shipments, check each one's tracks array against its items array over GET /rest/V1/shipment/{id}, and either report the gap or, when the original track data is known, repair it with the dedicated POST /rest/V1/shipment/track endpoint. Full code, tests, and a dry run guard are below.
The problem in plain words
When you create a shipment through the Magento admin UI, tracking works because the admin form calls addTrack() for each carrier row, and addTrack() does two things: it sets the track data on the model, and it adds that track object to the shipment's tracks collection. The collection is what actually gets iterated and saved to the database.
The REST API's ship endpoint does not go through that same path. When you call POST /V1/order/{orderId}/ship with a tracks array in the body, Magento's ShipOrder service builds a plain array of track data from your payload and calls $shipment->setTracks($tracksData) on the shipment model. setTracks() looks like it should do the same job as addTrack(), but it only stores the array as a property on the model object. It never touches getTracksCollection(). When the shipment is saved, Magento\Sales\Model\ResourceModel\Order\Shipment\Relation::processRelation() runs and writes rows to sales_shipment_track by walking the tracks collection, and finds it empty. Nothing errors. The shipment saves, the order moves to shipped or complete, and you get back a 200 with a shipment ID. The tracking number you sent in the same request is gone.
Why it happens
- Calling
POST /V1/order/{orderId}/shipwith the tracking number inlined in the same request as the ship call, which routes throughShipOrderand its use ofsetTracks()instead ofaddTrack(). - An integration or custom module that builds a
ShipmentTrackCreationInterfacearray and hands it to the ship service the same way the REST controller does, inheriting the same defect. - A carrier or fulfillment webhook that ships the order and tracking together in one call because that matches how the admin form appears to work, without knowing the two code paths diverge.
- Reproduces across Magento 2.1.x through 2.3.x whenever tracks are passed inline on the ship call rather than added through a separate track-add call after the shipment exists.
This is a documented core defect in Magento's own issue tracker: magento/magento2#13954, tracks not saved during shipment creation through the API, and magento/magento2#13248, unable to update tracking number, carrier code and title using the REST API. See the citations at the end for the exact threads.
An empty tracks array on a shipment is not always a bug. Plenty of shipments legitimately have no tracking, such as local pickup or a manual delivery. The signature worth acting on is an empty tracks array on a shipment that also has a non-empty items array and was created against an order that a carrier was supposed to ship. Because the original tracking number is not stored anywhere once setTracks() drops it, a script cannot invent one. It can only report the gap, or repair it when you separately have the track data that was meant to be attached, for example from the original request payload if it was logged, or from the carrier's own confirmation.
The fix, as a flow
We do not touch the ship endpoint. We add a job that lists shipments, checks each one's tracks against its items, and reports every shipment that has line items but no tracking. Only when expected track data is available, and only behind a DRY_RUN=false guard, does it call the dedicated track-add endpoint that actually persists a track row, then re-verifies the fix.
Build it step by step
Get an admin bearer token
Authenticate the way any Magento REST client does. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true" # start safe, change to false to allow the repair path
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export DRY_RUN="true" // start safe, change to false to allow the repair path
Talk to the Magento REST API
Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps GET and POST and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_post(path, payload):
r = requests.post(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPost(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
List shipments and check each one's tracks
Search shipments with GET /rest/V1/shipments, optionally filtered by order_id, then fetch each one's full record with GET /rest/V1/shipment/{id} to read back items and tracks. A shipment with line items but an empty tracks array, on an order you know was shipped with a carrier, is the signature to look for.
def shipments_for_order(order_id):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "order_id",
"searchCriteria[filterGroups][0][filters][0][value]": order_id,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
return magento_get("/shipments", params)["items"]
def shipment_detail(shipment_id):
return magento_get(f"/shipment/{shipment_id}")
async function shipmentsForOrder(orderId) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "order_id",
"searchCriteria[filterGroups][0][filters][0][value]": orderId,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/shipments", params);
return data.items;
}
async function shipmentDetail(shipmentId) {
return magentoGet(`/shipment/${shipmentId}`);
}
Decide, with one pure function
Keep the decision in its own function that takes plain shipment data and an optional expected track and returns a plain verdict. A pure function like this is easy to read and easy to test, which we do later. A shipment with no items is not a real shipment to worry about. A shipment that already has tracks is fine as is. A shipment with items, no tracks, and no known track data gets flagged for a human. Only a shipment with items, no tracks, and a known expected track is safe to repair.
def decide_track_repair(shipment, expected_track):
if len(shipment.get("items") or []) == 0:
return {"action": "skip_no_items", "reason": "shipment has no line items, not a real shipment"}
if len(shipment.get("tracks") or []) > 0:
return {"action": "skip_has_tracks", "reason": "shipment already has tracking, nothing to do"}
if expected_track is None:
return {"action": "flag_missing_track", "reason": "no source of truth track data available, report only"}
return {"action": "repair_add_track", "reason": "safe to POST /V1/shipment/track with the expected track"}
export function decideTrackRepair(shipment, expectedTrack) {
if ((shipment.items || []).length === 0) {
return { action: "skip_no_items", reason: "shipment has no line items, not a real shipment" };
}
if ((shipment.tracks || []).length > 0) {
return { action: "skip_has_tracks", reason: "shipment already has tracking, nothing to do" };
}
if (expectedTrack === null || expectedTrack === undefined) {
return { action: "flag_missing_track", reason: "no source of truth track data available, report only" };
}
return { action: "repair_add_track", reason: "safe to POST /V1/shipment/track with the expected track" };
}
Repair through the dedicated track-add endpoint, not the ship call
The broken path is the inline tracks array on the ship call. The working path is the dedicated endpoint that Magento uses to add a single track after the shipment exists: POST /rest/V1/shipment/track with an entity body carrying order_id, parent_id (the shipment ID), track_number, title, and carrier_code. It persists through salesShipmentTrackRepositoryV1, which correctly writes to sales_shipment_track.
def add_shipment_track(order_id, shipment_id, expected_track):
payload = {
"entity": {
"order_id": order_id,
"parent_id": shipment_id,
"track_number": expected_track["trackNumber"],
"title": expected_track["title"],
"carrier_code": expected_track["carrierCode"],
}
}
return magento_post("/shipment/track", payload)
async function addShipmentTrack(orderId, shipmentId, expectedTrack) {
const payload = {
entity: {
order_id: orderId,
parent_id: shipmentId,
track_number: expectedTrack.trackNumber,
title: expectedTrack.title,
carrier_code: expectedTrack.carrierCode,
},
};
return magentoPost("/shipment/track", payload);
}
Report by default, repair and re-verify only when gated
The default output lists every shipment with items but no tracks, whether or not an expected track is known. Only when DRY_RUN is false and an expected track exists does the script call POST /rest/V1/shipment/track, then call GET /rest/V1/shipment/{shipmentId} again to confirm tracks is now non-empty before moving on.
Always start with DRY_RUN=true. Never blind-write a tracking number the script guessed. A dropped track is only safe to repair when you have the original track data from the ship request that was logged, or from the carrier's own confirmation, and the script should re-verify the fix rather than assume the POST worked.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, lists shipments, checks items against tracks, respects the dry run flag, and is safe to run again and again because by default it only reports.
"""Detect and safely repair Magento 2 shipments with a dropped tracking number.
POST /V1/order/{orderId}/ship with an inline tracks array routes through
ShipOrder, which calls shipment.setTracks(tracksData). setTracks() only
assigns plain data to the model and never populates the shipment's tracks
collection, so Relation::processRelation(), which persists tracks by
iterating that collection on save, writes nothing to sales_shipment_track.
The shipment still saves and returns a 200 with a new shipment ID, so the
drop is silent. This is a documented core defect (magento/magento2#13954,
#13248), not user error.
This script reports every shipment with items but no tracks by default, and
only repairs it when you separately supply the expected track data, using
the dedicated POST /rest/V1/shipment/track endpoint rather than retrying the
broken ship call. 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("detect_dropped_tracking")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_post(path, payload):
r = requests.post(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload,
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def shipments_for_order(order_id):
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "order_id",
"searchCriteria[filterGroups][0][filters][0][value]": order_id,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
}
return magento_get("/shipments", params)["items"]
def shipment_detail(shipment_id):
return magento_get(f"/shipment/{shipment_id}")
def add_shipment_track(order_id, shipment_id, expected_track):
payload = {
"entity": {
"order_id": order_id,
"parent_id": shipment_id,
"track_number": expected_track["trackNumber"],
"title": expected_track["title"],
"carrier_code": expected_track["carrierCode"],
}
}
return magento_post("/shipment/track", payload)
def decide_track_repair(shipment, expected_track):
if len(shipment.get("items") or []) == 0:
return {"action": "skip_no_items", "reason": "shipment has no line items, not a real shipment"}
if len(shipment.get("tracks") or []) > 0:
return {"action": "skip_has_tracks", "reason": "shipment already has tracking, nothing to do"}
if expected_track is None:
return {"action": "flag_missing_track", "reason": "no source of truth track data available, report only"}
return {"action": "repair_add_track", "reason": "safe to POST /V1/shipment/track with the expected track"}
def to_plain_shipment(raw):
return {
"id": raw["entity_id"],
"orderId": raw.get("order_id"),
"items": raw.get("items") or [],
"tracks": raw.get("tracks") or [],
}
def run(order_id, expected_tracks_by_shipment_id=None):
"""expected_tracks_by_shipment_id maps shipment id -> {trackNumber, title, carrierCode},
typically loaded from a log of the original ship request or a carrier confirmation.
"""
expected_tracks_by_shipment_id = expected_tracks_by_shipment_id or {}
flagged = 0
repaired = 0
for raw in shipments_for_order(order_id):
detail = shipment_detail(raw["entity_id"])
shipment = to_plain_shipment(detail)
expected_track = expected_tracks_by_shipment_id.get(shipment["id"])
result = decide_track_repair(shipment, expected_track)
if result["action"] in ("skip_no_items", "skip_has_tracks"):
continue
if result["action"] == "flag_missing_track":
flagged += 1
log.warning("Shipment %s has items but no tracking, and no expected track data. %s",
shipment["id"], result["reason"])
continue
flagged += 1
log.warning("Shipment %s has items but no tracking. %s",
shipment["id"], "would add track" if DRY_RUN else "adding track")
if not DRY_RUN:
add_shipment_track(shipment["orderId"], shipment["id"], expected_track)
verified = to_plain_shipment(shipment_detail(shipment["id"]))
if len(verified["tracks"]) > 0:
repaired += 1
log.info("Shipment %s verified: tracks now non-empty.", shipment["id"])
else:
log.error("Shipment %s still has no tracks after POST /V1/shipment/track.", shipment["id"])
log.info("Done. %d shipment(s) flagged, %d repaired.", flagged, repaired)
if __name__ == "__main__":
run(order_id=os.environ.get("ORDER_ID", ""))
/**
* Detect and safely repair Magento 2 shipments with a dropped tracking number.
*
* POST /V1/order/{orderId}/ship with an inline tracks array routes through
* ShipOrder, which calls shipment.setTracks(tracksData). setTracks() only
* assigns plain data to the model and never populates the shipment's tracks
* collection, so Relation::processRelation(), which persists tracks by
* iterating that collection on save, writes nothing to sales_shipment_track.
* The shipment still saves and returns a 200 with a new shipment ID, so the
* drop is silent. This is a documented core defect (magento/magento2#13954,
* #13248), not user error.
*
* This script reports every shipment with items but no tracks by default,
* and only repairs it when you separately supply the expected track data,
* using the dedicated POST /rest/V1/shipment/track endpoint rather than
* retrying the broken ship call. Run on a schedule. Safe to run again and
* again.
*
* Guide: https://www.allanninal.dev/magento/shipment-tracking-dropped-via-api/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function decideTrackRepair(shipment, expectedTrack) {
if ((shipment.items || []).length === 0) {
return { action: "skip_no_items", reason: "shipment has no line items, not a real shipment" };
}
if ((shipment.tracks || []).length > 0) {
return { action: "skip_has_tracks", reason: "shipment already has tracking, nothing to do" };
}
if (expectedTrack === null || expectedTrack === undefined) {
return { action: "flag_missing_track", reason: "no source of truth track data available, report only" };
}
return { action: "repair_add_track", reason: "safe to POST /V1/shipment/track with the expected track" };
}
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPost(path, payload) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function shipmentsForOrder(orderId) {
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "order_id",
"searchCriteria[filterGroups][0][filters][0][value]": orderId,
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
};
const data = await magentoGet("/shipments", params);
return data.items;
}
async function shipmentDetail(shipmentId) {
return magentoGet(`/shipment/${shipmentId}`);
}
async function addShipmentTrack(orderId, shipmentId, expectedTrack) {
const payload = {
entity: {
order_id: orderId,
parent_id: shipmentId,
track_number: expectedTrack.trackNumber,
title: expectedTrack.title,
carrier_code: expectedTrack.carrierCode,
},
};
return magentoPost("/shipment/track", payload);
}
function toPlainShipment(raw) {
return {
id: raw.entity_id,
orderId: raw.order_id,
items: raw.items || [],
tracks: raw.tracks || [],
};
}
/**
* expectedTracksByShipmentId maps shipment id -> {trackNumber, title, carrierCode},
* typically loaded from a log of the original ship request or a carrier confirmation.
*/
export async function run(orderId, expectedTracksByShipmentId = {}) {
let flagged = 0;
let repaired = 0;
const rawShipments = await shipmentsForOrder(orderId);
for (const raw of rawShipments) {
const detail = await shipmentDetail(raw.entity_id);
const shipment = toPlainShipment(detail);
const expectedTrack = expectedTracksByShipmentId[shipment.id];
const result = decideTrackRepair(shipment, expectedTrack);
if (result.action === "skip_no_items" || result.action === "skip_has_tracks") continue;
if (result.action === "flag_missing_track") {
flagged++;
console.warn(`Shipment ${shipment.id} has items but no tracking, and no expected track data. ${result.reason}`);
continue;
}
flagged++;
console.warn(`Shipment ${shipment.id} has items but no tracking. ${DRY_RUN ? "would add track" : "adding track"}`);
if (!DRY_RUN) {
await addShipmentTrack(shipment.orderId, shipment.id, expectedTrack);
const verified = toPlainShipment(await shipmentDetail(shipment.id));
if (verified.tracks.length > 0) {
repaired++;
console.log(`Shipment ${shipment.id} verified: tracks now non-empty.`);
} else {
console.error(`Shipment ${shipment.id} still has no tracks after POST /V1/shipment/track.`);
}
}
}
console.log(`Done. ${flagged} shipment(s) flagged, ${repaired} repaired.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run(process.env.ORDER_ID || "").catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a shipment gets a blind write, a safe repair, or a report only flag. Because we kept decide_track_repair pure, the test needs no network, no Magento store, and no admin token. It just feeds in plain fixture objects and checks the answer.
from detect_dropped_tracking import decide_track_repair
def shipment(**over):
base = {"id": "77", "items": [{"sku": "ABC-1", "qty": 1}], "tracks": []}
base.update(over)
return base
EXPECTED_TRACK = {"trackNumber": "1Z999AA10123456784", "title": "UPS", "carrierCode": "ups"}
def test_skip_no_items_when_shipment_has_no_line_items():
result = decide_track_repair(shipment(items=[]), EXPECTED_TRACK)
assert result["action"] == "skip_no_items"
def test_skip_has_tracks_when_tracks_already_present():
result = decide_track_repair(shipment(tracks=[{"trackNumber": "123"}]), EXPECTED_TRACK)
assert result["action"] == "skip_has_tracks"
def test_flag_missing_track_when_no_expected_track_known():
result = decide_track_repair(shipment(), None)
assert result["action"] == "flag_missing_track"
def test_repair_add_track_when_items_no_tracks_and_expected_known():
result = decide_track_repair(shipment(), EXPECTED_TRACK)
assert result["action"] == "repair_add_track"
def test_no_items_wins_over_missing_expected_track():
result = decide_track_repair(shipment(items=[]), None)
assert result["action"] == "skip_no_items"
def test_has_tracks_wins_over_missing_expected_track():
result = decide_track_repair(shipment(tracks=[{"trackNumber": "123"}]), None)
assert result["action"] == "skip_has_tracks"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideTrackRepair } from "./detect-dropped-tracking.js";
const shipment = (over = {}) => ({
id: "77",
items: [{ sku: "ABC-1", qty: 1 }],
tracks: [],
...over,
});
const EXPECTED_TRACK = { trackNumber: "1Z999AA10123456784", title: "UPS", carrierCode: "ups" };
test("skip no items when shipment has no line items", () => {
const result = decideTrackRepair(shipment({ items: [] }), EXPECTED_TRACK);
assert.equal(result.action, "skip_no_items");
});
test("skip has tracks when tracks already present", () => {
const result = decideTrackRepair(shipment({ tracks: [{ trackNumber: "123" }] }), EXPECTED_TRACK);
assert.equal(result.action, "skip_has_tracks");
});
test("flag missing track when no expected track known", () => {
const result = decideTrackRepair(shipment(), null);
assert.equal(result.action, "flag_missing_track");
});
test("repair add track when items, no tracks, and expected known", () => {
const result = decideTrackRepair(shipment(), EXPECTED_TRACK);
assert.equal(result.action, "repair_add_track");
});
test("no items wins over missing expected track", () => {
const result = decideTrackRepair(shipment({ items: [] }), null);
assert.equal(result.action, "skip_no_items");
});
test("has tracks wins over missing expected track", () => {
const result = decideTrackRepair(shipment({ tracks: [{ trackNumber: "123" }] }), null);
assert.equal(result.action, "skip_has_tracks");
});
Case studies
The warehouse system that shipped orders with silent gaps
A mid sized retailer's warehouse management system called POST /V1/order/{orderId}/ship with the carrier and tracking number attached the moment a package left the dock, exactly as the endpoint's shape suggested it should work. For months the integration reported success on every call, and support only found out something was wrong when customers started asking why their shipped email had no tracking link.
Running the detection job against a week of shipments turned up dozens with items but empty tracks arrays, all created the same way. Because the warehouse system still had the original tracking numbers in its own database, the team fed those into the repair path, which used POST /rest/V1/shipment/track and re-verified each one, and then split the warehouse system's ship and track-add calls going forward so the gap never came back.
The carrier app that only found out from a support ticket
A dropshipping operation's carrier integration app shipped orders in bulk overnight, always inlining the tracks array on the ship call for speed. Nothing in the app's logs or Magento's own logs showed an error, since the API call genuinely succeeded and returned a shipment ID every time.
A single customer complaint led the team to run the script in dry run first, which flagged every shipment from the past month with no tracking, letting them see exactly how large the backlog was before doing anything. Because the original tracking numbers were not stored anywhere once setTracks() dropped them, most of the older shipments could only be flagged for manual follow up with the carrier, while new shipments going forward were fixed by pairing the ship call with a separate track-add call.
After this runs on a schedule, a shipment that saved without its tracking number is caught within one detection cycle instead of surfacing only when a customer asks where their package is. Shipments with a known expected track get repaired through the endpoint that actually persists a track row, and the fix is re-verified rather than assumed. Everything else is reported with enough detail, the shipment ID, the order, and whether the source track data exists, for a human to close the gap with confidence.
FAQ
Why does my Magento shipment have no tracking number even though the ship call succeeded?
When you call POST /V1/order/{orderId}/ship with a tracks array, Magento's ShipOrder service calls shipment.setTracks(tracksData), which only assigns plain track data to the model and never adds entries to the shipment's internal tracks collection. Magento\Sales\Model\ResourceModel\Order\Shipment\Relation::processRelation() persists tracks by iterating that collection on save, so the track rows are silently never written to sales_shipment_track even though the shipment itself saves and returns a 200 with a new shipment ID. It is a documented core defect, not user error.
How do I detect shipments with a dropped tracking number?
Call GET /rest/V1/shipment/{shipmentId}, or search with GET /rest/V1/shipments filtered by order_id, and inspect the response's tracks array. A shipment whose items array is non-empty but whose tracks array is empty, despite the order having been shipped with a carrier, is the signature of a dropped tracking number. Checking items first rules out shipments that legitimately never had tracking attached.
Is it safe to have a script automatically add the missing tracking number?
Only when you have the original track data to re-apply, such as the tracks payload from the initial ship request or the carrier confirmation. The safe repair is not to retry the broken ship call, but to call POST /rest/V1/shipment/track directly with the order_id, parent_id, track_number, title, and carrier_code, which persists through salesShipmentTrackRepositoryV1 and correctly writes to sales_shipment_track. When no source of truth for the track data exists, the script only flags the shipment for a human to check, guarded by DRY_RUN.
Related field notes
Citations
On the problem:
- GitHub Issue: track not saved during shipment creation through the API. github.com/magento/magento2/issues/13954
- GitHub Issue: unable to update tracking number, carrier code and title using the Magento REST API. github.com/magento/magento2/issues/13248
- GitHub Issue: could not save shipment data. github.com/magento/devdocs/issues/527
On the solution:
- Adobe Commerce/Magento REST API: order shipment tutorial. devdocs.magento.com/guides/v2.3/rest/tutorials/orders/order-intro.html
- Adobe Commerce Web API: REST reference guide. developer.adobe.com/commerce/webapi/rest
- Adobe Commerce/Magento REST API reference, Swagger. adobe-commerce.redoc.ly
Stuck on a tricky one?
If you have a problem in Magento 2 or Adobe Commerce orders, shipments, catalog data, or inventory that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clear your missing tracking numbers?
If this saved you a confused support ticket or a customer who could not track their package, 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