Repair WooCommerce core: scheduling, cron, and email

Completed actions never purged

Open the Scheduled Actions screen and filter by Complete. On a store that has run for a year or two, you will often find hundreds of thousands, sometimes millions, of actions sitting there finished, doing nothing, taking up space. Action Scheduler is supposed to clean these up on its own. On a lot of stores, it quietly falls behind and never catches up. Here is why that happens and a small script that safely purges the old finished ones once it confirms, through Stripe, that nothing important is still riding on them.

Python and Node.js Runs on a schedule Safe by default (dry run)
A close up of a book with writing on it
Photo by Brett Jordan on Unsplash
The short answer

Action Scheduler's built in cleanup only removes old completed and canceled actions if WP-Cron fires reliably and the daily batch is large enough to outrun new completions. On a busy or migrated store it usually is not, so the actionscheduler_actions table and the order meta tied to old finished payment checks grow forever. Run a small Python or Node.js job on a schedule that finds orders whose reconciliation is long finished, re-confirms with Stripe that the PaymentIntent is still succeeded with the right amount, and only then purges the stale leftover meta. Full code, tests, and a dry run guard are below.

The problem in plain words

Every time WooCommerce schedules work, an email, a stock check, a subscription renewal, a Stripe reconciliation pass, it writes a row into the actionscheduler_actions table. When the job finishes, WooCommerce does not delete that row. It marks it Complete and leaves it there, because Action Scheduler ships with its own daily cleanup that is supposed to sweep up anything older than thirty days.

That cleanup is itself just another scheduled action. It only runs if WP-Cron actually fires, and it only deletes a fixed batch size per run. If cron is disabled, overloaded, or firing late, or if the store produces more completed actions per day than the cleanup batch can remove, the backlog never shrinks. It only grows. Alongside it, order meta written by past reconciliation and webhook-repair passes, the same kind of scripts covered in the other field notes on this site, keeps accumulating too, since nothing ever goes back to remove the meta once the order it describes is long settled.

Actions run and complete Row marked status: complete cleanup falls behind Never purged rows pile up Table bloats queue slows
Every completed action is marked done but stays in the table. If the daily cleanup cannot keep up, the backlog only ever grows.

Why it happens

The Action Scheduler docs describe the cleanup as a best effort background pass, not a guarantee, and a few common conditions push it past its limit:

This shows up as a slow admin, slow REST API responses, and a scheduled actions table that dwarfs every other table in the database. The fix has to be careful, since Action Scheduler is what runs subscription renewals and emails. Deleting the wrong row, or a row that is not actually finished, can silently break a customer's billing.

The key insight

Nothing here should ever be deleted just because it looks old. An action is only safe to purge once it is marked complete or canceled and is older than your retention window. Meta on an order tied to a payment is only safe to purge once Stripe still confirms the PaymentIntent is succeeded with the right amount, so you never erase the one record that proves a customer paid.

The fix, as a flow

We do not touch anything pending or in-progress. We add a job that runs once a day, lists orders whose linked reconciliation is long finished and past a safe retention window, re-confirms the linked PaymentIntent with Stripe, and only purges the stale meta when Stripe still agrees the order is genuinely paid and settled. Anything Stripe cannot confirm is left alone and logged for a person to look at.

Scheduled job once a day List settled orders older than retention window Re-confirm intent with Stripe Still succeeded and matches? yes no, keep Purge stale meta keep order, drop old rows
The purge job never touches an order itself. It only removes leftover reconciliation meta once Stripe still confirms the payment behind it, and it always keeps anything it cannot confirm.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair, a consumer key and a consumer secret, with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
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 RETENTION_DAYS="90"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
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 RETENTION_DAYS="90"
export DRY_RUN="true"   // start safe, change to false to write
2

List orders old enough to be worth purging

Ask the WooCommerce REST API for paid orders modified before your retention window. A ninety day window is a reasonable default, since it comfortably outlives any refund or dispute period. Anything newer than that is left alone completely, no matter what it contains.

step2.py
import os, datetime, 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"])
PAID_STATUSES = {"processing", "completed"}

def settled_orders(retention_days):
    before = (datetime.date.today() - datetime.timedelta(days=retention_days)).isoformat() + "T00:00:00"
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": ",".join(PAID_STATUSES), "before": before, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1
step2.js
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");
const PAID_STATUSES = ["processing", "completed"];

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* settledOrders(retentionDays) {
  const before = new Date(Date.now() - retentionDays * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=${PAID_STATUSES.join(",")}&before=${before}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}
3

Find the reconciliation meta worth purging

Look for the leftover meta keys that past reconciliation and webhook repair runs leave on an order, things like a stored intent id used only for a one time lookup, or a repair note flag. Read the PaymentIntent id from _stripe_intent_id meta, or fall back to transaction_id when it looks like a PaymentIntent rather than a charge.

step3.py
PURGEABLE_META_KEYS = {"_reconciler_checked_at", "_webhook_repair_log", "_payment_verify_pass"}

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 purgeable_meta_ids(order):
    return [m["id"] for m in (order.get("meta_data") or []) if m.get("key") in PURGEABLE_META_KEYS]
step3.js
const PURGEABLE_META_KEYS = new Set(["_reconciler_checked_at", "_webhook_repair_log", "_payment_verify_pass"]);

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 purgeableMetaIds(order) {
  return (order.meta_data || []).filter((m) => PURGEABLE_META_KEYS.has(m.key)).map((m) => m.id);
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order, its linked Stripe intent, the retention window, 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. Skip anything with nothing to purge, or anything too recent, or anything Stripe can no longer confirm. Otherwise it is safe to purge the stale meta.

decide.py
from datetime import datetime, timedelta, timezone

PAID_STATUSES = {"processing", "completed"}

def order_amount_minor(order):
    # Works for two decimal currencies. Zero decimal currencies (JPY and friends)
    # have their own guide, since 50.00 is wrong for those.
    return round(float(order["total"]) * 100)

def decide(order, intent, retention_days, now=None):
    now = now or datetime.now(timezone.utc)
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order not in a settled state")
    meta_ids = [m["id"] for m in (order.get("meta_data") or [])
                if m.get("key") in {"_reconciler_checked_at", "_webhook_repair_log", "_payment_verify_pass"}]
    if not meta_ids:
        return ("skip", "nothing to purge")
    modified = datetime.fromisoformat(order["date_modified_gmt"].replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
    if now - modified < timedelta(days=retention_days):
        return ("skip", "inside the retention window")
    if intent is None or intent.get("status") != "succeeded":
        return ("keep", "Stripe no longer confirms a succeeded payment")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("keep", "amount no longer matches the Stripe charge")
    return ("purge", "settled, past retention, Stripe still confirms the payment")
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);
const PURGEABLE_KEYS = new Set(["_reconciler_checked_at", "_webhook_repair_log", "_payment_verify_pass"]);

export function orderAmountMinor(order) {
  // Works for two decimal currencies. Zero decimal currencies (JPY and friends)
  // have their own guide, since 50.00 is wrong for those.
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent, retentionDays, now = new Date()) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a settled state"];
  const metaIds = (order.meta_data || []).filter((m) => PURGEABLE_KEYS.has(m.key)).map((m) => m.id);
  if (!metaIds.length) return ["skip", "nothing to purge"];
  const modified = new Date(order.date_modified_gmt.endsWith("Z") ? order.date_modified_gmt : order.date_modified_gmt + "Z");
  if (now - modified < retentionDays * 86400000) return ["skip", "inside the retention window"];
  if (!intent || intent.status !== "succeeded") return ["keep", "Stripe no longer confirms a succeeded payment"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["keep", "amount no longer matches the Stripe charge"];
  }
  return ["purge", "settled, past retention, Stripe still confirms the payment"];
}
5

Purge only the stale meta, never the order

When the action is purge, delete just the leftover reconciliation meta keys through the REST API and add a short order note explaining what was removed and why. The order itself, its status, its total, its transaction id, is never touched. Only the scratch meta that past scripts left behind goes away.

apply.py
def purge_meta(order_id, meta_ids):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
        json={"meta_data": [{"id": mid, "value": None} for mid in meta_ids]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": f"Purged {len(meta_ids)} stale reconciliation meta row(s) past the retention "
                      f"window. Stripe still confirms the payment, so the order itself is unchanged."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function purgeMeta(orderId, metaIds) {
  await woo(`/orders/${orderId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: metaIds.map((id) => ({ id, value: null })) }),
  });
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Purged ${metaIds.length} stale reconciliation meta row(s) past the retention ` +
            `window. Stripe still confirms the payment, so the order itself is unchanged.`,
    }),
  });
}
6

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 what it would purge. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once a day.

Run it safe

Always start with DRY_RUN=true. This job writes to real orders, so you want to see its plan before it acts. Once the report looks right for a few runs, turn it off.

The full code

Here is the complete purge job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only ever purges meta that is already stale and confirmed by Stripe.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

purge_completed_meta.py
"""Purge stale WooCommerce reconciliation meta left behind on long settled orders.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
from datetime import datetime, timedelta, timezone
import stripe
import requests
from requests.auth import HTTPBasicAuth

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

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
RETENTION_DAYS = int(os.environ.get("RETENTION_DAYS", "90"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}
PURGEABLE_META_KEYS = {"_reconciler_checked_at", "_webhook_repair_log", "_payment_verify_pass"}


def settled_orders(retention_days):
    before = (datetime.now(timezone.utc) - timedelta(days=retention_days)).strftime("%Y-%m-%dT%H:%M:%S")
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": ",".join(PAID_STATUSES), "before": before, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


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 purgeable_meta_ids(order):
    return [m["id"] for m in (order.get("meta_data") or []) if m.get("key") in PURGEABLE_META_KEYS]


def order_amount_minor(order):
    return round(float(order["total"]) * 100)


def decide(order, intent, retention_days, now=None):
    now = now or datetime.now(timezone.utc)
    if order["status"] not in PAID_STATUSES:
        return ("skip", "order not in a settled state")
    meta_ids = purgeable_meta_ids(order)
    if not meta_ids:
        return ("skip", "nothing to purge")
    modified = datetime.fromisoformat(order["date_modified_gmt"].replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
    if now - modified < timedelta(days=retention_days):
        return ("skip", "inside the retention window")
    if intent is None or intent.get("status") != "succeeded":
        return ("keep", "Stripe no longer confirms a succeeded payment")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("keep", "amount no longer matches the Stripe charge")
    return ("purge", "settled, past retention, Stripe still confirms the payment")


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def purge_meta(order_id, meta_ids):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
        json={"meta_data": [{"id": mid, "value": None} for mid in meta_ids]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": f"Purged {len(meta_ids)} stale reconciliation meta row(s) past the retention "
                      f"window. Stripe still confirms the payment, so the order itself is unchanged."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    purged = 0
    kept = 0
    for order in settled_orders(RETENTION_DAYS):
        intent = get_intent(intent_id_of(order))
        action, reason = decide(order, intent, RETENTION_DAYS)
        if action == "skip":
            continue
        if action == "keep":
            log.warning("Order %s: %s. Leaving meta in place.", order["id"], reason)
            kept += 1
            continue
        meta_ids = purgeable_meta_ids(order)
        log.info("Order %s: %s. %s", order["id"], reason, "would purge" if DRY_RUN else "purging")
        if not DRY_RUN:
            purge_meta(order["id"], meta_ids)
        purged += 1
    log.info("Done. %d order(s) %s, %d kept for review.", purged, "to purge" if DRY_RUN else "purged", kept)


if __name__ == "__main__":
    run()
purge-completed-meta.js
/**
 * Purge stale WooCommerce reconciliation meta left behind on long settled orders.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/completed-actions-never-purged/
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test_dummy");
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 RETENTION_DAYS = Number(process.env.RETENTION_DAYS || 90);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);
const PURGEABLE_KEYS = new Set(["_reconciler_checked_at", "_webhook_repair_log", "_payment_verify_pass"]);

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 purgeableMetaIds(order) {
  return (order.meta_data || []).filter((m) => PURGEABLE_KEYS.has(m.key)).map((m) => m.id);
}

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent, retentionDays, now = new Date()) {
  if (!PAID_STATUSES.has(order.status)) return ["skip", "order not in a settled state"];
  const metaIds = purgeableMetaIds(order);
  if (!metaIds.length) return ["skip", "nothing to purge"];
  const modified = new Date(order.date_modified_gmt.endsWith("Z") ? order.date_modified_gmt : order.date_modified_gmt + "Z");
  if (now - modified < retentionDays * 86400000) return ["skip", "inside the retention window"];
  if (!intent || intent.status !== "succeeded") return ["keep", "Stripe no longer confirms a succeeded payment"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["keep", "amount no longer matches the Stripe charge"];
  }
  return ["purge", "settled, past retention, Stripe still confirms the payment"];
}

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 getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function* settledOrders(retentionDays) {
  const before = new Date(Date.now() - retentionDays * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=${[...PAID_STATUSES].join(",")}&before=${before}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function purgeMeta(orderId, metaIds) {
  await woo(`/orders/${orderId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: metaIds.map((id) => ({ id, value: null })) }),
  });
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Purged ${metaIds.length} stale reconciliation meta row(s) past the retention ` +
            `window. Stripe still confirms the payment, so the order itself is unchanged.`,
    }),
  });
}

export async function run() {
  let purged = 0;
  let kept = 0;
  for await (const order of settledOrders(RETENTION_DAYS)) {
    const intent = await getIntent(intentIdOf(order));
    const [action, reason] = decide(order, intent, RETENTION_DAYS);
    if (action === "skip") continue;
    if (action === "keep") {
      console.warn(`Order ${order.id}: ${reason}. Leaving meta in place.`);
      kept++;
      continue;
    }
    const metaIds = purgeableMetaIds(order);
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would purge" : "purging"}`);
    if (!DRY_RUN) await purgeMeta(order.id, metaIds);
    purged++;
  }
  console.log(`Done. ${purged} order(s) ${DRY_RUN ? "to purge" : "purged"}, ${kept} kept for review.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides what gets deleted. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects, a fixed clock, and checks the action.

test_completed_purge_decide.py
from datetime import datetime, timezone
from purge_completed_meta import decide, intent_id_of, purgeable_meta_ids

NOW = datetime(2026, 7, 10, tzinfo=timezone.utc)


def order(**over):
    base = {
        "status": "completed",
        "total": "50.00",
        "date_modified_gmt": "2026-01-01T00:00:00",
        "meta_data": [{"id": 1, "key": "_reconciler_checked_at", "value": "2026-01-01"}],
    }
    base.update(over)
    return base


def intent(**over):
    base = {"status": "succeeded", "amount_received": 5000}
    base.update(over)
    return base


def test_purge_when_settled_stale_and_confirmed():
    assert decide(order(), intent(), 90, now=NOW)[0] == "purge"


def test_skip_when_order_not_settled():
    assert decide(order(status="pending"), intent(), 90, now=NOW)[0] == "skip"


def test_skip_when_nothing_to_purge():
    assert decide(order(meta_data=[]), intent(), 90, now=NOW)[0] == "skip"


def test_skip_when_inside_retention_window():
    recent = order(date_modified_gmt="2026-07-01T00:00:00")
    assert decide(recent, intent(), 90, now=NOW)[0] == "skip"


def test_keep_when_stripe_no_longer_confirms():
    assert decide(order(), None, 90, now=NOW)[0] == "keep"


def test_keep_when_amount_no_longer_matches():
    assert decide(order(total="80.00"), intent(), 90, now=NOW)[0] == "keep"


def test_intent_id_from_meta():
    o = order(meta_data=[{"id": 2, "key": "_stripe_intent_id", "value": "pi_123"}], transaction_id="")
    assert intent_id_of(o) == "pi_123"


def test_purgeable_meta_ids_only_known_keys():
    o = order(meta_data=[
        {"id": 1, "key": "_reconciler_checked_at", "value": "x"},
        {"id": 2, "key": "_billing_address_index", "value": "keep me"},
    ])
    assert purgeable_meta_ids(o) == [1]
purge-completed-meta.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, purgeableMetaIds } from "./purge-completed-meta.js";

const NOW = new Date("2026-07-10T00:00:00Z");

const order = (over = {}) => ({
  status: "completed",
  total: "50.00",
  date_modified_gmt: "2026-01-01T00:00:00",
  meta_data: [{ id: 1, key: "_reconciler_checked_at", value: "2026-01-01" }],
  ...over,
});

const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });

test("purge when settled, stale, and confirmed", () => {
  assert.equal(decide(order(), intent(), 90, NOW)[0], "purge");
});

test("skip when order not settled", () => {
  assert.equal(decide(order({ status: "pending" }), intent(), 90, NOW)[0], "skip");
});

test("skip when nothing to purge", () => {
  assert.equal(decide(order({ meta_data: [] }), intent(), 90, NOW)[0], "skip");
});

test("skip when inside retention window", () => {
  const recent = order({ date_modified_gmt: "2026-07-01T00:00:00" });
  assert.equal(decide(recent, intent(), 90, NOW)[0], "skip");
});

test("keep when Stripe no longer confirms", () => {
  assert.equal(decide(order(), null, 90, NOW)[0], "keep");
});

test("keep when amount no longer matches", () => {
  assert.equal(decide(order({ total: "80.00" }), intent(), 90, NOW)[0], "keep");
});

test("intentIdOf from meta", () => {
  const o = order({ meta_data: [{ id: 2, key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" });
  assert.equal(intentIdOf(o), "pi_123");
});

test("purgeableMetaIds keeps only known keys", () => {
  const o = order({
    meta_data: [
      { id: 1, key: "_reconciler_checked_at", value: "x" },
      { id: 2, key: "_billing_address_index", value: "keep me" },
    ],
  });
  assert.deepEqual(purgeableMetaIds(o), [1]);
});

Case studies

Migration backlog

The store that imported five years of orders overnight

A merchant moved platforms and imported five years of historical orders in one batch. Every import triggered a reconciliation pass, and the daily Action Scheduler cleanup, sized for normal traffic, never had a chance to work through the sudden spike. The actions table hit four gigabytes within a month.

Running the purge job in dry run first showed which settled orders were safe to clean. After a week of daily runs the table shrank back to a manageable size, and admin pages that used to take seconds to load were back to normal.

Disabled cron

The store that turned off WP-Cron for performance

A dev team disabled WP-Cron and moved scheduling to a real system cron job, but the new cron entry only triggered the queue runner, not Action Scheduler's own cleanup hook. Completed actions kept accumulating for over a year before anyone noticed the queue had slowed to a crawl.

The retention based purge caught up the backlog safely, since it never depended on the same cleanup hook that had been silently skipped, and it left every order and every action itself completely untouched.

What good looks like

After this runs on a schedule, old finished reconciliation meta stops accumulating no matter what the built in cleanup is doing. The worst case becomes a short delay before the next daily run catches up. Keep it running even after you fix WP-Cron or resize the cleanup batch, since a busy store will always produce more completed work than you expect.

FAQ

Why does WooCommerce keep millions of completed scheduled actions?

Action Scheduler ships with a daily cleanup hook that should delete old completed and canceled actions, but it only runs if WP-Cron actually fires and the batch size is large enough to keep up. On a busy store with disabled or overloaded cron, or after a migration, finished actions build up faster than the cleanup can remove them, and the table just keeps growing.

Is it safe to delete old completed actions and their related order meta?

Yes, once the action finished well outside your retention window and, for anything tied to a payment, Stripe still confirms the PaymentIntent as succeeded with the right amount. A purge job should only ever remove rows that are already marked complete or canceled, never anything pending or in-progress.

How often should the purge job run?

Once a day is enough for most stores. It only looks at actions and order meta that are already finished and older than your retention window, so running it daily keeps the table small without ever touching a job that is still doing work.

Related field notes

Citations

On the problem:

  1. Action Scheduler documentation: how completed and canceled actions are cleaned up, and the limits of the built in retention job. actionscheduler.org/faq
  2. WooCommerce developer docs: the actionscheduler_actions table and why it can grow very large on active stores. developer.woocommerce.com
  3. WordPress support discussion: Action Scheduler tables reaching many gigabytes and slowing the site. wordpress.org/support

On the solution:

  1. Stripe API: retrieve a PaymentIntent to re-confirm its current status before trusting old local data. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce REST API: update an order's meta data and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce REST API: list orders with status and date filters for building a retention query. woocommerce.github.io/woocommerce-rest-api-docs

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.

Contact me on LinkedIn

Did this clear out your bloated tables?

If this saved you a slow admin or a database that would not stop growing, 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 WooCommerce and Stripe field notes