Skip to content

Diagnostic Cron

Order confirmation emails silently stop sending

Checkout works. Payment captures. Inventory deducts. The order lands in the admin grid looking completely normal. But the customer never gets a confirmation email, the invoice email never goes out, and nobody notices until a customer asks where their receipt is, sometimes days later. The order was never broken. The email queue behind it was never drained, because the Magento cron scheduler that drains it quietly stopped running. Here is why that dependency is so easy to miss and a small script that surfaces the backlog before customers do.

Python and Node.js Orders REST API Safe by default (report only)
A large array of equipment
Photo by Matthieu Beaumont on Unsplash
The short answer

Magento 2 and Adobe Commerce send sales emails, order, invoice, shipment, and credit memo, through an asynchronous queue when Stores > Configuration > Sales > Sales Emails > Asynchronous sending is enabled, which is the default in modern versions. Placing an order only writes send_email=1 and email_sent=null on sales_order. The actual send happens later, when the sales_send_order_emails cron job runs. If the cron scheduler is dead, that job never fires, the queue never drains, and orders keep completing successfully while confirmation emails never go out. There is no REST endpoint for cron_schedule, so the detectable proxy is a growing backlog of orders in /rest/V1/orders that are older than a threshold and still open. A script can report that backlog and flag likely cron death, but it cannot safely force-send an email over the API. Full code, tests, and a dry run guard are below.

The problem in plain words

Every sales email in Magento, order confirmation, invoice, shipment, and credit memo, used to be sent synchronously, in the same request that created the record. That was slow and fragile, so modern versions default to asynchronous sending. The order finishes checkout, the row is written to sales_order with send_email set to 1 and email_sent left at null or 0, and Magento moves on. Nothing in that request actually talks to an SMTP server.

The email goes out later, when the sales_send_order_emails cron job, defined in Magento_Sales/etc/crontab.xml under the default cron group, runs and finds that unsent row. This is by design, and on a healthy store it is invisible, because cron runs every few minutes and the queue never has time to build up.

The trouble starts when the cron scheduler itself is not running. Maybe the system crontab entry for bin/magento cron:run was never installed, or was removed by a deploy script. Maybe a stuck cron_schedule row stuck on "running" is blocking Magento from ever scheduling a fresh run of that group. Maybe a fatal PHP error in an unrelated job is killing the whole cron process before it reaches the sales email group. Whatever the cause, once cron stops, every cron-dependent feature stops with it, and sales emails are one of the quietest to fail, because the storefront experience looks completely fine. Checkout succeeds, payment captures, inventory deducts, the order appears in the admin grid. The only thing missing is a message that was never expected to arrive instantly in the first place, so nobody looks for it, sometimes for hours or days.

Order completes send_email=1, email_sent=null Waiting on cron sales_send_order_emails cron scheduler is dead Queue never drains email_sent stays null Customer never notified Checkout, payment, inventory all still succeed normally
The order never fails. The queue behind it just never drains, because the cron scheduler that drains it is silently down.

Why it happens

This exact pattern shows up repeatedly in Magento's own issue tracker and community forum: asynchronous sales emails piling up unsent, and order confirmation emails that stopped after an upgrade with no obvious error anywhere in the storefront. See the citations at the end for the specific threads.

The key insight

cron_schedule is a database table with no REST endpoint, and the real send_email and email_sent flags are not exposed on the default Orders REST DTO either. So a script cannot ask Magento directly "is my email queue backed up." What it can do is use "order created more than N minutes ago and still open" as a proxy. A healthy store only ever shows very recent orders unconfirmed, because cron drains the queue every few minutes. A growing, aging backlog across repeated polls is strong indirect evidence that the whole cron scheduler, not just email, has stopped running.

The fix, as a flow

We do not attempt to send email over the API, because there is no safe public way to do that, and forcing a send would only mask a scheduler that needs to be fixed at the process level. Instead we add a job that polls open orders, computes how overdue each one is against a staleness threshold, and raises a clear flag when the backlog is large or old enough to indicate the cron scheduler itself is down, so an operator can go run the real repair.

Scheduled job runs on a timer GET /V1/orders created_at lteq threshold Compute minutesOverdue exclude terminal statuses Backlog past threshold? yes no, report ok CRON_LIKELY_DOWN operator runs cron:run
The script only ever reports. It never tries to send an email itself, because the real fix lives at the cron process level.

Build it step by step

1

Get an admin bearer token

Authenticate the same way as any other Magento REST client. 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.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STALE_MINUTES="30"
export BACKLOG_ALERT_COUNT="5"
export DRY_RUN="true"   # report-only either way, this only affects log verbosity
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export STALE_MINUTES="30"
export BACKLOG_ALERT_COUNT="5"
export DRY_RUN="true"   // report-only either way, this only affects log verbosity
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

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

List orders older than the staleness threshold

Call GET /rest/V1/orders with a searchCriteria filter group on created_at using lteq against now minus the threshold, combined with a second filter group excluding canceled and closed statuses. Sort by created_at ascending so the oldest, most overdue orders come first, and page with pageSize and currentPage.

step3.py
def orders_older_than(threshold_iso, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][value]": threshold_iso,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "lteq",
        "searchCriteria[filterGroups][1][filters][0][field]": "status",
        "searchCriteria[filterGroups][1][filters][0][value]": "canceled",
        "searchCriteria[filterGroups][1][filters][0][conditionType]": "neq",
        "searchCriteria[filterGroups][2][filters][0][field]": "status",
        "searchCriteria[filterGroups][2][filters][0][value]": "closed",
        "searchCriteria[filterGroups][2][filters][0][conditionType]": "neq",
        "searchCriteria[sortOrders][0][field]": "created_at",
        "searchCriteria[sortOrders][0][direction]": "ASC",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/orders", params)["items"]
step3.js
async function ordersOlderThan(thresholdIso, pageSize = 100, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][value]": thresholdIso,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "lteq",
    "searchCriteria[filterGroups][1][filters][0][field]": "status",
    "searchCriteria[filterGroups][1][filters][0][value]": "canceled",
    "searchCriteria[filterGroups][1][filters][0][conditionType]": "neq",
    "searchCriteria[filterGroups][2][filters][0][field]": "status",
    "searchCriteria[filterGroups][2][filters][0][value]": "closed",
    "searchCriteria[filterGroups][2][filters][0][conditionType]": "neq",
    "searchCriteria[sortOrders][0][field]": "created_at",
    "searchCriteria[sortOrders][0][direction]": "ASC",
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/orders", params);
  return data.items;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a plain list of orders, the current time, and two thresholds, and returns the stale ones plus a summary flag. A pure function like this is easy to read and easy to test, which we do later. It excludes terminal statuses such as canceled that never expect a confirmation email, computes how many minutes overdue each remaining order is, and sets cronLikelyDown true once the backlog is either large enough in count or old enough at its worst case.

decide.py
import datetime

TERMINAL_STATUSES = {"canceled"}

def classify_cron_email_backlog(orders, now_iso, stale_minutes=30, backlog_alert_count=5):
    now = datetime.datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
    stale_orders = []

    for o in orders:
        if o.get("status") in TERMINAL_STATUSES:
            continue
        created = datetime.datetime.fromisoformat(o["createdAt"].replace("Z", "+00:00"))
        minutes_overdue = (now - created).total_seconds() / 60
        if minutes_overdue > stale_minutes:
            stale_orders.append({
                "entityId": o["entityId"],
                "incrementId": o["incrementId"],
                "minutesOverdue": minutes_overdue,
            })

    stale_orders.sort(key=lambda o: o["minutesOverdue"], reverse=True)

    cron_likely_down = len(stale_orders) >= backlog_alert_count or (
        len(stale_orders) > 0
        and max(o["minutesOverdue"] for o in stale_orders) > stale_minutes * 4
    )

    return {"staleOrders": stale_orders, "cronLikelyDown": cron_likely_down}
decide.js
const TERMINAL_STATUSES = new Set(["canceled"]);

export function classifyCronEmailBacklog(orders, nowIso, staleMinutes = 30, backlogAlertCount = 5) {
  const now = new Date(nowIso).getTime();
  const staleOrders = [];

  for (const o of orders) {
    if (TERMINAL_STATUSES.has(o.status)) continue;
    const created = new Date(o.createdAt).getTime();
    const minutesOverdue = (now - created) / 60000;
    if (minutesOverdue > staleMinutes) {
      staleOrders.push({ entityId: o.entityId, incrementId: o.incrementId, minutesOverdue });
    }
  }

  staleOrders.sort((a, b) => b.minutesOverdue - a.minutesOverdue);

  const maxOverdue = staleOrders.length ? Math.max(...staleOrders.map((o) => o.minutesOverdue)) : 0;
  const cronLikelyDown =
    staleOrders.length >= backlogAlertCount ||
    (staleOrders.length > 0 && maxOverdue > staleMinutes * 4);

  return { staleOrders, cronLikelyDown };
}
5

Cross check by re-polling

A single poll only tells you the backlog exists right now. Re-poll /rest/V1/orders after a wait interval, for example fifteen minutes, and compare the count of stale orders. If the count never shrinks between polls, cron is confirmed dead rather than merely running slow behind a temporary spike, and the report should say so clearly.

recheck.py
def backlog_is_shrinking(previous_stale_count, current_stale_count):
    return current_stale_count < previous_stale_count
recheck.js
function backlogIsShrinking(previousStaleCount, currentStaleCount) {
  return currentStaleCount < previousStaleCount;
}
6

Report by default, never force a send

The output is a structured report per affected order, its entity_id, increment_id, created_at, and minutes_overdue, plus a top level CRON_LIKELY_DOWN flag when the backlog crosses the threshold. There is no code path in this script that sends an email or touches an order. There is no safe REST way to do that, so the fix is always for a human to run bin/magento cron:run, check bin/magento cron:install and the system crontab, or query cron_schedule directly to clear a stuck row.

Run it safe

This script never sends an email and never modifies an order. DRY_RUN only changes log verbosity, since the action is always a report. Treat CRON_LIKELY_DOWN as a signal to check the cron scheduler at the process and database level, not something to script around at the API layer.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, polls the orders backlog, classifies it with the pure function, and prints a structured report. It never attempts to send an email or write to an order, so it is safe to run again and again.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
flag_cron_email_backlog.py
"""Flag a Magento 2 order confirmation email backlog caused by a dead cron scheduler.

Magento sends sales emails (order, invoice, shipment, credit memo) through an
asynchronous queue by default. An order only sets send_email=1 and
email_sent=null when it completes; the actual send happens later, when the
sales_send_order_emails cron job runs. cron_schedule has no REST endpoint and
the real send_email/email_sent flags are not on the default order DTO, so this
uses "order created more than N minutes ago and still open" as the detectable
proxy for a stuck email queue. This never sends an email or writes to an
order, it only reports. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import datetime
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
STALE_MINUTES = float(os.environ.get("STALE_MINUTES", "30"))
BACKLOG_ALERT_COUNT = int(os.environ.get("BACKLOG_ALERT_COUNT", "5"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

TERMINAL_STATUSES = {"canceled"}


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 orders_older_than(threshold_iso, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][value]": threshold_iso,
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "lteq",
        "searchCriteria[filterGroups][1][filters][0][field]": "status",
        "searchCriteria[filterGroups][1][filters][0][value]": "canceled",
        "searchCriteria[filterGroups][1][filters][0][conditionType]": "neq",
        "searchCriteria[filterGroups][2][filters][0][field]": "status",
        "searchCriteria[filterGroups][2][filters][0][value]": "closed",
        "searchCriteria[filterGroups][2][filters][0][conditionType]": "neq",
        "searchCriteria[sortOrders][0][field]": "created_at",
        "searchCriteria[sortOrders][0][direction]": "ASC",
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/orders", params)["items"]


def classify_cron_email_backlog(orders, now_iso, stale_minutes=30, backlog_alert_count=5):
    now = datetime.datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
    stale_orders = []

    for o in orders:
        if o.get("status") in TERMINAL_STATUSES:
            continue
        created = datetime.datetime.fromisoformat(o["createdAt"].replace("Z", "+00:00"))
        minutes_overdue = (now - created).total_seconds() / 60
        if minutes_overdue > stale_minutes:
            stale_orders.append({
                "entityId": o["entityId"],
                "incrementId": o["incrementId"],
                "minutesOverdue": minutes_overdue,
            })

    stale_orders.sort(key=lambda o: o["minutesOverdue"], reverse=True)

    cron_likely_down = len(stale_orders) >= backlog_alert_count or (
        len(stale_orders) > 0
        and max(o["minutesOverdue"] for o in stale_orders) > stale_minutes * 4
    )

    return {"staleOrders": stale_orders, "cronLikelyDown": cron_likely_down}


def normalize_order(item):
    return {
        "entityId": item.get("entity_id"),
        "incrementId": item.get("increment_id"),
        "createdAt": item.get("created_at"),
        "status": item.get("status"),
    }


def run():
    now = datetime.datetime.now(datetime.timezone.utc)
    threshold = now - datetime.timedelta(minutes=STALE_MINUTES)
    threshold_iso = threshold.strftime("%Y-%m-%d %H:%M:%S")

    raw_items = orders_older_than(threshold_iso)
    orders = [normalize_order(item) for item in raw_items]

    result = classify_cron_email_backlog(
        orders, now.isoformat(), STALE_MINUTES, BACKLOG_ALERT_COUNT
    )

    for stale in result["staleOrders"]:
        log.warning(
            "Order %s (id %s) is %.0f minute(s) overdue for its confirmation email.",
            stale["incrementId"], stale["entityId"], stale["minutesOverdue"],
        )

    if result["cronLikelyDown"]:
        log.error(
            "CRON_LIKELY_DOWN: %d stale order(s) found past the %d minute threshold. "
            "Run bin/magento cron:run, check bin/magento cron:install and the system "
            "crontab, or clear a stuck cron_schedule row.",
            len(result["staleOrders"]), STALE_MINUTES,
        )
    else:
        log.info("Done. %d stale order(s), cron appears healthy.", len(result["staleOrders"]))


if __name__ == "__main__":
    run()
flag-cron-email-backlog.js
/**
 * Flag a Magento 2 order confirmation email backlog caused by a dead cron scheduler.
 *
 * Magento sends sales emails (order, invoice, shipment, credit memo) through an
 * asynchronous queue by default. An order only sets send_email=1 and
 * email_sent=null when it completes; the actual send happens later, when the
 * sales_send_order_emails cron job runs. cron_schedule has no REST endpoint and
 * the real send_email/email_sent flags are not on the default order DTO, so this
 * uses "order created more than N minutes ago and still open" as the detectable
 * proxy for a stuck email queue. This never sends an email or writes to an
 * order, it only reports. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/order-emails-not-sent-cron-dependency/
 */
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 STALE_MINUTES = Number(process.env.STALE_MINUTES || 30);
const BACKLOG_ALERT_COUNT = Number(process.env.BACKLOG_ALERT_COUNT || 5);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const TERMINAL_STATUSES = new Set(["canceled"]);

export function classifyCronEmailBacklog(orders, nowIso, staleMinutes = 30, backlogAlertCount = 5) {
  const now = new Date(nowIso).getTime();
  const staleOrders = [];

  for (const o of orders) {
    if (TERMINAL_STATUSES.has(o.status)) continue;
    const created = new Date(o.createdAt).getTime();
    const minutesOverdue = (now - created) / 60000;
    if (minutesOverdue > staleMinutes) {
      staleOrders.push({ entityId: o.entityId, incrementId: o.incrementId, minutesOverdue });
    }
  }

  staleOrders.sort((a, b) => b.minutesOverdue - a.minutesOverdue);

  const maxOverdue = staleOrders.length ? Math.max(...staleOrders.map((o) => o.minutesOverdue)) : 0;
  const cronLikelyDown =
    staleOrders.length >= backlogAlertCount ||
    (staleOrders.length > 0 && maxOverdue > staleMinutes * 4);

  return { staleOrders, cronLikelyDown };
}

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 ordersOlderThan(thresholdIso, pageSize = 100, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][value]": thresholdIso,
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "lteq",
    "searchCriteria[filterGroups][1][filters][0][field]": "status",
    "searchCriteria[filterGroups][1][filters][0][value]": "canceled",
    "searchCriteria[filterGroups][1][filters][0][conditionType]": "neq",
    "searchCriteria[filterGroups][2][filters][0][field]": "status",
    "searchCriteria[filterGroups][2][filters][0][value]": "closed",
    "searchCriteria[filterGroups][2][filters][0][conditionType]": "neq",
    "searchCriteria[sortOrders][0][field]": "created_at",
    "searchCriteria[sortOrders][0][direction]": "ASC",
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/orders", params);
  return data.items;
}

function normalizeOrder(item) {
  return {
    entityId: item.entity_id,
    incrementId: item.increment_id,
    createdAt: item.created_at,
    status: item.status,
  };
}

export async function run() {
  const now = new Date();
  const threshold = new Date(now.getTime() - STALE_MINUTES * 60000);
  const thresholdIso = threshold.toISOString().slice(0, 19).replace("T", " ");

  const rawItems = await ordersOlderThan(thresholdIso);
  const orders = rawItems.map(normalizeOrder);

  const result = classifyCronEmailBacklog(orders, now.toISOString(), STALE_MINUTES, BACKLOG_ALERT_COUNT);

  for (const stale of result.staleOrders) {
    console.warn(
      `Order ${stale.incrementId} (id ${stale.entityId}) is ${stale.minutesOverdue.toFixed(0)} minute(s) overdue for its confirmation email.`
    );
  }

  if (result.cronLikelyDown) {
    console.error(
      `CRON_LIKELY_DOWN: ${result.staleOrders.length} stale order(s) found past the ${STALE_MINUTES} minute threshold. ` +
      `Run bin/magento cron:run, check bin/magento cron:install and the system crontab, or clear a stuck cron_schedule row.`
    );
  } else {
    console.log(`Done. ${result.staleOrders.length} stale order(s), cron appears healthy.`);
  }
}

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

Add a test

The classification rule is the part most worth testing, because it decides whether the report escalates to CRON_LIKELY_DOWN. Because we kept classifyCronEmailBacklog pure, the test needs no network and no Magento store. It just feeds in a fixed clock string and fixture orders and checks the answer.

test_order_email_backlog.py
from flag_cron_email_backlog import classify_cron_email_backlog

NOW = "2026-07-10T12:00:00Z"


def order(**over):
    base = {
        "entityId": 501,
        "incrementId": "100000501",
        "createdAt": "2026-07-10T11:00:00Z",  # 60 minutes old
        "status": "processing",
    }
    base.update(over)
    return base


def test_no_stale_orders_when_all_recent():
    result = classify_cron_email_backlog([order(createdAt="2026-07-10T11:55:00Z")], NOW, 30, 5)
    assert result["staleOrders"] == []
    assert result["cronLikelyDown"] is False


def test_stale_order_past_threshold():
    result = classify_cron_email_backlog([order()], NOW, 30, 5)
    assert len(result["staleOrders"]) == 1
    assert result["staleOrders"][0]["incrementId"] == "100000501"


def test_canceled_orders_are_excluded():
    result = classify_cron_email_backlog([order(status="canceled")], NOW, 30, 5)
    assert result["staleOrders"] == []


def test_cron_likely_down_when_backlog_count_reached():
    orders = [order(entityId=i, incrementId=str(i)) for i in range(5)]
    result = classify_cron_email_backlog(orders, NOW, 30, 5)
    assert result["cronLikelyDown"] is True


def test_cron_likely_down_when_one_order_extremely_overdue():
    result = classify_cron_email_backlog(
        [order(createdAt="2026-07-10T09:00:00Z")], NOW, 30, 5  # 180 minutes overdue
    )
    assert result["cronLikelyDown"] is True


def test_not_cron_likely_down_with_small_recent_backlog():
    result = classify_cron_email_backlog([order()], NOW, 30, 5)
    assert result["cronLikelyDown"] is False


def test_stale_orders_sorted_by_minutes_overdue_descending():
    orders = [
        order(entityId=1, incrementId="1", createdAt="2026-07-10T11:00:00Z"),
        order(entityId=2, incrementId="2", createdAt="2026-07-10T10:00:00Z"),
    ]
    result = classify_cron_email_backlog(orders, NOW, 30, 5)
    assert [o["incrementId"] for o in result["staleOrders"]] == ["2", "1"]
order-email-backlog.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyCronEmailBacklog } from "./flag-cron-email-backlog.js";

const NOW = "2026-07-10T12:00:00Z";

const order = (over = {}) => ({
  entityId: 501,
  incrementId: "100000501",
  createdAt: "2026-07-10T11:00:00Z", // 60 minutes old
  status: "processing",
  ...over,
});

test("no stale orders when all recent", () => {
  const result = classifyCronEmailBacklog([order({ createdAt: "2026-07-10T11:55:00Z" })], NOW, 30, 5);
  assert.deepEqual(result.staleOrders, []);
  assert.equal(result.cronLikelyDown, false);
});

test("stale order past threshold", () => {
  const result = classifyCronEmailBacklog([order()], NOW, 30, 5);
  assert.equal(result.staleOrders.length, 1);
  assert.equal(result.staleOrders[0].incrementId, "100000501");
});

test("canceled orders are excluded", () => {
  const result = classifyCronEmailBacklog([order({ status: "canceled" })], NOW, 30, 5);
  assert.deepEqual(result.staleOrders, []);
});

test("cron likely down when backlog count reached", () => {
  const orders = [0, 1, 2, 3, 4].map((i) => order({ entityId: i, incrementId: String(i) }));
  const result = classifyCronEmailBacklog(orders, NOW, 30, 5);
  assert.equal(result.cronLikelyDown, true);
});

test("cron likely down when one order extremely overdue", () => {
  const result = classifyCronEmailBacklog(
    [order({ createdAt: "2026-07-10T09:00:00Z" })], NOW, 30, 5 // 180 minutes overdue
  );
  assert.equal(result.cronLikelyDown, true);
});

test("not cron likely down with small recent backlog", () => {
  const result = classifyCronEmailBacklog([order()], NOW, 30, 5);
  assert.equal(result.cronLikelyDown, false);
});

test("stale orders sorted by minutes overdue descending", () => {
  const orders = [
    order({ entityId: 1, incrementId: "1", createdAt: "2026-07-10T11:00:00Z" }),
    order({ entityId: 2, incrementId: "2", createdAt: "2026-07-10T10:00:00Z" }),
  ];
  const result = classifyCronEmailBacklog(orders, NOW, 30, 5);
  assert.deepEqual(result.staleOrders.map((o) => o.incrementId), ["2", "1"]);
});

Case studies

Dropped crontab entry

The migration that lost the system cron

A mid-size store moved to a new host. The application deployed cleanly and checkout worked on day one, so nobody thought to check cron. Three days later a support ticket asked why no order confirmation had arrived since the migration. By then dozens of customers had checked out with no receipt in their inbox.

The detection job, run hourly against /rest/V1/orders, would have raised CRON_LIKELY_DOWN within the first cycle after the migration, since every order created after the cutover would have aged well past the staleness threshold with none of them ever clearing. The fix was simply reinstalling the crontab entry with bin/magento cron:install and confirming cron:run executed.

Stuck cron_schedule row

The unrelated module that quietly wedged the default group

A third-party shipping module threw a fatal error partway through an unrelated scheduled task, leaving its cron_schedule row stuck on running. Because that job never finished, Magento never scheduled a fresh run for the same cron group, and sales_send_order_emails, which shared that group, stopped firing along with it.

The backlog report showed a slowly growing count of overdue orders across repeated hourly polls, never shrinking, which confirmed cron was fully stuck rather than just backed up. An operator cleared the stuck row directly in the database and the queue drained itself within minutes of the next scheduled run.

What good looks like

After this runs on a schedule, a dead cron scheduler is caught within one polling cycle instead of surviving silently until a customer complains. The report carries the exact orders affected, how many minutes overdue each one is, and a clear CRON_LIKELY_DOWN flag, so whoever responds knows to go check cron:run, the system crontab, or a stuck cron_schedule row rather than chase the storefront for a bug that is not there.

FAQ

Why did my Magento store stop sending order confirmation emails?

Modern Magento versions send sales emails asynchronously by default. Placing an order only marks it with send_email=1 and email_sent=null, and the actual email is dispatched later by the sales_send_order_emails cron job. If the Magento cron scheduler is not running, that job never fires, so orders keep completing normally while the confirmation, invoice, and shipment emails simply never go out.

How can I tell if Magento cron is down without server access?

Poll GET /rest/V1/orders filtered to orders created more than a set number of minutes ago and not in a canceled or closed state. A healthy store only ever shows very recent orders in that list because cron drains the queue every few minutes. A growing, aging backlog of orders across repeated polls is a strong indirect signal that the cron scheduler, and with it the sales email queue, is not running.

Can a script send the missing order emails directly over the REST API?

No, and it should not try. There is no public REST endpoint to resend an order email, and forcing a send would bypass the real problem. The correct fix is restarting or repairing the CLI cron scheduler, for example running bin/magento cron:run, checking bin/magento cron:install and the system crontab, or clearing a stuck cron_schedule row so sales_send_order_emails can drain the queue on its own.

Related field notes

Citations

On the problem:

  1. GitHub Issue: sales emails asynchronous sending sends out old emails. github.com/magento/magento2/issues/27039
  2. GitHub Issue: sales emails async sending issue. github.com/magento/magento2/issues/16165
  3. Magento Forums: order confirmation emails after upgrade to 2.3.3. community.magento.com order confirmation emails after upgrade

On the solution:

  1. Adobe Commerce: configure and run cron jobs. experienceleague.adobe.com config cli subcommands cron
  2. Adobe Commerce: Orders API, search orders and searchCriteria. developer.adobe.com/commerce/webapi/rest/quick-reference/search-criteria
  3. Adobe Commerce: Sales Emails configuration reference. experienceleague.adobe.com transactional emails

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce cron, orders, 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.

Contact me on LinkedIn

Did this catch a dead cron before your customers did?

If this saved you a pile of missing confirmation emails or an awkward support conversation, 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 Magento field notes