Reconciler Migration and housekeeping
Draft orders never completed
A sales rep starts a draft order for a phone quote, then the deal goes cold and the draft is left as it was. Someone spins up a test draft to check a workflow and never cleans it up. A quote gets built, sent, and never confirmed. None of these drafts ever get completed into a real order, and none of them ever go away on their own. Here is why Medusa v2 keeps every unfinished draft forever and a small script that finds the truly stale ones and reports them for cleanup, without deleting anything automatically.
In Medusa v2, a draft order is an order with is_draft_order true and status draft. It only leaves that state when someone completes it, which converts it into a real order. Medusa ships no default scheduled job for draft order retention, so nothing proactively expires, archives, or removes a draft that was started and never completed. Run a script that pages through GET /admin/draft-orders, keeps only drafts where the record is still a draft and created_at is older than a threshold such as 30 days, and writes a report. This is flag and report only. Deletion, if the team ever wants it, stays gated behind DRY_RUN and a human confirming the draft is truly dead. Full code, tests, and the report format are below.
The problem in plain words
When a team needs a manual order, for a phone quote, an email sale, or a workflow test, they create a draft order in the admin. In Medusa v2 that draft is not a throwaway object. It is a real order record with is_draft_order set to true and its status set to draft. It stays that way until someone completes it, which is the one event that converts it into a finished, real order.
That is the only exit. There is no clock running anywhere that says a draft older than some number of days should be closed out or removed. If the deal goes cold, or the test is forgotten, or the quote is never confirmed, the draft just sits there, exactly as it was left, forever. Multiply that by every quote that did not close, every test someone spun up while building a feature, and every half-built order that got abandoned mid-way, and the draft orders list grows without bound while almost none of that growth represents real, ongoing business.
Why it happens
A draft order is created on purpose, by a person, but it is only closed out by one specific event: completing it into a real order. A few common ways stores end up with a pile of these:
- A sales rep builds a draft for a phone or email quote, the customer stops responding, and the draft is left sitting in
draftstatus with no clear owner. - A developer or ops person creates a test draft while building or checking a workflow, confirms it works, and never goes back to remove the throwaway.
- A quote is built and sent, the customer picks a competitor or simply never confirms, and the draft that represented that deal is never completed and never cleaned up.
- The pile is compounded when nobody owns housekeeping. Because completing a draft is a manual admin action, there is no natural moment where a stale draft gets noticed, so old drafts quietly accumulate release after release.
Medusa ships no default scheduled job for draft order retention. Nothing in the framework is watching for drafts that go stale, so the responsibility sits entirely with whoever operates the store. See the citations at the end for the exact docs and reference.
A pile of old drafts is not automatically a problem you can solve by deleting rows. A draft order can still hold real intent, such as a quote a rep is quietly still negotiating, or a large order a customer plans to confirm next week. So the safe pattern is not "delete every draft older than N days." It is "find the drafts that are truly stale, still in draft and well past the threshold, and put them in front of a human first." Reporting comes before any write.
The fix, as a flow
We do not complete anything or delete anything by default. The job pages through draft orders, applies a pure classification function to each one, and writes a report of stale draft order ids with age, email, and total for manual review.
Build it step by step
Authenticate against the Admin API
Exchange the admin email and password for a JWT at /auth/user/emailpass, then send it as a Bearer token on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export MAX_AGE_DAYS="30"
export DRY_RUN="true" # start safe, change to false only if auto-delete is opted into
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 MAX_AGE_DAYS="30"
export DRY_RUN="true" // start safe, change to false only if auto-delete is opted into
List draft orders with the fields the decision needs
Ask for every draft order with status, is_draft_order, created_at, and the display and money fields you want in the report. Paginate with limit and offset. The response looks like {draft_orders:[...],count,offset,limit}. Because completing a draft converts it into a real order, anything this route returns is by definition still a draft.
import os, requests
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def list_draft_orders(token):
orders = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/draft-orders", {
"fields": "id,display_id,status,is_draft_order,email,customer_id,region_id,sales_channel_id,currency_code,total,created_at,updated_at",
"limit": limit,
"offset": offset,
})
orders.extend(data["draft_orders"])
offset += limit
if offset >= data["count"]:
return orders
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });
async function listDraftOrders() {
const orders = [];
let offset = 0;
const limit = 200;
while (true) {
const { draft_orders: page, count } = await sdk.admin.draftOrder.list({
fields: "id,display_id,status,is_draft_order,email,customer_id,region_id,sales_channel_id,currency_code,total,created_at,updated_at",
limit,
offset,
});
orders.push(...page);
offset += limit;
if (offset >= count) return orders;
}
}
Decide, with one pure function
Keep the classification in its own function that takes a plain order shape, the current time as epoch seconds, and the age threshold, and returns a plain answer. It never touches the network, so it is trivial to unit test with fixture data. Anything that is not a draft is skipped. A recent draft is left alone. Only a draft that is still a draft and was created longer ago than the threshold is flagged.
def is_stale_draft(order, now_epoch, max_age_days=30):
"""Pure decision function. No I/O.
order: {"id": str, "status": str, "is_draft_order": bool, "created_at": str}
now_epoch: float, current time in epoch seconds
max_age_days: int
Returns {"stale": bool, "reason": str}.
"""
is_draft = order.get("is_draft_order") is True or order.get("status") == "draft"
if not is_draft:
return {"stale": False, "reason": "not-a-draft"}
created = order.get("created_at")
if not created:
return {"stale": False, "reason": "no-created-at"}
age_days = (now_epoch - _parse_epoch(created)) / 86400
if age_days >= max_age_days:
return {"stale": True, "reason": f"draft-{int(age_days)}d-never-completed"}
return {"stale": False, "reason": "recent-draft"}
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, status: string, is_draft_order: boolean, created_at: string }} order
* @param {number} nowEpoch current time in epoch seconds
* @param {number} maxAgeDays
* @returns {{ stale: boolean, reason: string }}
*/
export function isStaleDraft(order, nowEpoch, maxAgeDays = 30) {
const isDraft = order.is_draft_order === true || order.status === "draft";
if (!isDraft) return { stale: false, reason: "not-a-draft" };
if (!order.created_at) return { stale: false, reason: "no-created-at" };
const createdEpoch = new Date(order.created_at).getTime() / 1000;
const ageDays = (nowEpoch - createdEpoch) / 86400;
if (ageDays >= maxAgeDays) return { stale: true, reason: `draft-${Math.floor(ageDays)}d-never-completed` };
return { stale: false, reason: "recent-draft" };
}
Parse the timestamp into epoch seconds
The pure function above compares two epoch numbers, which keeps it free of any timezone surprises. A tiny helper turns Medusa's ISO 8601 timestamp string into epoch seconds, treating a value with no zone as UTC.
from datetime import datetime, timezone
def _parse_epoch(value):
"""Parse an ISO 8601 timestamp string into epoch seconds (UTC)."""
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
// Date.parse understands ISO 8601 directly, including the trailing Z for UTC.
// Divide milliseconds by 1000 to get epoch seconds.
const createdEpoch = new Date(order.created_at).getTime() / 1000;
Shape a report row
For every draft classified as stale, write one row to the report: draft_order_id, display_id, email or customer_id, region_id, sales_channel_id, currency_code, total, and age_in_days. Remember Medusa money is a decimal (BigNumber), not cents, so the total is a plain decimal amount.
def order_total(order):
total = order.get("total")
if total is None:
return 0.0
return float(total)
def to_report_row(order, age_days):
return {
"draft_order_id": order["id"],
"display_id": order.get("display_id"),
"email": order.get("email") or order.get("customer_id"),
"region_id": order.get("region_id"),
"sales_channel_id": order.get("sales_channel_id"),
"currency_code": order.get("currency_code"),
"total": order_total(order),
"age_in_days": round(age_days, 1),
}
export function orderTotal(order) {
if (order.total === undefined || order.total === null) return 0;
return Number(order.total);
}
export function toReportRow(order, ageDays) {
return {
draft_order_id: order.id,
display_id: order.display_id,
email: order.email || order.customer_id,
region_id: order.region_id,
sales_channel_id: order.sales_channel_id,
currency_code: order.currency_code,
total: orderTotal(order),
age_in_days: Math.round(ageDays * 10) / 10,
};
}
Wire it together with a dry run guard
The loop ties every piece together. It always writes the report. It never deletes anything unless a team explicitly opts in later, and even then only for a draft a human has confirmed is truly dead, gated behind DRY_RUN, calling DELETE /admin/draft-orders/{id} from a scheduled job such as a daily cron, with every acted-on id logged for audit. Run the reporting job on a schedule that fits how fast your store accumulates drafts, for example once a day.
This script only ever writes a report by default. Deleting a draft order is destructive, since a draft can still hold real intent, such as a quote a rep is quietly negotiating or a large order a customer plans to confirm soon. If a team wants automated deletion, gate it behind DRY_RUN, confirm with a human that the draft is truly dead first, and log every id acted on.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and writes a report of stale draft orders. It never deletes a draft on its own, and is safe to run again and again because every run starts from the same read-only view of the draft orders list.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa draft orders that were created but never completed.
In Medusa v2 a draft order is an order with is_draft_order true and status "draft".
Completing it converts it into a real order. Nothing in the framework closes out a
draft order that a team started and then abandoned, so half-built quotes, test drafts,
and orders someone meant to finish later just sit in the store forever. This lists
draft orders, classifies each one with a pure function, and writes a report of the
stale ones (older than a threshold, still in draft) for manual review.
This is flag and report only. It never deletes a draft order on its own.
Run on a schedule. Safe to run again and again.
"""
import os
import json
import logging
import requests
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("report_stale_draft_orders")
BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
ADMIN_EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
ADMIN_PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
MAX_AGE_DAYS = int(os.environ.get("MAX_AGE_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPORT_PATH = os.environ.get("REPORT_PATH", "stale_draft_orders_report.json")
def get_admin_token():
r = requests.post(
f"{BACKEND_URL}/auth/user/emailpass",
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def admin_get(token, path, params=None):
r = requests.get(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
params=params or {},
timeout=30,
)
r.raise_for_status()
return r.json()
def admin_delete(token, path):
r = requests.delete(
f"{BACKEND_URL}{path}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def _parse_epoch(value):
"""Parse an ISO 8601 timestamp string into epoch seconds (UTC)."""
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
def is_stale_draft(order, now_epoch, max_age_days=30):
"""Pure decision function. No I/O.
order: {"id": str, "status": str, "is_draft_order": bool, "created_at": str}
now_epoch: float, current time in epoch seconds
max_age_days: int
Returns {"stale": bool, "reason": str}.
"""
is_draft = order.get("is_draft_order") is True or order.get("status") == "draft"
if not is_draft:
return {"stale": False, "reason": "not-a-draft"}
created = order.get("created_at")
if not created:
return {"stale": False, "reason": "no-created-at"}
age_days = (now_epoch - _parse_epoch(created)) / 86400
if age_days >= max_age_days:
return {"stale": True, "reason": f"draft-{int(age_days)}d-never-completed"}
return {"stale": False, "reason": "recent-draft"}
def order_total(order):
total = order.get("total")
if total is None:
return 0.0
return float(total)
def to_report_row(order, age_days):
return {
"draft_order_id": order["id"],
"display_id": order.get("display_id"),
"email": order.get("email") or order.get("customer_id"),
"region_id": order.get("region_id"),
"sales_channel_id": order.get("sales_channel_id"),
"currency_code": order.get("currency_code"),
"total": order_total(order),
"age_in_days": round(age_days, 1),
}
def list_draft_orders(token):
orders = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/draft-orders", {
"fields": "id,display_id,status,is_draft_order,email,customer_id,region_id,sales_channel_id,currency_code,total,created_at,updated_at",
"limit": limit,
"offset": offset,
})
orders.extend(data["draft_orders"])
offset += limit
if offset >= data["count"]:
return orders
def delete_draft_order(token, draft_order_id):
"""Not called by run(). Kept for a team that explicitly opts into automated
cleanup, gated behind DRY_RUN, only for a draft confirmed stale and reviewed.
"""
return admin_delete(token, f"/admin/draft-orders/{draft_order_id}")
def run():
token = get_admin_token()
drafts = list_draft_orders(token)
now_epoch = datetime.now(timezone.utc).timestamp()
report = []
for order in drafts:
shaped = {
"id": order["id"],
"status": order.get("status"),
"is_draft_order": order.get("is_draft_order"),
"created_at": order.get("created_at"),
}
outcome = is_stale_draft(shaped, now_epoch, MAX_AGE_DAYS)
if not outcome["stale"]:
continue
age_days = (now_epoch - _parse_epoch(shaped["created_at"])) / 86400
row = to_report_row(order, age_days)
report.append(row)
log.warning("Stale draft %s: %s, age=%.1fd, total=%s", row["draft_order_id"], outcome["reason"], age_days, row["total"])
with open(REPORT_PATH, "w") as f:
json.dump(report, f, indent=2)
log.info("Done. %d stale draft order(s) written to %s. %s", len(report), REPORT_PATH,
"(dry run, no deletes ever run from this script)" if DRY_RUN else "")
if __name__ == "__main__":
run()
/**
* Find Medusa draft orders that were created but never completed.
*
* In Medusa v2 a draft order is an order with is_draft_order true and status "draft".
* Completing it converts it into a real order. Nothing in the framework closes out a
* draft order that a team started and then abandoned, so half-built quotes, test drafts,
* and orders someone meant to finish later just sit in the store forever. This lists
* draft orders, classifies each one with a pure function, and writes a report of the
* stale ones (older than a threshold, still in draft) for manual review.
* This is flag and report only. It never deletes a draft order on its own.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/draft-orders-never-completed/
*/
import { pathToFileURL } from "node:url";
import { writeFile } from "node:fs/promises";
const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const MAX_AGE_DAYS = Number(process.env.MAX_AGE_DAYS || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPORT_PATH = process.env.REPORT_PATH || "stale_draft_orders_report.json";
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, status: string, is_draft_order: boolean, created_at: string }} order
* @param {number} nowEpoch current time in epoch seconds
* @param {number} maxAgeDays
* @returns {{ stale: boolean, reason: string }}
*/
export function isStaleDraft(order, nowEpoch, maxAgeDays = 30) {
const isDraft = order.is_draft_order === true || order.status === "draft";
if (!isDraft) return { stale: false, reason: "not-a-draft" };
if (!order.created_at) return { stale: false, reason: "no-created-at" };
const createdEpoch = new Date(order.created_at).getTime() / 1000;
const ageDays = (nowEpoch - createdEpoch) / 86400;
if (ageDays >= maxAgeDays) return { stale: true, reason: `draft-${Math.floor(ageDays)}d-never-completed` };
return { stale: false, reason: "recent-draft" };
}
export function orderTotal(order) {
if (order.total === undefined || order.total === null) return 0;
return Number(order.total);
}
export function toReportRow(order, ageDays) {
return {
draft_order_id: order.id,
display_id: order.display_id,
email: order.email || order.customer_id,
region_id: order.region_id,
sales_channel_id: order.sales_channel_id,
currency_code: order.currency_code,
total: orderTotal(order),
age_in_days: Math.round(ageDays * 10) / 10,
};
}
async function getAdminToken() {
const res = await fetch(`${BACKEND_URL}/auth/user/emailpass`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
const body = await res.json();
return body.token;
}
async function adminGet(token, path, params = {}) {
const url = new URL(`${BACKEND_URL}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status} on GET ${path}`);
return res.json();
}
async function adminDelete(token, path) {
const res = await fetch(`${BACKEND_URL}${path}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status} on DELETE ${path}`);
return res.json();
}
async function listDraftOrders(token) {
const orders = [];
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/draft-orders", {
fields: "id,display_id,status,is_draft_order,email,customer_id,region_id,sales_channel_id,currency_code,total,created_at,updated_at",
limit,
offset,
});
orders.push(...data.draft_orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
/**
* Not called by run(). Kept for a team that explicitly opts into automated
* cleanup, gated behind DRY_RUN, only for a draft confirmed stale and reviewed.
*/
async function deleteDraftOrder(token, draftOrderId) {
return adminDelete(token, `/admin/draft-orders/${draftOrderId}`);
}
export async function run() {
const token = await getAdminToken();
const drafts = await listDraftOrders(token);
const nowEpoch = Date.now() / 1000;
const report = [];
for (const order of drafts) {
const shaped = {
id: order.id,
status: order.status,
is_draft_order: order.is_draft_order,
created_at: order.created_at,
};
const outcome = isStaleDraft(shaped, nowEpoch, MAX_AGE_DAYS);
if (!outcome.stale) continue;
const ageDays = (nowEpoch - new Date(shaped.created_at).getTime() / 1000) / 86400;
const row = toReportRow(order, ageDays);
report.push(row);
console.warn(`Stale draft ${row.draft_order_id}: ${outcome.reason}, age=${ageDays.toFixed(1)}d, total=${row.total}`);
}
await writeFile(REPORT_PATH, JSON.stringify(report, null, 2));
console.log(`Done. ${report.length} stale draft order(s) written to ${REPORT_PATH}. ${DRY_RUN ? "(dry run, no deletes ever run from this script)" : ""}`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
is_stale_draft is the part most worth testing, because it decides which drafts land in front of a human. It is pure, so the test needs no network and no Medusa backend. It just feeds in plain data structures and a fixed clock, passed as epoch seconds, and checks the answer.
from datetime import datetime, timezone
from report_stale_draft_orders import is_stale_draft
# Fixed clock: 2026-07-10 00:00:00 UTC, as epoch seconds.
NOW = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc).timestamp()
def order(**over):
base = {
"id": "order_1",
"status": "draft",
"is_draft_order": True,
"created_at": "2026-06-01T00:00:00Z",
}
base.update(over)
return base
def test_stale_when_old_draft():
result = is_stale_draft(order(), NOW, 30)
assert result["stale"] is True
assert result["reason"].startswith("draft-")
def test_not_stale_when_not_a_draft():
result = is_stale_draft(order(status="completed", is_draft_order=False), NOW, 30)
assert result == {"stale": False, "reason": "not-a-draft"}
def test_not_stale_when_recent_draft():
result = is_stale_draft(order(created_at="2026-07-09T00:00:00Z"), NOW, 30)
assert result == {"stale": False, "reason": "recent-draft"}
def test_exactly_at_threshold_is_stale():
result = is_stale_draft(order(created_at="2026-06-10T00:00:00Z"), NOW, 30)
assert result["stale"] is True
def test_draft_recognized_by_status_alone():
result = is_stale_draft(order(is_draft_order=None), NOW, 30)
assert result["stale"] is True
def test_no_created_at_is_not_stale():
result = is_stale_draft(order(created_at=None), NOW, 30)
assert result == {"stale": False, "reason": "no-created-at"}
def test_not_a_draft_wins_even_when_old():
result = is_stale_draft(order(status="pending", is_draft_order=False, created_at="2026-01-01T00:00:00Z"), NOW, 30)
assert result == {"stale": False, "reason": "not-a-draft"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { isStaleDraft } from "./report-stale-draft-orders.js";
// Fixed clock: 2026-07-10 00:00:00 UTC, as epoch seconds.
const NOW = new Date("2026-07-10T00:00:00Z").getTime() / 1000;
const order = (over = {}) => ({
id: "order_1",
status: "draft",
is_draft_order: true,
created_at: "2026-06-01T00:00:00Z",
...over,
});
test("stale when old draft", () => {
const result = isStaleDraft(order(), NOW, 30);
assert.equal(result.stale, true);
assert.ok(result.reason.startsWith("draft-"));
});
test("not stale when not a draft", () => {
const result = isStaleDraft(order({ status: "completed", is_draft_order: false }), NOW, 30);
assert.deepEqual(result, { stale: false, reason: "not-a-draft" });
});
test("not stale when recent draft", () => {
const result = isStaleDraft(order({ created_at: "2026-07-09T00:00:00Z" }), NOW, 30);
assert.deepEqual(result, { stale: false, reason: "recent-draft" });
});
test("exactly at threshold is stale", () => {
const result = isStaleDraft(order({ created_at: "2026-06-10T00:00:00Z" }), NOW, 30);
assert.equal(result.stale, true);
});
test("draft recognized by status alone", () => {
const result = isStaleDraft(order({ is_draft_order: undefined }), NOW, 30);
assert.equal(result.stale, true);
});
test("no created_at is not stale", () => {
const result = isStaleDraft(order({ created_at: null }), NOW, 30);
assert.deepEqual(result, { stale: false, reason: "no-created-at" });
});
test("not a draft wins even when old", () => {
const result = isStaleDraft(order({ status: "pending", is_draft_order: false, created_at: "2026-01-01T00:00:00Z" }), NOW, 30);
assert.deepEqual(result, { stale: false, reason: "not-a-draft" });
});
Case studies
The B2B store buried under quotes that never closed
A store selling to businesses used draft orders as quotes. A rep would build a draft, share the total with a buyer, and wait. Over a year, most of those quotes never converted, and the draft orders list had grown into the thousands, making it hard to spot the handful of live deals still worth chasing.
Running the report each morning surfaced only the drafts older than 30 days and still sitting in draft. The sales team used that list to close out dead quotes and follow up on the few that were merely slow, and the draft orders view finally reflected real, active work again.
The test drafts nobody remembered creating
A team building a custom checkout flow created dozens of draft orders while testing, each one a throwaway they meant to remove. Months later, no one could tell which drafts were real quotes and which were leftover test data, so nobody dared touch any of them.
The report gave them a clear, dated list of every stale draft with its total and customer, so a person could scan it and mark the obvious test drafts for removal while leaving anything that looked like a genuine quote alone. Because the script only reports, the cleanup stayed a deliberate, human decision.
After this runs on a schedule, the team has a standing, accurate view of which draft orders are genuinely stale, still in draft and well past the threshold. Nothing gets removed without a human deciding to, and if the team later opts into automated cleanup, it only ever touches a draft already confirmed dead, one id at a time, with every action logged.
FAQ
Why do draft orders never disappear from my Medusa store?
In Medusa v2 a draft order is an order with is_draft_order true and status draft. It only leaves that state when someone completes it, which converts it into a real order. Medusa ships no default scheduled job that expires, archives, or removes a draft order that was started and never completed, so every abandoned quote, test draft, or order someone meant to finish later stays in the store indefinitely.
Is it safe to auto-delete stale Medusa draft orders with a script?
Treat it as flag and report first, not auto-delete. A draft order can hold real intent, such as a quote a sales rep is still negotiating, so removing it blindly is destructive. The safer pattern writes a report of stale draft order ids for manual review, and only calls DELETE on /admin/draft-orders/{id} after a human confirms the draft is truly dead, gated behind a DRY_RUN flag.
How do I find stale draft orders with the Medusa Admin API?
Page through GET /admin/draft-orders with fields for status, is_draft_order, and created_at. A draft counts as stale when it is still a draft, is_draft_order is true or status is draft, and it was created longer than your threshold, for example 30 days ago. Because completing a draft converts it into a real order, anything still returned by the draft-orders route is by definition not completed.
Related field notes
Citations
On the problem:
- Medusa Documentation: Draft Order Overview, Order Module. docs.medusajs.com/resources/commerce-modules/order/draft-order
- Medusa Documentation: Order Module. docs.medusajs.com/resources/commerce-modules/order
- Medusa Documentation: Manage Draft Orders, Admin User Guide. docs.medusajs.com/user-guide/orders/draft-orders
On the solution:
- Medusa Documentation: Scheduled Jobs. docs.medusajs.com/learn/fundamentals/scheduled-jobs
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
- Medusa Documentation: listDraftOrders, Order Module Reference. docs.medusajs.com/resources/references/order/listDraftOrders
Stuck on a tricky one?
If you have a problem in Medusa storefront access, pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clean up your draft orders?
If this saved you from a runaway draft orders list or a risky bulk delete, 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