Reconciler Cart and checkout
Abandoned carts pile up
A shopper starts a cart, adds a few items, then never comes back. A bot crawls the storefront and triggers an empty cart on every visit. A flaky network makes a checkout retry and leaves a duplicate cart behind. None of these carts ever convert into an order, and none of them ever go away on their own. Here is why Medusa v2 keeps every one of them forever and a small script that finds the truly stale ones and reports them for review, without deleting anything automatically.
In Medusa v2, a cart is a first-class, persistent record in the Cart Module. It is created as soon as a shopper's session needs one, and it is only marked complete by setting completed_at when it converts into an order. Medusa ships no default scheduled job for cart retention, so nothing proactively expires, archives, or deletes a cart that never reaches checkout. Run a script that pages through GET /admin/carts, keeps only carts where completed_at is null, at least one line item exists, and updated_at is older than a stale window such as 30 days, cross-checks each flagged cart_id against /admin/orders, and writes a report. This is flag and report only. Deletion, if the team ever wants it, stays gated behind DRY_RUN and a confirmed no-order check. Full code, tests, and the report format are below.
The problem in plain words
When a shopper's storefront session needs a cart, Medusa creates one right away in the Cart Module, before anything is even added to it. That cart is a normal, durable database row from the moment it exists. It stays that way until it is marked complete, which only happens by setting completed_at when the cart successfully converts into an order.
That is the only exit. There is no clock running anywhere that says a cart older than some number of days should be closed out. If the shopper never finishes checkout, the cart just sits there, exactly as it was left, forever. Multiply that by every abandoned session, every bot that triggers an empty cart by visiting the storefront, and every duplicate cart created when a shaky connection makes a checkout retry, and the carts table grows without bound while almost none of that growth represents real, ongoing business.
Why it happens
A cart is created optimistically, as soon as a session might need one, but it is only closed out by one specific event: converting into an order. A few common ways stores end up with a pile of these:
- A shopper adds items, gets distracted or changes their mind, and never returns to finish checkout, so the cart sits at whatever state it was left in.
- A search engine crawler or a bot hits the storefront and triggers cart creation on the first request, leaving an empty cart with no items and no shopper behind it.
- A flaky network or a double click on the checkout button causes a retry that creates a second cart for the same intended purchase, so one real checkout leaves a duplicate cart behind.
- The gap is compounded when the deployment's event bus and scheduled job runner are not backed by the Redis modules. Even a custom cleanup job that depends on events or workflows can silently fail to run across restarts, so the one piece of automation a team did add quietly stops working.
Medusa ships no default scheduled job for cart retention. Nothing in the framework is watching for carts 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 carts is not automatically a problem you can solve by deleting rows. A cart_id can still be referenced by an in-flight payment session, or by an order that has not finished syncing back to the cart yet. So the safe pattern is not "delete every cart older than N days." It is "find the carts that are truly stale, with items, no completion, and no linked order, and put them in front of a human first." Reporting comes before any write.
The fix, as a flow
We do not touch checkout or delete anything by default. The job pages through carts, applies a pure classification function to each one, cross-checks anything flagged against orders as a belt-and-suspenders check, and writes a report of stale cart_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 STALE_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 STALE_DAYS="30"
export DRY_RUN="true" // start safe, change to false only if auto-delete is opted into
List carts with the fields the decision needs
Ask for every cart with completed_at, created_at, updated_at, and the items relation. Paginate with limit and offset, since a live store can carry a very large carts table. Not every installed version exposes a server-side date filter on this route, so the script over-fetches and filters locally to stay correct across versions.
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_carts(token):
carts = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/carts", {
"fields": "id,email,customer_id,region_id,sales_channel_id,completed_at,created_at,updated_at,*items",
"limit": limit,
"offset": offset,
})
carts.extend(data["carts"])
offset += limit
if offset >= data["count"]:
return carts
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({ baseUrl: process.env.MEDUSA_BACKEND_URL, auth: { type: "jwt" } });
async function listCarts() {
const carts = [];
let offset = 0;
const limit = 200;
while (true) {
const { carts: page, count } = await sdk.admin.cart.list({
fields: "id,email,customer_id,region_id,sales_channel_id,completed_at,created_at,updated_at,*items",
limit,
offset,
});
carts.push(...page);
offset += limit;
if (offset >= count) return carts;
}
}
Cross-check flagged carts against orders
Belt and suspenders, in case completed_at was not set due to a race or a missed webhook. Pull orders with their cart_id and build a set of every cart_id that truly converted, so the report never lists a cart that actually completed.
def completed_cart_ids(token):
"""Returns the set of cart_id values that have a matching order."""
cart_ids = set()
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,cart_id",
"limit": limit,
"offset": offset,
})
for order in data["orders"]:
if order.get("cart_id"):
cart_ids.add(order["cart_id"])
offset += limit
if offset >= data["count"]:
return cart_ids
async function completedCartIds() {
const cartIds = new Set();
let offset = 0;
const limit = 200;
while (true) {
const { orders, count } = await sdk.admin.order.list({
fields: "id,cart_id",
limit,
offset,
});
for (const order of orders) {
if (order.cart_id) cartIds.add(order.cart_id);
}
offset += limit;
if (offset >= count) return cartIds;
}
}
Decide, with one pure function
Keep the classification in its own function that takes a plain cart shape, the current time, and the stale window, and returns a plain answer. It never touches the network, so it is trivial to unit test with fixture data. A cart that already completed is never stale. An empty cart, most likely a bot or a crawler, is never treated as abandoned. Only a cart with items that has gone quiet longer than the stale window is flagged.
def classify_stale_cart(cart, now, stale_days=30):
"""Pure decision function. No I/O.
cart: {"id": str, "completed_at": str | None, "updated_at": str, "item_count": int}
now: datetime
stale_days: int
Returns {"stale": bool, "reason": str}.
"""
if cart.get("completed_at"):
return {"stale": False, "reason": "completed"}
age_days = (now - _parse_iso(cart["updated_at"])).total_seconds() / 86400
if cart.get("item_count", 0) == 0:
return {"stale": False, "reason": "empty-cart-not-abandoned"}
if age_days >= stale_days:
return {"stale": True, "reason": f"inactive-{int(age_days)}d-with-items"}
return {"stale": False, "reason": "recent"}
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, completed_at: string | null, updated_at: string, item_count: number }} cart
* @param {Date} now
* @param {number} staleDays
* @returns {{ stale: boolean, reason: string }}
*/
export function classifyStaleCart(cart, now, staleDays = 30) {
if (cart.completed_at) return { stale: false, reason: "completed" };
const ageMs = now.getTime() - new Date(cart.updated_at).getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (cart.item_count === 0) return { stale: false, reason: "empty-cart-not-abandoned" };
if (ageDays >= staleDays) return { stale: true, reason: `inactive-${Math.floor(ageDays)}d-with-items` };
return { stale: false, reason: "recent" };
}
Write the report, do not delete
For every cart classified as stale, and not found in the completed set from step 3, write one row to the report: cart_id, email or customer_id, region_id, sales_channel_id, item_count, cart_total, and age_in_days. This is the entire output by default. Nothing gets deleted.
def cart_total(cart):
items = cart.get("items") or []
return sum(float(i.get("unit_price", 0)) * float(i.get("quantity", 0)) for i in items)
def to_report_row(cart, age_days):
items = cart.get("items") or []
return {
"cart_id": cart["id"],
"email": cart.get("email") or cart.get("customer_id"),
"region_id": cart.get("region_id"),
"sales_channel_id": cart.get("sales_channel_id"),
"item_count": len(items),
"cart_total": cart_total(cart),
"age_in_days": round(age_days, 1),
}
function cartTotal(cart) {
const items = cart.items || [];
return items.reduce((sum, i) => sum + Number(i.unit_price || 0) * Number(i.quantity || 0), 0);
}
function toReportRow(cart, ageDays) {
const items = cart.items || [];
return {
cart_id: cart.id,
email: cart.email || cart.customer_id,
region_id: cart.region_id,
sales_channel_id: cart.sales_channel_id,
item_count: items.length,
cart_total: cartTotal(cart),
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 cart confirmed to have no linked order, gated behind DRY_RUN, calling DELETE /admin/carts/{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 carts, for example once a day.
This script only ever writes a report by default. Deleting a customer's in-progress cart is destructive, since a cart_id may still be referenced by an in-flight payment session or an order that has not synced back yet. If a team wants automated deletion, gate it behind DRY_RUN, confirm no order is linked 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 carts. It never deletes a cart on its own, and is safe to run again and again because every run starts from the same read-only view of the carts table.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa carts that piled up because they never converted to an order.
A Medusa v2 cart is a first-class, persistent record in the Cart Module, created as
soon as a shopper's session needs one and only marked complete by setting completed_at
when it converts into an order. Medusa ships no default scheduled job for cart
retention, so nothing expires, archives, or deletes a cart that never reaches checkout.
This lists carts, classifies each one with a pure function, cross-checks anything
flagged against real orders, and writes a report of stale cart_ids for manual review.
This is flag and report only. It never deletes a cart 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_carts")
BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
STALE_DAYS = int(os.environ.get("STALE_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPORT_PATH = os.environ.get("REPORT_PATH", "stale_carts_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_iso(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def classify_stale_cart(cart, now, stale_days=30):
"""Pure decision function. No I/O.
cart: {"id": str, "completed_at": str | None, "updated_at": str, "item_count": int}
now: datetime
stale_days: int
Returns {"stale": bool, "reason": str}.
"""
if cart.get("completed_at"):
return {"stale": False, "reason": "completed"}
age_days = (now - _parse_iso(cart["updated_at"])).total_seconds() / 86400
if cart.get("item_count", 0) == 0:
return {"stale": False, "reason": "empty-cart-not-abandoned"}
if age_days >= stale_days:
return {"stale": True, "reason": f"inactive-{int(age_days)}d-with-items"}
return {"stale": False, "reason": "recent"}
def cart_total(cart):
items = cart.get("items") or []
return sum(float(i.get("unit_price", 0)) * float(i.get("quantity", 0)) for i in items)
def to_report_row(cart, age_days):
items = cart.get("items") or []
return {
"cart_id": cart["id"],
"email": cart.get("email") or cart.get("customer_id"),
"region_id": cart.get("region_id"),
"sales_channel_id": cart.get("sales_channel_id"),
"item_count": len(items),
"cart_total": cart_total(cart),
"age_in_days": round(age_days, 1),
}
def list_carts(token):
carts = []
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/carts", {
"fields": "id,email,customer_id,region_id,sales_channel_id,completed_at,created_at,updated_at,*items",
"limit": limit,
"offset": offset,
})
carts.extend(data["carts"])
offset += limit
if offset >= data["count"]:
return carts
def completed_cart_ids(token):
"""Returns the set of cart_id values that have a matching order."""
cart_ids = set()
offset = 0
limit = 200
while True:
data = admin_get(token, "/admin/orders", {
"fields": "id,cart_id",
"limit": limit,
"offset": offset,
})
for order in data["orders"]:
if order.get("cart_id"):
cart_ids.add(order["cart_id"])
offset += limit
if offset >= data["count"]:
return cart_ids
def run():
token = get_admin_token()
carts = list_carts(token)
confirmed_orders = completed_cart_ids(token)
now = datetime.now(timezone.utc)
report = []
for cart in carts:
items = cart.get("items") or []
shaped = {
"id": cart["id"],
"completed_at": cart.get("completed_at"),
"updated_at": cart.get("updated_at") or cart.get("created_at"),
"item_count": len(items),
}
outcome = classify_stale_cart(shaped, now, STALE_DAYS)
if not outcome["stale"]:
continue
if cart["id"] in confirmed_orders:
log.info("Cart %s classified stale but has a matching order, skipping.", cart["id"])
continue
age_days = (now - _parse_iso(shaped["updated_at"])).total_seconds() / 86400
row = to_report_row(cart, age_days)
report.append(row)
log.warning("Stale cart %s: %s, age=%.1fd, total=%s", row["cart_id"], outcome["reason"], age_days, row["cart_total"])
with open(REPORT_PATH, "w") as f:
json.dump(report, f, indent=2)
log.info("Done. %d stale cart(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 carts that piled up because they never converted to an order.
*
* A Medusa v2 cart is a first-class, persistent record in the Cart Module, created as
* soon as a shopper's session needs one and only marked complete by setting completed_at
* when it converts into an order. Medusa ships no default scheduled job for cart
* retention, so nothing expires, archives, or deletes a cart that never reaches checkout.
* This lists carts, classifies each one with a pure function, cross-checks anything
* flagged against real orders, and writes a report of stale cart_ids for manual review.
* This is flag and report only. It never deletes a cart on its own.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/abandoned-carts-pile-up/
*/
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 STALE_DAYS = Number(process.env.STALE_DAYS || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPORT_PATH = process.env.REPORT_PATH || "stale_carts_report.json";
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, completed_at: string | null, updated_at: string, item_count: number }} cart
* @param {Date} now
* @param {number} staleDays
* @returns {{ stale: boolean, reason: string }}
*/
export function classifyStaleCart(cart, now, staleDays = 30) {
if (cart.completed_at) return { stale: false, reason: "completed" };
const ageMs = now.getTime() - new Date(cart.updated_at).getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (cart.item_count === 0) return { stale: false, reason: "empty-cart-not-abandoned" };
if (ageDays >= staleDays) return { stale: true, reason: `inactive-${Math.floor(ageDays)}d-with-items` };
return { stale: false, reason: "recent" };
}
export function cartTotal(cart) {
const items = cart.items || [];
return items.reduce((sum, i) => sum + Number(i.unit_price || 0) * Number(i.quantity || 0), 0);
}
export function toReportRow(cart, ageDays) {
const items = cart.items || [];
return {
cart_id: cart.id,
email: cart.email || cart.customer_id,
region_id: cart.region_id,
sales_channel_id: cart.sales_channel_id,
item_count: items.length,
cart_total: cartTotal(cart),
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 listCarts(token) {
const carts = [];
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/carts", {
fields: "id,email,customer_id,region_id,sales_channel_id,completed_at,created_at,updated_at,*items",
limit,
offset,
});
carts.push(...data.carts);
offset += limit;
if (offset >= data.count) return carts;
}
}
async function completedCartIds(token) {
const cartIds = new Set();
let offset = 0;
const limit = 200;
while (true) {
const data = await adminGet(token, "/admin/orders", {
fields: "id,cart_id",
limit,
offset,
});
for (const order of data.orders) {
if (order.cart_id) cartIds.add(order.cart_id);
}
offset += limit;
if (offset >= data.count) return cartIds;
}
}
export async function run() {
const token = await getAdminToken();
const carts = await listCarts(token);
const confirmedOrders = await completedCartIds(token);
const now = new Date();
const report = [];
for (const cart of carts) {
const items = cart.items || [];
const shaped = {
id: cart.id,
completed_at: cart.completed_at,
updated_at: cart.updated_at || cart.created_at,
item_count: items.length,
};
const outcome = classifyStaleCart(shaped, now, STALE_DAYS);
if (!outcome.stale) continue;
if (confirmedOrders.has(cart.id)) {
console.log(`Cart ${cart.id} classified stale but has a matching order, skipping.`);
continue;
}
const ageDays = (now.getTime() - new Date(shaped.updated_at).getTime()) / (1000 * 60 * 60 * 24);
const row = toReportRow(cart, ageDays);
report.push(row);
console.warn(`Stale cart ${row.cart_id}: ${outcome.reason}, age=${ageDays.toFixed(1)}d, total=${row.cart_total}`);
}
await writeFile(REPORT_PATH, JSON.stringify(report, null, 2));
console.log(`Done. ${report.length} stale cart(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
classify_stale_cart is the part most worth testing, because it decides which carts 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, and checks the answer.
from datetime import datetime, timezone
from report_stale_carts import classify_stale_cart
NOW = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
def cart(**over):
base = {"id": "cart_1", "completed_at": None, "updated_at": "2026-06-01T00:00:00Z", "item_count": 2}
base.update(over)
return base
def test_stale_when_old_with_items():
result = classify_stale_cart(cart(), NOW, 30)
assert result["stale"] is True
assert result["reason"].startswith("inactive-")
def test_not_stale_when_completed():
result = classify_stale_cart(cart(completed_at="2026-06-02T00:00:00Z"), NOW, 30)
assert result == {"stale": False, "reason": "completed"}
def test_not_stale_when_empty_cart():
result = classify_stale_cart(cart(item_count=0), NOW, 30)
assert result == {"stale": False, "reason": "empty-cart-not-abandoned"}
def test_not_stale_when_recent():
result = classify_stale_cart(cart(updated_at="2026-07-09T00:00:00Z"), NOW, 30)
assert result == {"stale": False, "reason": "recent"}
def test_exactly_at_stale_window_is_stale():
result = classify_stale_cart(cart(updated_at="2026-06-10T00:00:00Z"), NOW, 30)
assert result["stale"] is True
def test_completed_wins_even_with_items_and_age():
result = classify_stale_cart(cart(completed_at="2026-01-01T00:00:00Z", item_count=5), NOW, 30)
assert result == {"stale": False, "reason": "completed"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStaleCart } from "./report-stale-carts.js";
const NOW = new Date("2026-07-10T00:00:00Z");
const cart = (over = {}) => ({
id: "cart_1",
completed_at: null,
updated_at: "2026-06-01T00:00:00Z",
item_count: 2,
...over,
});
test("stale when old with items", () => {
const result = classifyStaleCart(cart(), NOW, 30);
assert.equal(result.stale, true);
assert.ok(result.reason.startsWith("inactive-"));
});
test("not stale when completed", () => {
const result = classifyStaleCart(cart({ completed_at: "2026-06-02T00:00:00Z" }), NOW, 30);
assert.deepEqual(result, { stale: false, reason: "completed" });
});
test("not stale when empty cart", () => {
const result = classifyStaleCart(cart({ item_count: 0 }), NOW, 30);
assert.deepEqual(result, { stale: false, reason: "empty-cart-not-abandoned" });
});
test("not stale when recent", () => {
const result = classifyStaleCart(cart({ updated_at: "2026-07-09T00:00:00Z" }), NOW, 30);
assert.deepEqual(result, { stale: false, reason: "recent" });
});
test("exactly at stale window is stale", () => {
const result = classifyStaleCart(cart({ updated_at: "2026-06-10T00:00:00Z" }), NOW, 30);
assert.equal(result.stale, true);
});
test("completed wins even with items and age", () => {
const result = classifyStaleCart(cart({ completed_at: "2026-01-01T00:00:00Z", item_count: 5 }), NOW, 30);
assert.deepEqual(result, { stale: false, reason: "completed" });
});
Case studies
The storefront that crawlers kept filling with empty carts
A store noticed its carts table had grown to hundreds of thousands of rows within a few months of launch, most of them with zero items. A search engine crawler and a handful of scraping bots were hitting the storefront on paths that triggered cart creation on every visit, and none of those sessions ever added anything.
Running the report showed almost all of that growth was empty carts, correctly skipped as empty-cart-not-abandoned rather than counted as real abandonment. The team focused their attention only on the small number of carts that actually had items and had gone stale, which was the real signal buried in the noise.
The duplicate carts from a flaky mobile network
A store selling to a mobile-heavy audience kept seeing pairs of near-identical carts from the same shopper, a few minutes apart. A dropped connection during checkout was causing the storefront to create a second cart on retry, and the first one was simply left behind once the second one converted into an order.
The cross-check against orders in step three caught this cleanly. Even though the abandoned first cart looked stale on its own, its sibling's order did not reference it, so it still showed up in the report exactly as it should, ready for a human to notice the pattern and fix the retry behavior upstream.
After this runs on a schedule, the team has a standing, accurate view of which carts are genuinely abandoned, with items, past the stale window, and confirmed to have no order attached. Nothing gets deleted without a human deciding to, and if the team later opts into automated cleanup, it only ever touches a cart already confirmed safe, one id at a time, with every action logged.
FAQ
Why do abandoned carts never disappear from my Medusa store?
A Medusa v2 cart is a first-class, persistent record in the Cart Module. It is only marked complete by setting completed_at when it converts into an order. Medusa ships no default scheduled job that expires, archives, or deletes a cart that never reaches checkout, so every abandoned session, bot-created empty cart, or duplicate from a retried checkout stays in the database indefinitely.
Is it safe to auto-delete stale Medusa carts with a script?
Treat it as flag and report first, not auto-delete. A cart_id can still be referenced by an in-flight payment session or an order that has not finished syncing, so deleting it is destructive. The safer pattern writes a report of stale cart_ids for manual review, and only calls DELETE on a cart after confirming with /admin/orders that no order is linked to it, gated behind a DRY_RUN flag.
How do I find abandoned carts with the Medusa Admin API?
Page through GET /admin/carts with fields for completed_at, updated_at, and the items relation. A cart counts as a real abandoned cart, not just an empty session, when completed_at is null, it has at least one line item, and it has not been updated in longer than your stale window, for example 30 days. Cross-check flagged cart_ids against /admin/orders so you never flag a cart that actually converted.
Related field notes
Citations
On the problem:
- Medusa Documentation: Cart Module. docs.medusajs.com/resources/commerce-modules/cart
- Medusa Documentation: listCarts, Cart Module Reference. docs.medusajs.com/resources/references/cart/listCarts
- GitHub: luluhoc/medusa-plugin-abandoned-cart. github.com/luluhoc/medusa-plugin-abandoned-cart
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: listAndCountCarts, Cart Module Reference. docs.medusajs.com/resources/references/cart/listAndCountCarts
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 carts table?
If this saved you from a runaway carts table 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