Diagnostic WooCommerce core: scheduling, cron, and email
WP-Cron disabled, emails never send
The order came through, the payment is fine, and the customer swears they never got a confirmation email. Support checks the SMTP plugin, the spam folder, everything looks normal. The real cause is quieter than that: WP-Cron is disabled or starved, so the queue that sends every order email, and runs most of WooCommerce's background jobs, has simply stopped moving. Here is why that happens and a small script that detects the stuck queue before customers notice.
WooCommerce order emails and the Action Scheduler queue are not sent by a background service. They are sent by WP-Cron, which WordPress only checks when a browser or bot loads a page on your site. If DISABLE_WP_CRON is set to true and no real system cron calls wp-cron.php, or if a full page cache serves every request without WordPress ever booting, the queue keeps growing but nothing fires. Run a small Python or Node.js script on a schedule that reads recent orders from the WooCommerce REST API, checks how long each one has waited without its confirmation note, and flags the store when the backlog is too old. Full code, tests, and a dry run guard are below.
The problem in plain words
WordPress does not have a real background worker running all the time. Instead it uses a trick called WP-Cron. Every time someone visits the site, WordPress quietly checks whether any scheduled task is due, and if one is, it runs it right there in that same page load. It looks like a cron job. It is really just a to-do list that gets checked whenever a visitor happens to walk by.
WooCommerce leans on this heavily. Order emails, most of its background jobs, and every one of its Action Scheduler tasks (stock holds expiring, scheduled sales starting and ending, subscription renewals, webhook retries) are queued as WP-Cron events. If nobody ever triggers WP-Cron, none of that runs. The order still gets created, the payment still gets captured, but the "send confirmation email" job just sits in the queue forever.
Why it happens
WP-Cron being effectively off is one of the most common and least visible WordPress problems, because the site keeps working in every way a person testing it by hand would notice. A few common causes:
- A performance guide or a hosting provider set
define('DISABLE_WP_CRON', true);inwp-config.phpto stop WP-Cron from running on every page load, and a real system cron callingwp-cron.phpwas never set up to replace it. - A full page cache or CDN serves every request straight from cache, so WordPress itself never boots for most visitors and the pseudo-cron check never fires.
- A low-traffic store, or a store where every page a real visitor sees is a cached page, simply does not get enough uncached page loads for WP-Cron to trigger often.
- Action Scheduler, which WooCommerce uses for its own queue, has its own runners that can silently stop claiming a batch if a previous run timed out or a fatal error killed a worker mid-batch, leaving pending actions stuck even when WP-Cron itself is technically firing.
WooCommerce's own documentation notes that Action Scheduler depends on WP-Cron by default and recommends a real system cron on any store handling meaningful volume. This is reported constantly under titles like "order emails not sending" when the actual fault is scheduling, not the mail server.
An email that never sends and a queue that never runs are the same symptom. Do not chase the SMTP plugin first. Check whether the clock is moving at all. If order notes that record "email sent" stop appearing shortly after the order's creation time, and that gap keeps growing across many orders, the scheduler is the problem, not the mail provider.
The fix, as a flow
We do not touch email sending directly and we do not try to fix WP-Cron from a Python or Node.js script, since that lives on the WordPress server. Instead we add a small watchdog that runs from its own schedule, outside of WordPress, and reads recent orders through the WooCommerce REST API. For each order it looks at how long it has waited without an order note confirming its email went out. If enough recent orders are stuck past a safe threshold, that is a reliable signal WP-Cron is not running, and the script raises a clear flag (and can optionally leave a diagnostic note on the oldest stuck order) so a human goes and fixes the actual scheduling problem on the server.
Build it step by step
Get access to the store
You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with at least read access to orders. Create it under WooCommerce, Settings, Advanced, REST API. This watchdog never needs a Stripe key, since it only reads order timing, but we still read the saved PaymentIntent id from order meta when we want to cross-check a specific stuck order by hand.
pip install stripe requests
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="6"
export STUCK_MINUTES="30"
export DRY_RUN="true" # start safe, change to false to write
npm install stripe
export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="6"
export STUCK_MINUTES="30"
export DRY_RUN="true" // start safe, change to false to write
List recent orders and their notes
Pull orders created in the lookback window through the WooCommerce REST API, then read each order's notes. WooCommerce (and most transactional email plugins) leaves a customer-facing note like "Order status changed" or logs the mail attempt, so the notes list is our evidence for whether the email job ever ran, without needing server access.
import os, requests
from requests.auth import HTTPBasicAuth
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
def recent_orders(lookback_hours):
since = __import__("datetime").datetime.utcnow() - __import__("datetime").timedelta(hours=lookback_hours)
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"after": since.isoformat(), "per_page": 100, "orderby": "date", "order": "asc"},
auth=AUTH, timeout=30,
)
r.raise_for_status()
return r.json()
def order_notes(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes", auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
async function woo(path) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
headers: { "Content-Type": "application/json", Authorization: AUTH },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function recentOrders(lookbackHours) {
const since = new Date(Date.now() - lookbackHours * 3600000).toISOString();
return woo(`/orders?after=${since}&per_page=100&orderby=date&order=asc`);
}
async function orderNotes(orderId) {
return woo(`/orders/${orderId}/notes`);
}
Read the PaymentIntent id when you need to cross-check one order
When the watchdog flags a specific order for a closer look, the saved Stripe PaymentIntent id tells you the payment side is fine, so you can rule that out fast. WooCommerce's Stripe gateway stores it in order meta under _stripe_intent_id, and older orders sometimes only have it in transaction_id.
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
Decide, with one pure function
Keep the decision in its own function that takes an order, its notes, and the current time, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If the order already has a note that looks like the email went out, it is fine. If the order has not waited long enough yet, it is too soon to judge. Otherwise, past the stuck threshold with no confirming note, it is stuck.
from datetime import datetime, timezone
EMAIL_NOTE_MARKERS = ("email sent", "order status changed", "note sent to customer")
def _parse(ts):
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
def has_email_note(notes):
for note in notes:
text = (note.get("note") or "").lower()
if note.get("customer_note") or any(m in text for m in EMAIL_NOTE_MARKERS):
return True
return False
def minutes_waiting(order, now):
created = _parse(order["date_created_gmt"] + "Z" if "Z" not in order["date_created_gmt"] else order["date_created_gmt"])
return (now - created).total_seconds() / 60
def decide(order, notes, now, stuck_minutes):
waited = minutes_waiting(order, now)
if has_email_note(notes):
return ("ok", "a confirmation note already exists")
if waited < stuck_minutes:
return ("wait", "too soon to judge, still inside the grace window")
return ("stuck", f"no confirmation note after {int(waited)} minutes")
const EMAIL_NOTE_MARKERS = ["email sent", "order status changed", "note sent to customer"];
export function hasEmailNote(notes) {
return notes.some((note) => {
const text = (note.note || "").toLowerCase();
return note.customer_note || EMAIL_NOTE_MARKERS.some((m) => text.includes(m));
});
}
export function minutesWaiting(order, now) {
const created = new Date(order.date_created_gmt.endsWith("Z") ? order.date_created_gmt : order.date_created_gmt + "Z");
return (now.getTime() - created.getTime()) / 60000;
}
export function decide(order, notes, now, stuckMinutes) {
const waited = minutesWaiting(order, now);
if (hasEmailNote(notes)) return ["ok", "a confirmation note already exists"];
if (waited < stuckMinutes) return ["wait", "too soon to judge, still inside the grace window"];
return ["stuck", `no confirmation note after ${Math.floor(waited)} minutes`];
}
Roll it up into a store-level verdict
One stuck order could just be a slow customer note. A backlog of several stuck orders in the same run is the real signal. We count how many orders in the batch come back stuck and only raise the WP-Cron alarm once that count crosses a small threshold, so a single edge case does not page anyone at 3 a.m.
MIN_STUCK_TO_ALARM = 3
def store_verdict(stuck_count):
if stuck_count >= MIN_STUCK_TO_ALARM:
return ("alarm", f"{stuck_count} orders stuck, WP-Cron is likely disabled or starved")
if stuck_count > 0:
return ("watch", f"{stuck_count} order(s) stuck, below the alarm threshold")
return ("healthy", "no stuck orders in this window")
const MIN_STUCK_TO_ALARM = 3;
export function storeVerdict(stuckCount) {
if (stuckCount >= MIN_STUCK_TO_ALARM) {
return ["alarm", `${stuckCount} orders stuck, WP-Cron is likely disabled or starved`];
}
if (stuckCount > 0) {
return ["watch", `${stuckCount} order(s) stuck, below the alarm threshold`];
}
return ["healthy", "no stuck orders in this window"];
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports its verdict without writing anything. Read the output, trust it, then switch it off to let it leave a diagnostic note on the oldest stuck order when it raises the alarm. Run it on a schedule with a real system cron every fifteen to thirty minutes, since a script that depends on the same broken WP-Cron would be useless here.
Always start with DRY_RUN=true. This watchdog only ever adds a diagnostic note, it never changes an order's status, but you still want to see its verdict before it writes anything.
The full code
Here is the complete watchdog in one file for each language. It reads settings from the environment, logs its verdict, respects the dry run flag, and is safe to run again and again because it never changes order status or totals, it only reads timing evidence and, when it finds a real backlog, leaves one note.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Detect a WooCommerce store where WP-Cron is disabled or starved, so order
emails and the Action Scheduler queue never fire. Read only by default.
Run on a real system cron schedule, since this must not depend on WP-Cron.
"""
import os
import logging
import requests
from datetime import datetime, timezone
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cron_watchdog")
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "6"))
STUCK_MINUTES = int(os.environ.get("STUCK_MINUTES", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EMAIL_NOTE_MARKERS = ("email sent", "order status changed", "note sent to customer")
MIN_STUCK_TO_ALARM = 3
def recent_orders(lookback_hours):
since = datetime.now(timezone.utc) - __import__("datetime").timedelta(hours=lookback_hours)
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"after": since.isoformat(), "per_page": 100, "orderby": "date", "order": "asc"},
auth=AUTH, timeout=30,
)
r.raise_for_status()
return r.json()
def order_notes(order_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes", auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
tid = order.get("transaction_id")
return tid if tid and tid.startswith("pi_") else None
def has_email_note(notes):
for note in notes:
text = (note.get("note") or "").lower()
if note.get("customer_note") or any(m in text for m in EMAIL_NOTE_MARKERS):
return True
return False
def minutes_waiting(order, now):
raw = order["date_created_gmt"]
created = datetime.fromisoformat(raw if raw.endswith("+00:00") else raw + "+00:00")
return (now - created).total_seconds() / 60
def decide(order, notes, now, stuck_minutes):
waited = minutes_waiting(order, now)
if has_email_note(notes):
return ("ok", "a confirmation note already exists")
if waited < stuck_minutes:
return ("wait", "too soon to judge, still inside the grace window")
return ("stuck", f"no confirmation note after {int(waited)} minutes")
def store_verdict(stuck_count):
if stuck_count >= MIN_STUCK_TO_ALARM:
return ("alarm", f"{stuck_count} orders stuck, WP-Cron is likely disabled or starved")
if stuck_count > 0:
return ("watch", f"{stuck_count} order(s) stuck, below the alarm threshold")
return ("healthy", "no stuck orders in this window")
def leave_diagnostic_note(order_id, reason):
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"WP-Cron watchdog: {reason}. WP-Cron appears disabled or starved on "
f"this store. Check DISABLE_WP_CRON in wp-config.php and whether a real "
f"system cron calls wp-cron.php."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
now = datetime.now(timezone.utc)
stuck_orders = []
for order in recent_orders(LOOKBACK_HOURS):
notes = order_notes(order["id"])
action, reason = decide(order, notes, now, STUCK_MINUTES)
if action == "stuck":
log.warning("Order %s: %s", order["id"], reason)
stuck_orders.append((order, reason))
verdict, message = store_verdict(len(stuck_orders))
log.info("Verdict: %s. %s", verdict, message)
if verdict == "alarm" and stuck_orders and not DRY_RUN:
oldest_order, reason = stuck_orders[0]
leave_diagnostic_note(oldest_order["id"], reason)
log.info("Done. %s", "would flag" if DRY_RUN and verdict == "alarm" else "checked")
if __name__ == "__main__":
run()
/**
* Detect a WooCommerce store where WP-Cron is disabled or starved, so order
* emails and the Action Scheduler queue never fire. Read only by default.
* Run on a real system cron schedule, since this must not depend on WP-Cron.
*/
import { pathToFileURL } from "node:url";
const WOO_URL = (process.env.WOO_STORE_URL || "https://example.com").replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY || "ck_dummy"}:${process.env.WOO_CONSUMER_SECRET || "cs_dummy"}`
).toString("base64");
const LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 6);
const STUCK_MINUTES = Number(process.env.STUCK_MINUTES || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EMAIL_NOTE_MARKERS = ["email sent", "order status changed", "note sent to customer"];
const MIN_STUCK_TO_ALARM = 3;
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function recentOrders(lookbackHours) {
const since = new Date(Date.now() - lookbackHours * 3600000).toISOString();
return woo(`/orders?after=${since}&per_page=100&orderby=date&order=asc`);
}
async function orderNotes(orderId) {
return woo(`/orders/${orderId}/notes`);
}
export function intentIdOf(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
const tid = order.transaction_id;
return tid && tid.startsWith("pi_") ? tid : null;
}
export function hasEmailNote(notes) {
return notes.some((note) => {
const text = (note.note || "").toLowerCase();
return note.customer_note || EMAIL_NOTE_MARKERS.some((m) => text.includes(m));
});
}
export function minutesWaiting(order, now) {
const raw = order.date_created_gmt;
const created = new Date(raw.endsWith("Z") ? raw : raw + "Z");
return (now.getTime() - created.getTime()) / 60000;
}
export function decide(order, notes, now, stuckMinutes) {
const waited = minutesWaiting(order, now);
if (hasEmailNote(notes)) return ["ok", "a confirmation note already exists"];
if (waited < stuckMinutes) return ["wait", "too soon to judge, still inside the grace window"];
return ["stuck", `no confirmation note after ${Math.floor(waited)} minutes`];
}
export function storeVerdict(stuckCount) {
if (stuckCount >= MIN_STUCK_TO_ALARM) {
return ["alarm", `${stuckCount} orders stuck, WP-Cron is likely disabled or starved`];
}
if (stuckCount > 0) {
return ["watch", `${stuckCount} order(s) stuck, below the alarm threshold`];
}
return ["healthy", "no stuck orders in this window"];
}
async function leaveDiagnosticNote(orderId, reason) {
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `WP-Cron watchdog: ${reason}. WP-Cron appears disabled or starved on this store. ` +
`Check DISABLE_WP_CRON in wp-config.php and whether a real system cron calls wp-cron.php.`,
}),
});
}
export async function run() {
const now = new Date();
const stuckOrders = [];
for (const order of await recentOrders(LOOKBACK_HOURS)) {
const notes = await orderNotes(order.id);
const [action, reason] = decide(order, notes, now, STUCK_MINUTES);
if (action === "stuck") {
console.warn(`Order ${order.id}: ${reason}`);
stuckOrders.push([order, reason]);
}
}
const [verdict, message] = storeVerdict(stuckOrders.length);
console.log(`Verdict: ${verdict}. ${message}`);
if (verdict === "alarm" && stuckOrders.length && !DRY_RUN) {
const [oldestOrder, reason] = stuckOrders[0];
await leaveDiagnosticNote(oldestOrder.id, reason);
}
console.log(`Done. ${DRY_RUN && verdict === "alarm" ? "would flag" : "checked"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule and the store-level verdict are the parts most worth testing, because together they decide whether the alarm fires. Because we kept decide and storeVerdict pure, the tests need no network and no WordPress site. They just feed in plain objects and a fixed clock, and check the action.
from datetime import datetime, timedelta, timezone
from cron_watchdog import decide, store_verdict, intent_id_of
def order(created_minutes_ago, **over):
now = datetime(2026, 7, 10, 12, 0, 0, tzinfo=timezone.utc)
created = now - timedelta(minutes=created_minutes_ago)
base = {"id": 1, "date_created_gmt": created.isoformat()}
base.update(over)
return base, now
def test_ok_when_email_note_present():
o, now = order(60)
notes = [{"note": "Order status changed from processing to completed."}]
assert decide(o, notes, now, 30)[0] == "ok"
def test_wait_when_inside_grace_window():
o, now = order(10)
assert decide(o, [], now, 30)[0] == "wait"
def test_stuck_when_past_threshold_with_no_note():
o, now = order(90)
assert decide(o, [], now, 30)[0] == "stuck"
def test_customer_note_flag_counts_as_ok():
o, now = order(90)
notes = [{"note": "Thanks!", "customer_note": True}]
assert decide(o, notes, now, 30)[0] == "ok"
def test_verdict_alarm_at_threshold():
assert store_verdict(3)[0] == "alarm"
def test_verdict_watch_below_threshold():
assert store_verdict(1)[0] == "watch"
def test_verdict_healthy_when_zero():
assert store_verdict(0)[0] == "healthy"
def test_intent_id_from_meta():
o = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
assert intent_id_of(o) == "pi_123"
def test_intent_id_falls_back_to_transaction_id():
o = {"meta_data": [], "transaction_id": "pi_456"}
assert intent_id_of(o) == "pi_456"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, storeVerdict, intentIdOf } from "./cron-watchdog.js";
const NOW = new Date("2026-07-10T12:00:00Z");
function order(createdMinutesAgo, over = {}) {
const created = new Date(NOW.getTime() - createdMinutesAgo * 60000);
return { id: 1, date_created_gmt: created.toISOString(), ...over };
}
test("ok when email note present", () => {
const notes = [{ note: "Order status changed from processing to completed." }];
assert.equal(decide(order(60), notes, NOW, 30)[0], "ok");
});
test("wait when inside grace window", () => {
assert.equal(decide(order(10), [], NOW, 30)[0], "wait");
});
test("stuck when past threshold with no note", () => {
assert.equal(decide(order(90), [], NOW, 30)[0], "stuck");
});
test("customer note flag counts as ok", () => {
const notes = [{ note: "Thanks!", customer_note: true }];
assert.equal(decide(order(90), notes, NOW, 30)[0], "ok");
});
test("verdict alarm at threshold", () => {
assert.equal(storeVerdict(3)[0], "alarm");
});
test("verdict watch below threshold", () => {
assert.equal(storeVerdict(1)[0], "watch");
});
test("verdict healthy when zero", () => {
assert.equal(storeVerdict(0)[0], "healthy");
});
test("intentIdOf from meta", () => {
assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});
test("intentIdOf falls back to transaction_id", () => {
assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});
Case studies
The migration that turned off the clock
A store moved to a new managed WordPress host. The new host set DISABLE_WP_CRON to true, since that is their standard performance setting, and added a system cron entry to replace it, except the entry pointed at the old domain from the migration checklist. Every scheduled email and every Action Scheduler job quietly stopped.
Nobody noticed for several days, until refund requests started mentioning "never got my receipt." The watchdog, run from a completely separate server, caught the backlog on its first pass and pointed straight at the wrong cron target.
The store that cached its way into silence
An aggressive CDN configuration cached every page, including the homepage and product pages, so almost no request ever reached WordPress itself. WP-Cron only fires on an actual WordPress page load, so with caching this complete, days went by between triggers.
The fix was a one-line system cron entry calling wp-cron.php directly every few minutes, bypassing the cache entirely. The watchdog's verdict went from alarm to healthy on the very next scheduled run.
Once this runs on its own schedule, a disabled or starved WP-Cron stops being a mystery that support discovers from angry emails. It becomes a clear alarm with a number attached, checked from outside WordPress so it never depends on the very thing it is watching. Keep it running even after you add a real system cron entry, since caching layers, host changes, and plugin conflicts can quietly disable it again.
FAQ
Why are my WooCommerce order emails not sending at all?
Most transactional WooCommerce emails and the whole Action Scheduler queue are dispatched through WP-Cron, which only runs when a visitor loads a page on your site. If DISABLE_WP_CRON is set to true and no real system cron calls wp-cron.php, or if a caching layer serves cached pages without ever triggering WordPress, the queue piles up and nothing goes out.
Is it safe to detect this with a script instead of just checking the WordPress admin?
Yes. A read-only script that calls the WooCommerce REST API to look at order timestamps and order notes cannot make things worse. It only writes anything when you turn off dry run mode, and even then it only adds a diagnostic note or nudges the scheduler, it never changes an order's status or its total.
How often should the WP-Cron health check run?
Every fifteen to thirty minutes from a real system cron is enough. It is a monitor, not a mail sender, so running it more often just gets you a faster alert when the queue backs up again.
Related field notes
Citations
On the problem:
- WordPress Developer Resources: how wp-cron.php works and why it is a pseudo-cron triggered by page loads. developer.wordpress.org/plugins/cron
- WordPress support: DISABLE_WP_CRON and setting up a real system cron to replace it. wordpress.org/documentation/article/disable-wp-cron
- Action Scheduler documentation: how WooCommerce's background queue depends on WP-Cron and what starves it. actionscheduler.org/faq
On the solution:
- WooCommerce REST API: list orders and read order notes. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce documentation: email settings and how order status changes trigger notifications. woocommerce.com/document/email-faq
- Stripe API: retrieve a PaymentIntent to confirm the payment side independently of the email queue. docs.stripe.com/api/payment_intents/retrieve
Stuck on a tricky one?
If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway 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 fix your stuck orders?
If this saved you a pile of support tickets or a chargeback, 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