Repair WooCommerce core: HPOS and housekeeping
Checkout-draft orders pile up
Open your orders table and filter by status. If your store uses the block based checkout, there is a good chance you will find hundreds or thousands of orders sitting in checkout-draft, most of them days or months old, none of them ever paid. Nobody deleted them because nothing in WooCommerce core ever does. Here is why they build up and a small script that clears out the stale ones without touching a checkout that is still in progress.
The block based checkout creates a real order in the checkout-draft status the instant a shopper opens the checkout page, and WooCommerce core never cleans those up on its own. Run a small Python or Node.js job on a schedule that finds drafts older than a safe window, confirms with Stripe that no real payment ever landed on them, cancels any PaymentIntent still open, and deletes the draft. Full code, tests, and a dry run guard are below.
The problem in plain words
Modern WooCommerce checkout is built on the Store API, and the Store API needs an order to exist before it can hold the cart, apply a coupon, or calculate shipping. So the moment a shopper lands on the checkout page, WooCommerce quietly creates an order with the status checkout-draft. No name, no address, no payment, just a placeholder that will become a real order if the shopper finishes.
Most shoppers do not finish. They close the tab, get distracted, or were only ever checking the shipping cost. That draft order is left behind exactly as it was, and nothing in WooCommerce core goes back to remove it. Over months, a busy store can end up with more checkout-draft rows than real orders, all of them dead weight in the orders table.
Why it happens
This is not a bug so much as a gap. The WooCommerce Store API docs describe the checkout-draft status as an intentional part of how the block checkout works, but core ships no scheduled cleanup for the drafts it leaves behind. A few things make the pile up worse:
- Search bots, uptime monitors, and link previews that load the checkout page can each spin up a fresh draft order without a real shopper ever involved.
- High Performance Order Storage (HPOS) keeps orders in their own table, so a huge stack of drafts does not get mixed in with the classic post cleanup tools store owners already know.
- Some stores start a PaymentIntent on the same page load, so a stale draft can leave an open PaymentIntent behind on the Stripe side too, one nobody is watching.
- Analytics, order count widgets, and export tools that do not filter out checkout-draft status will quietly include these rows in totals, which is its own reporting headache.
WooCommerce's own documentation on the Store API confirms that draft orders are expected to be temporary and are the store's responsibility to clear out, since core intentionally leaves the decision of when a draft is truly abandoned to the site.
A checkout-draft order is not a customer record worth keeping, it is scratch space. The only two things worth checking before deleting one are its age and whether Stripe shows a real payment attached to it. If it is old and there is no payment, it is safe to remove.
The fix, as a flow
We do not touch the live checkout, and we never delete anything that is still fresh. We add a job that runs once a day, lists checkout-draft orders, and for each one checks whether it is older than a safe window and whether Stripe shows a succeeded or processing PaymentIntent on it. If the draft is stale and unpaid, we cancel any open PaymentIntent so it cannot be captured later, then delete the draft.
Build it step by step
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.
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 STALE_AFTER_HOURS="24"
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 STALE_AFTER_HOURS="24"
export DRY_RUN="true" // start safe, change to false to write
List the checkout-draft orders
Ask the WooCommerce REST API for orders whose status is checkout-draft, paging through the results. This status is a real WooCommerce order status, so the REST API filters on it exactly like any other, and it works the same whether the store keeps orders in HPOS tables or the legacy post tables.
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"])
DRAFT_STATUS = "checkout-draft"
def stale_drafts():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": DRAFT_STATUS, "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
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 DRAFT_STATUS = "checkout-draft";
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* staleDrafts() {
let page = 1;
while (true) {
const batch = await woo(`/orders?status=${DRAFT_STATUS}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
Read the linked Stripe PaymentIntent, if any
Some drafts started a PaymentIntent before the shopper left. The WooCommerce Stripe gateway saves that intent's ID on the order, in meta _stripe_intent_id, or occasionally as the order's transaction_id. Look up the intent so we can tell a truly empty draft from one where a payment might still be in flight.
import stripe
def intent_id_of(order):
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 get_intent(intent_id):
if not intent_id:
return None
try:
return stripe.PaymentIntent.retrieve(intent_id)
except stripe.error.InvalidRequestError:
return 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;
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
Decide, with one pure function
Keep the decision in its own function that takes an order, its linked intent, 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 that is not a draft, or is still fresh. Keep anything with a real payment behind it. Otherwise it is safe to purge.
import datetime
DRAFT_STATUS = "checkout-draft"
PAID_INTENT_STATUSES = {"succeeded", "processing"}
def age_hours(order, now_ts):
modified = order.get("date_modified_gmt") or order.get("date_created_gmt")
if not modified:
return 0
dt = datetime.datetime.fromisoformat(modified).replace(tzinfo=datetime.timezone.utc)
return (now_ts - dt.timestamp()) / 3600
def decide(order, intent, now_ts, stale_after_hours=24):
if order.get("status") != DRAFT_STATUS:
return ("skip", "order is not a checkout-draft")
hours_old = age_hours(order, now_ts)
if hours_old < stale_after_hours:
return ("skip", "draft is still fresh")
if intent is not None and intent.get("status") in PAID_INTENT_STATUSES:
return ("keep", "Stripe shows a real payment on this draft")
return ("purge", "stale draft with no completed payment")
const DRAFT_STATUS = "checkout-draft";
const PAID_INTENT_STATUSES = new Set(["succeeded", "processing"]);
export function ageHours(order, nowTs) {
const modified = order.date_modified_gmt || order.date_created_gmt;
if (!modified) return 0;
const dt = Date.parse(modified.endsWith("Z") ? modified : `${modified}Z`);
return (nowTs - dt / 1000) / 3600;
}
export function decide(order, intent, nowTs, staleAfterHours = 24) {
if (order.status !== DRAFT_STATUS) return ["skip", "order is not a checkout-draft"];
const hoursOld = ageHours(order, nowTs);
if (hoursOld < staleAfterHours) return ["skip", "draft is still fresh"];
if (intent && PAID_INTENT_STATUSES.has(intent.status)) {
return ["keep", "Stripe shows a real payment on this draft"];
}
return ["purge", "stale draft with no completed payment"];
}
Cancel the open intent, then delete the draft
When the action is purge, first check whether the linked PaymentIntent is still in an open state, like requires_payment_method or requires_action, and cancel it on Stripe so it can never be confirmed later by a stray retry. Then delete the WooCommerce order for good, using force=true so it skips the trash and does not linger either.
OPEN_INTENT_STATUSES = {
"requires_payment_method", "requires_confirmation",
"requires_action", "requires_capture",
}
def cancelable_intent(intent):
return intent is not None and intent.get("status") in OPEN_INTENT_STATUSES
def purge(order, intent):
if cancelable_intent(intent):
stripe.PaymentIntent.cancel(intent["id"])
requests.delete(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
params={"force": "true"},
auth=AUTH, timeout=30,
).raise_for_status()
const OPEN_INTENT_STATUSES = new Set([
"requires_payment_method", "requires_confirmation",
"requires_action", "requires_capture",
]);
export function cancelableIntent(intent) {
return Boolean(intent && OPEN_INTENT_STATUSES.has(intent.status));
}
async function purge(order, intent) {
if (cancelableIntent(intent)) {
await stripe.paymentIntents.cancel(intent.id);
}
await woo(`/orders/${order.id}?force=true`, { method: "DELETE" });
}
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 remove. Read the output, trust it, then switch it off to let it delete. Run it on a schedule with cron once a day, since drafts build up slowly.
Always start with DRY_RUN=true. This job deletes real order rows, so you want to see its plan before it acts. Once the report looks right for a few days, turn it off.
The full code
Here is the complete cleanup 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 touches drafts that are old and unpaid.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Clean out stale WooCommerce checkout-draft orders that never convert to a real order.
The block based checkout (Store API) creates an order in the "checkout-draft" status
the moment a shopper opens the checkout page, before they pay or even enter an
address. Most shoppers who bounce leave that draft behind forever, since nothing in
WooCommerce core ever removes it. This walks old checkout-draft orders, checks
whether an actual payment ever happened, and trashes the ones that are safe to
remove. It also cancels any Stripe PaymentIntent still sitting open for that draft,
so it cannot be captured later by mistake. Read only by default. Run on a schedule.
"""
import os
import logging
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_checkout_drafts")
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"])
STALE_AFTER_HOURS = int(os.environ.get("STALE_AFTER_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
DRAFT_STATUS = "checkout-draft"
PAID_INTENT_STATUSES = {"succeeded", "processing"}
OPEN_INTENT_STATUSES = {"requires_payment_method", "requires_confirmation", "requires_action", "requires_capture"}
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 age_hours(order, now_ts):
modified = order.get("date_modified_gmt") or order.get("date_created_gmt")
if not modified:
return 0
import datetime
dt = datetime.datetime.fromisoformat(modified).replace(tzinfo=datetime.timezone.utc)
return (now_ts - dt.timestamp()) / 3600
def decide(order, intent, now_ts, stale_after_hours=STALE_AFTER_HOURS):
"""Pure decision: what to do with one checkout-draft order.
Returns (action, reason). Action is one of:
"skip" - not a draft, or too young to touch yet
"keep" - a real payment is in flight or already happened, never delete
"purge" - safe to trash, and cancel any open Stripe intent first
"""
if order.get("status") != DRAFT_STATUS:
return ("skip", "order is not a checkout-draft")
hours_old = age_hours(order, now_ts)
if hours_old < stale_after_hours:
return ("skip", "draft is still fresh")
if intent is not None and intent.get("status") in PAID_INTENT_STATUSES:
return ("keep", "Stripe shows a real payment on this draft")
return ("purge", "stale draft with no completed payment")
def cancelable_intent(intent):
"""True when the linked PaymentIntent is still open and safe to cancel."""
return intent is not None and intent.get("status") in OPEN_INTENT_STATUSES
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 stale_drafts():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": DRAFT_STATUS, "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 purge(order, intent):
if cancelable_intent(intent):
stripe.PaymentIntent.cancel(intent["id"])
requests.delete(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
params={"force": "true"},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
import time
now_ts = time.time()
purged = 0
kept = 0
for order in stale_drafts():
intent = get_intent(intent_id_of(order))
action, reason = decide(order, intent, now_ts)
if action == "skip":
continue
if action == "keep":
log.info("Order %s: %s. Leaving it alone.", order["id"], reason)
kept += 1
continue
log.info("Order %s: %s. %s", order["id"], reason, "would purge" if DRY_RUN else "purging")
if not DRY_RUN:
purge(order, intent)
purged += 1
log.info("Done. %d draft(s) %s, %d kept.", purged, "to purge" if DRY_RUN else "purged", kept)
if __name__ == "__main__":
run()
/**
* Clean out stale WooCommerce checkout-draft orders that never convert to a real order.
*
* The block based checkout (Store API) creates an order in the "checkout-draft" status
* the moment a shopper opens the checkout page, before they pay or even enter an
* address. Most shoppers who bounce leave that draft behind forever, since nothing in
* WooCommerce core ever removes it. This walks old checkout-draft orders, checks
* whether an actual payment ever happened, and trashes the ones that are safe to
* remove. It also cancels any Stripe PaymentIntent still sitting open for that draft,
* so it cannot be captured later by mistake. Read only by default. Run on a schedule.
*
* Guide: https://www.allanninal.dev/woocommerce/checkout-draft-orders-pile-up/
*/
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 STALE_AFTER_HOURS = Number(process.env.STALE_AFTER_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const DRAFT_STATUS = "checkout-draft";
const PAID_INTENT_STATUSES = new Set(["succeeded", "processing"]);
const OPEN_INTENT_STATUSES = new Set([
"requires_payment_method",
"requires_confirmation",
"requires_action",
"requires_capture",
]);
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 ageHours(order, nowTs) {
const modified = order.date_modified_gmt || order.date_created_gmt;
if (!modified) return 0;
const dt = Date.parse(modified.endsWith("Z") ? modified : `${modified}Z`);
return (nowTs - dt / 1000) / 3600;
}
export function decide(order, intent, nowTs, staleAfterHours = STALE_AFTER_HOURS) {
if (order.status !== DRAFT_STATUS) return ["skip", "order is not a checkout-draft"];
const hoursOld = ageHours(order, nowTs);
if (hoursOld < staleAfterHours) return ["skip", "draft is still fresh"];
if (intent && PAID_INTENT_STATUSES.has(intent.status)) {
return ["keep", "Stripe shows a real payment on this draft"];
}
return ["purge", "stale draft with no completed payment"];
}
export function cancelableIntent(intent) {
return Boolean(intent && OPEN_INTENT_STATUSES.has(intent.status));
}
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* staleDrafts() {
let page = 1;
while (true) {
const batch = await woo(`/orders?status=${DRAFT_STATUS}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function purge(order, intent) {
if (cancelableIntent(intent)) {
await stripe.paymentIntents.cancel(intent.id);
}
await woo(`/orders/${order.id}?force=true`, { method: "DELETE" });
}
export async function run() {
const nowTs = Date.now() / 1000;
let purged = 0;
let kept = 0;
for await (const order of staleDrafts()) {
const intent = await getIntent(intentIdOf(order));
const [action, reason] = decide(order, intent, nowTs);
if (action === "skip") continue;
if (action === "keep") {
console.log(`Order ${order.id}: ${reason}. Leaving it alone.`);
kept++;
continue;
}
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would purge" : "purging"}`);
if (!DRY_RUN) await purge(order, intent);
purged++;
}
console.log(`Done. ${purged} draft(s) ${DRY_RUN ? "to purge" : "purged"}, ${kept} kept.`);
}
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 which order rows get permanently deleted. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and a fixed clock, then checks the action.
import time
from purge_checkout_drafts import decide, intent_id_of, cancelable_intent
NOW = time.time()
def draft(hours_old=48, **over):
order = {
"status": "checkout-draft",
"date_modified_gmt": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(NOW - hours_old * 3600)),
}
order.update(over)
return order
def intent(**over):
base = {"id": "pi_1", "status": "requires_payment_method"}
base.update(over)
return base
def test_purge_when_stale_and_no_payment():
assert decide(draft(hours_old=48), None, NOW)[0] == "purge"
def test_skip_when_still_fresh():
assert decide(draft(hours_old=1), None, NOW)[0] == "skip"
def test_skip_when_not_a_draft():
order = draft(hours_old=48, status="pending")
assert decide(order, None, NOW)[0] == "skip"
def test_keep_when_intent_succeeded():
assert decide(draft(hours_old=48), intent(status="succeeded"), NOW)[0] == "keep"
def test_purge_when_intent_still_requires_payment_method():
action, _ = decide(draft(hours_old=48), intent(status="requires_payment_method"), NOW)
assert action == "purge"
def test_custom_stale_after_hours():
assert decide(draft(hours_old=10), None, NOW, stale_after_hours=5)[0] == "purge"
assert decide(draft(hours_old=10), None, NOW, stale_after_hours=20)[0] == "skip"
def test_cancelable_intent_true_when_open():
assert cancelable_intent(intent(status="requires_action")) is True
def test_cancelable_intent_false_when_succeeded():
assert cancelable_intent(intent(status="succeeded")) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, cancelableIntent } from "./purge-checkout-drafts.js";
const NOW = Date.now() / 1000;
const draft = (hoursOld = 48, over = {}) => ({
status: "checkout-draft",
date_modified_gmt: new Date((NOW - hoursOld * 3600) * 1000).toISOString().replace("Z", ""),
...over,
});
const intent = (over = {}) => ({ id: "pi_1", status: "requires_payment_method", ...over });
test("purge when stale and no payment", () => {
assert.equal(decide(draft(48), null, NOW)[0], "purge");
});
test("skip when still fresh", () => {
assert.equal(decide(draft(1), null, NOW)[0], "skip");
});
test("skip when not a draft", () => {
assert.equal(decide(draft(48, { status: "pending" }), null, NOW)[0], "skip");
});
test("keep when intent succeeded", () => {
assert.equal(decide(draft(48), intent({ status: "succeeded" }), NOW)[0], "keep");
});
test("purge when intent still requires payment method", () => {
assert.equal(decide(draft(48), intent({ status: "requires_payment_method" }), NOW)[0], "purge");
});
test("custom stale after hours", () => {
assert.equal(decide(draft(10), null, NOW, 5)[0], "purge");
assert.equal(decide(draft(10), null, NOW, 20)[0], "skip");
});
test("cancelableIntent true when open", () => {
assert.equal(cancelableIntent(intent({ status: "requires_action" })), true);
});
test("cancelableIntent false when succeeded", () => {
assert.equal(cancelableIntent(intent({ status: "succeeded" })), false);
});
Case studies
The store that had more drafts than customers
An uptime monitor was configured to load the checkout page every few minutes to make sure it returned a 200. Each visit quietly created a new checkout-draft order. After a year, the store had over forty thousand draft orders and fewer than six thousand real ones, and the orders screen in wp-admin took several seconds to load.
The cleanup job in dry run mode reported the exact count before anything ran. Once switched on, it cleared the backlog in nightly batches over about a week and the orders screen was fast again.
The drafts that left Stripe holding open charges
A store using saved cards started a PaymentIntent as soon as checkout loaded, so shoppers who bounced left both a checkout-draft order and an open PaymentIntent behind. Support once found an old draft that had somehow been reopened and confirmed weeks later, charging a card the shopper had long forgotten about.
Adding the PaymentIntent cancel step to the cleanup job closed that gap. Now a stale draft's payment intent is canceled the same day it stops being usable, before it can ever be confirmed by accident.
After this runs on a schedule, checkout-draft orders stop being a growing pile and become a small rolling window of the last day's real activity. The orders table stays lean, reports stop counting phantom checkouts, and no stray PaymentIntent is ever left open for someone to stumble into later.
FAQ
Why does my WooCommerce store have thousands of checkout-draft orders?
The block based checkout creates an order in the checkout-draft status as soon as a shopper opens the checkout page, before they enter an address or pay. Most shoppers who leave without paying never come back, and WooCommerce core has no built-in job that removes those drafts, so they build up forever.
Is it safe to delete checkout-draft orders?
Yes, once a draft is older than a safe window, usually a day, and Stripe shows no succeeded or processing PaymentIntent attached to it. A cleanup job should also cancel any PaymentIntent still open on that draft so it can never be captured later by mistake. Start in dry run mode to review the list before it deletes anything.
How often should the cleanup job run?
Once a day is plenty for most stores. Checkout-draft orders build up slowly, so there is no rush, and running it daily keeps the orders table small without ever touching a draft that is still active.
Related field notes
Citations
On the problem:
- WooCommerce developer docs: the Store API creates a draft order to hold the cart during checkout. developer.woocommerce.com/docs/apis/store-api
- WooCommerce blog: how the block based checkout and Store API changed order creation. developer.woocommerce.com/2021/10/04/woocommerce-blocks-store-api-launch
- WooCommerce forum discussion: large numbers of checkout-draft orders left behind after enabling the block checkout. wordpress.org/support/topic/thousands-of-draft-orders
On the solution:
- WooCommerce REST API: list and delete orders, including the force parameter to skip the trash. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: cancel a PaymentIntent that has not yet been confirmed. docs.stripe.com/api/payment_intents/cancel
- Stripe docs: PaymentIntent statuses and which ones are still open and cancelable. docs.stripe.com/payments/paymentintents/lifecycle
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 clear out your draft orders?
If this saved you a slow orders screen or a scare over a stray PaymentIntent, 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