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.

Python and Node.js Medusa Admin API Safe by default (dry run)
The short answer

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.

Session needs a cart Cart Module creates it Shopper leaves, bot visits, or checkout retries checkout never finishes no retention job runs completed_at stays null cart never closes out Row stays forever no expiry, no archive Carts table grows
Every cart that never converts stays exactly as it was left. With no scheduled job for retention, the table only ever grows.

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:

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.

The key insight

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.

List carts completed_at, items, updated_at Pure decision fn classifyStaleCart Cross-check orders belt and suspenders Stale and no order? yes no, skip Cart left alone recent, empty, or ordered Report written cart_id, age, email, total
The script only ever writes a report. Deletion, if the team opts into it, is a separate, explicit, DRY_RUN gated step that runs later.

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 STALE_DAYS="30"
export DRY_RUN="true"   # start safe, change to false only if auto-delete is opted into
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 STALE_DAYS="30"
export DRY_RUN="true"   // start safe, change to false only if auto-delete is opted into
2

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.

step2.py
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
step2.js
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;
  }
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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"}
decide.js
/**
 * 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" };
}
5

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.

step5.py
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),
    }
step5.js
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,
  };
}
6

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.

Run it safe

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.

report_stale_carts.py
"""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()
report-stale-carts.js
/**
 * 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.

test_abandoned_carts.py
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"}
carts.test.js
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

Bot traffic

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.

Retried checkout

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.

What good looks like

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:

  1. Medusa Documentation: Cart Module. docs.medusajs.com/resources/commerce-modules/cart
  2. Medusa Documentation: listCarts, Cart Module Reference. docs.medusajs.com/resources/references/cart/listCarts
  3. GitHub: luluhoc/medusa-plugin-abandoned-cart. github.com/luluhoc/medusa-plugin-abandoned-cart

On the solution:

  1. Medusa Documentation: Scheduled Jobs. docs.medusajs.com/learn/fundamentals/scheduled-jobs
  2. Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
  3. 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.

Contact me on LinkedIn

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

Back to all Medusa field notes