Repair WooCommerce core: stock and inventory
Stale reserved-stock rows oversell
A shopper hits checkout and WooCommerce quietly reserves the last unit for them, before a card is even charged. That reservation is supposed to disappear a few minutes later if the sale does not go through. But when the checkout crashes, the tab gets closed, or a background job that clears old holds never runs, the reservation row just sits there. The unit stays "sold" to a buyer who never paid, a second real buyer gets told it is out of stock, or worse, both orders get let through and you oversell. Here is why the reservation goes stale and a small script that finds every expired hold and releases it, without ever touching an order that actually paid.
WooCommerce reserves stock for an order at the start of checkout, before payment finishes, and that reservation is meant to expire on its own. When an order stalls on pending or checkout-draft and the cleanup never runs, the reservation outlives its hold window and keeps blocking stock that a real buyer could use. Run a small Python or Node.js script on a schedule that lists pending and checkout-draft orders older than your hold window, checks the order's Stripe PaymentIntent from meta _stripe_intent_id or transaction_id, and cancels only the orders Stripe confirms were never paid. Full code, tests, and a dry run guard are below.
The problem in plain words
The moment a shopper reaches the payment step, WooCommerce reserves the stock for the items in their cart. This is not the final stock reduction, it is a short-lived hold, recorded in its own reservation table, so two shoppers cannot both be sold the last unit of a product while they are both mid-checkout. Once the order is paid, or once the hold expires, the reservation clears and the stock count goes back to reflecting reality.
The hold is only supposed to last a few minutes. But a browser tab closed mid-payment, a payment page that times out, a worker or cron job that is supposed to clear expired holds but stopped running, or a plugin conflict that leaves the order stuck on pending, can all leave that reservation row in place long after it should have cleared. Nothing else in the store knows to look at it again. The unit looks unavailable to every other shopper, and the store owner just sees stock numbers that never seem to line up with what actually sold.
Why it happens
WooCommerce core adds a short hold on stock at checkout precisely to stop two shoppers from buying the last unit at the same time. That hold is meant to be temporary, and WooCommerce runs its own cleanup to release expired holds. A few common reasons that cleanup does not happen in time:
- The scheduled task that clears expired reservations depends on WordPress cron, which only fires on a site visit. A quiet store, a maintenance mode, or a caching layer that skips WordPress entirely can starve it for hours.
- The order was abandoned before payment even started, a closed tab or a browser crash, so there is no webhook and no PaymentIntent to look at, only an aging pending order still holding stock.
- A plugin conflict or a fatal error during checkout leaves the order on pending or on the newer checkout-draft status indefinitely, well past any reasonable hold window.
- High traffic during a sale multiplies the number of abandoned checkouts at once, so any gap in the cleanup shows up immediately as a wave of false "out of stock" notices.
This is a known rough edge in WooCommerce's stock hold design, reported in threads about reserved stock rows that outlive their order and keep blocking inventory that should be free to sell. See the citations at the end for the exact references.
A reservation only deserves to keep blocking stock while there is still a real chance the order gets paid. Once the hold window has passed and Stripe confirms no successful payment ever came in for that order, the reservation has no reason left to exist. A cleanup script is a safety net that runs on a schedule, checks Stripe as the source of truth for money, and releases only the holds that are truly dead.
The fix, as a flow
We do not touch the checkout itself. We add a job that runs every so often, looks at every order still on pending or checkout-draft, and measures how long it has been sitting there. If an order is older than the hold window, we check Stripe for a matching PaymentIntent. If Stripe never shows a succeeded payment, we cancel the order, which lets WooCommerce release the stock hold the same way it would if the shopper had cancelled it themselves.
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 HOLD_MINUTES="60"
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 HOLD_MINUTES="60"
export DRY_RUN="true" // start safe, change to false to write
List every order that could still be holding stock
Only orders on pending or checkout-draft hold stock in this way. We page through all of them with the WooCommerce REST API rather than reading the reservation table directly, so the script works the same whether or not the store has High Performance Order Storage (HPOS) turned on.
import 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 held_orders():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "pending,checkout-draft", "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
async function* heldOrders() {
let page = 1;
while (true) {
const batch = await woo(`/orders?status=pending,checkout-draft&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
Work out how old the reservation actually is
Each order carries its creation date. Compare that to now and turn the gap into minutes. This is the number we compare against the hold window, and it is a small enough calculation to keep pure and easy to test on its own.
import time
def minutes_since(iso_date_string, now=None):
now = time.time() if now is None else now
parsed = time.strptime(iso_date_string.split(".")[0], "%Y-%m-%dT%H:%M:%S")
then = time.mktime(parsed) - time.timezone
return (now - then) / 60
def order_age_minutes(order):
return minutes_since(order["date_created_gmt"] or order["date_created"])
export function minutesSince(isoDateString, now = Date.now()) {
const then = new Date(isoDateString.endsWith("Z") ? isoDateString : `${isoDateString}Z`).getTime();
return (now - then) / 60000;
}
export function orderAgeMinutes(order, now = Date.now()) {
return minutesSince(order.date_created_gmt || order.date_created, now);
}
Look up the Stripe PaymentIntent, if there is one
Read the saved PaymentIntent id from order meta _stripe_intent_id, or fall back to transaction_id when it looks like a PaymentIntent id. An abandoned checkout may never have reached Stripe at all, which is a valid case, not an error.
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 Stripe intent, and its age, 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 not on a holding status. Skip anything still inside the hold window. If Stripe shows the payment succeeded, leave it alone. Otherwise, release it.
HOLDING_STATUSES = {"pending", "checkout-draft"}
PAID_INTENT_STATUSES = {"succeeded"}
def decide(order, intent, age_minutes, hold_minutes):
if order["status"] not in HOLDING_STATUSES:
return ("skip", "order is not in a stock-holding status")
if age_minutes < hold_minutes:
return ("skip", "reservation has not expired yet")
if intent is not None and intent.get("status") in PAID_INTENT_STATUSES:
return ("paid", "Stripe shows this order was actually paid, do not touch stock")
return ("release", "reservation is stale and was never paid")
const HOLDING_STATUSES = new Set(["pending", "checkout-draft"]);
const PAID_INTENT_STATUSES = new Set(["succeeded"]);
export function decide(order, intent, ageMinutes, holdMinutes) {
if (!HOLDING_STATUSES.has(order.status)) {
return ["skip", "order is not in a stock-holding status"];
}
if (ageMinutes < holdMinutes) {
return ["skip", "reservation has not expired yet"];
}
if (intent && PAID_INTENT_STATUSES.has(intent.status)) {
return ["paid", "Stripe shows this order was actually paid, do not touch stock"];
}
return ["release", "reservation is stale and was never paid"];
}
Release the hold the same way a real cancel would
When the action is release, cancel the order through the REST API. WooCommerce's own cancel handling releases the reserved stock row as part of that flow, the same way it would if the shopper had clicked cancel themselves. Add an order note so the shop manager can see why it happened.
def release(order):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"status": "cancelled"}, auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": "Stock reservation released: this order sat unpaid past the hold "
"window and Stripe confirms no successful payment. Cancelled so the "
"held stock is freed for other buyers."},
auth=AUTH, timeout=30,
).raise_for_status()
async function release(order) {
await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "cancelled" }) });
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Stock reservation released: this order sat unpaid past the hold window and " +
"Stripe confirms no successful payment. Cancelled so the held stock is freed " +
"for other buyers.",
}),
});
}
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 release. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron every fifteen to thirty minutes.
Always start with DRY_RUN=true. Cancelling an order is a real write, so you want to see its plan before it acts. Once the report looks right for a day, turn it off. Also set HOLD_MINUTES wider than your slowest normal payment method so you never release a hold on an order still genuinely in progress.
The full code
Here is the complete cleanup script 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 never touches an order Stripe confirms was actually paid.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Release WooCommerce stock reservations that have gone stale.
WooCommerce holds stock for an order the moment checkout starts, before payment
is confirmed. The hold is meant to expire on its own, but a crashed checkout, a
timed out payment page, or a queue worker that never ran can leave the order on
pending or checkout-draft long after the hold window passed. The reservation
row is now stale: the item still looks sold to the stock count, even though no
payment ever completed for it, so a second buyer can be oversold the same
units. This walks recent unpaid orders, checks each one's age and its Stripe
PaymentIntent, and cancels the order (which releases the stock hold) only when
the hold is expired and Stripe confirms no payment ever came through. Safe to
run again and again. Read only until DRY_RUN is turned off.
"""
import os
import time
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("release_stale_reservations")
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"])
HOLD_MINUTES = int(os.environ.get("HOLD_MINUTES", "60"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Orders in these statuses still hold stock but have not paid.
HOLDING_STATUSES = {"pending", "checkout-draft"}
PAID_INTENT_STATUSES = {"succeeded"}
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 minutes_since(iso_date_string, now=None):
"""Minutes elapsed since an ISO 8601 timestamp (no timezone math needed for GMT dates)."""
now = time.time() if now is None else now
parsed = time.strptime(iso_date_string.split(".")[0], "%Y-%m-%dT%H:%M:%S")
then = time.mktime(parsed) - time.timezone
return (now - then) / 60
def decide(order, intent, age_minutes, hold_minutes=HOLD_MINUTES):
"""Pure decision: what should happen to one held order? No I/O in here.
order - dict from GET /orders/{id} (or a plain test double)
intent - Stripe PaymentIntent dict, or None if the order never got one
age_minutes - minutes since the order was created (caller computes this)
hold_minutes - how long a reservation is allowed to sit before it is stale
"""
if order["status"] not in HOLDING_STATUSES:
return ("skip", "order is not in a stock-holding status")
if age_minutes < hold_minutes:
return ("skip", "reservation has not expired yet")
if intent is not None and intent.get("status") in PAID_INTENT_STATUSES:
return ("paid", "Stripe shows this order was actually paid, do not touch stock")
return ("release", "reservation is stale and was never paid")
def order_age_minutes(order):
return minutes_since(order["date_created_gmt"] or order["date_created"])
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 held_orders():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "pending,checkout-draft", "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 release(order):
"""Cancel the order. WooCommerce releases the reserved stock row as part of
the normal cancel flow, the same way it would if a customer walked away.
"""
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"status": "cancelled"}, auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": "Stock reservation released: this order sat unpaid past the hold "
"window and Stripe confirms no successful payment. Cancelled so the "
"held stock is freed for other buyers."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
released = 0
for order in held_orders():
age_minutes = order_age_minutes(order)
intent = get_intent(intent_id_of(order))
action, reason = decide(order, intent, age_minutes)
if action != "release":
if action == "paid":
log.warning("Order %s: %s", order["id"], reason)
continue
log.info("Order %s: %s. %s", order["id"], reason, "would release" if DRY_RUN else "releasing")
if not DRY_RUN:
release(order)
released += 1
log.info("Done. %d order(s) %s.", released, "to release" if DRY_RUN else "released")
if __name__ == "__main__":
run()
/**
* Release WooCommerce stock reservations that have gone stale.
*
* WooCommerce holds stock for an order the moment checkout starts, before
* payment is confirmed. The hold is meant to expire on its own, but a crashed
* checkout, a timed out payment page, or a queue worker that never ran can
* leave the order on pending or checkout-draft long after the hold window
* passed. The reservation row is now stale: the item still looks sold to the
* stock count, even though no payment ever completed for it, so a second
* buyer can be oversold the same units. This walks recent unpaid orders,
* checks each one's age and its Stripe PaymentIntent, and cancels the order
* (which releases the stock hold) only when the hold is expired and Stripe
* confirms no payment ever came through. Safe to run again and again. Read
* only until DRY_RUN is turned off.
*/
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 HOLD_MINUTES = Number(process.env.HOLD_MINUTES || 60);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Orders in these statuses still hold stock but have not paid.
const HOLDING_STATUSES = new Set(["pending", "checkout-draft"]);
const PAID_INTENT_STATUSES = new Set(["succeeded"]);
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 minutesSince(isoDateString, now = Date.now()) {
const then = new Date(isoDateString.endsWith("Z") ? isoDateString : `${isoDateString}Z`).getTime();
return (now - then) / 60000;
}
/**
* Pure decision: what should happen to one held order? No I/O in here.
*
* order - object from GET /orders/{id} (or a plain test double)
* intent - Stripe PaymentIntent object, or null if the order never got one
* ageMinutes - minutes since the order was created (caller computes this)
* holdMinutes - how long a reservation is allowed to sit before it is stale
*/
export function decide(order, intent, ageMinutes, holdMinutes = HOLD_MINUTES) {
if (!HOLDING_STATUSES.has(order.status)) {
return ["skip", "order is not in a stock-holding status"];
}
if (ageMinutes < holdMinutes) {
return ["skip", "reservation has not expired yet"];
}
if (intent && PAID_INTENT_STATUSES.has(intent.status)) {
return ["paid", "Stripe shows this order was actually paid, do not touch stock"];
}
return ["release", "reservation is stale and was never paid"];
}
export function orderAgeMinutes(order, now = Date.now()) {
return minutesSince(order.date_created_gmt || order.date_created, now);
}
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* heldOrders() {
let page = 1;
while (true) {
const batch = await woo(`/orders?status=pending,checkout-draft&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function release(order) {
await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "cancelled" }) });
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({
note: "Stock reservation released: this order sat unpaid past the hold window and " +
"Stripe confirms no successful payment. Cancelled so the held stock is freed " +
"for other buyers.",
}),
});
}
export async function run() {
let released = 0;
for await (const order of heldOrders()) {
const ageMinutes = orderAgeMinutes(order);
const intent = await getIntent(intentIdOf(order));
const [action, reason] = decide(order, intent, ageMinutes);
if (action !== "release") {
if (action === "paid") console.warn(`Order ${order.id}: ${reason}`);
continue;
}
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would release" : "releasing"}`);
if (!DRY_RUN) await release(order);
released++;
}
console.log(`Done. ${released} order(s) ${DRY_RUN ? "to release" : "released"}.`);
}
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 whether real customer orders get cancelled. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and a made-up age, and checks the action.
from release_stale_reservations import decide, intent_id_of
def intent(**over):
base = {"status": "succeeded"}
base.update(over)
return base
def test_release_when_stale_and_unpaid():
order = {"status": "pending"}
assert decide(order, None, age_minutes=90, hold_minutes=60)[0] == "release"
def test_release_when_stale_and_intent_never_succeeded():
order = {"status": "pending"}
action = decide(order, intent(status="requires_payment_method"), age_minutes=90, hold_minutes=60)[0]
assert action == "release"
def test_skip_when_still_within_hold_window():
order = {"status": "pending"}
assert decide(order, None, age_minutes=10, hold_minutes=60)[0] == "skip"
def test_skip_when_order_not_in_holding_status():
order = {"status": "processing"}
assert decide(order, None, age_minutes=200, hold_minutes=60)[0] == "skip"
def test_paid_when_stripe_shows_succeeded():
order = {"status": "pending"}
assert decide(order, intent(), age_minutes=90, hold_minutes=60)[0] == "paid"
def test_checkout_draft_is_also_a_holding_status():
order = {"status": "checkout-draft"}
assert decide(order, None, age_minutes=90, hold_minutes=60)[0] == "release"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./release-stale-reservations.js";
const intent = (over = {}) => ({ status: "succeeded", ...over });
test("release when stale and unpaid", () => {
assert.equal(decide({ status: "pending" }, null, 90, 60)[0], "release");
});
test("release when stale and intent never succeeded", () => {
assert.equal(decide({ status: "pending" }, intent({ status: "requires_payment_method" }), 90, 60)[0], "release");
});
test("skip when still within hold window", () => {
assert.equal(decide({ status: "pending" }, null, 10, 60)[0], "skip");
});
test("skip when order not in a holding status", () => {
assert.equal(decide({ status: "processing" }, null, 200, 60)[0], "skip");
});
test("paid when Stripe shows succeeded", () => {
assert.equal(decide({ status: "pending" }, intent(), 90, 60)[0], "paid");
});
test("checkout-draft is also a holding status", () => {
assert.equal(decide({ status: "checkout-draft" }, null, 90, 60)[0], "release");
});
Case studies
The store where cron never fired
A shop moved behind a full-page cache and put WordPress cron on a real system cron job, but the new job was never actually installed. WooCommerce's own cleanup of expired reservations depends on that scheduler running, so it silently stopped. Reservation rows piled up for two weeks on a bestselling item that looked out of stock the entire time.
The cleanup script, running on its own schedule outside WordPress entirely, cleared the backlog on its first pass and the product went back to showing real availability within minutes.
The drop that oversold itself
A limited drop of 200 units sold out in under a minute, but support then had to explain to a dozen buyers why their "successful" order later got cancelled for being out of stock. A burst of abandoned checkouts near the end of the drop had reserved units that were never going to be paid for, and the store's own cleanup could not keep up with the volume.
Running the script every fifteen minutes during future drops keeps the reservation table honest in near real time, so the count shown to shoppers matches what is actually still sellable.
After this runs on a schedule, an abandoned checkout stops being a phantom hold on your best sellers. Real stock numbers stay honest, real buyers stop seeing false "out of stock" messages, and nothing that actually paid is ever touched. Keep it running even after you fix the root cause of the stalled checkouts, because some will always happen.
FAQ
Why does a WooCommerce product show sold out when the stock number still looks fine?
WooCommerce reserves stock for an order the moment checkout starts, before payment finishes. That reservation should expire on its own, but if the order never moved past pending, the reservation can outlive its hold window and keep counting against available stock long after the buyer left. A script that checks each pending order's age and its Stripe payment and releases the stale ones fixes it.
Is it safe to cancel a pending order with a script?
Yes, when the script first confirms the hold window has actually passed and that Stripe shows no successful PaymentIntent for that order. Anything Stripe shows as paid is left untouched. Start in dry run mode to review the exact list before it cancels anything.
How long should a stock reservation be allowed to sit before it counts as stale?
WooCommerce's own default hold is short, usually a matter of minutes, meant to cover the time a buyer spends on the payment page. Giving the cleanup script a wider window, an hour is a safe starting point, avoids releasing a hold while a slow payment method is still genuinely in progress.
Related field notes
Citations
On the problem:
- WooCommerce core: how stock is held for pending orders, and the reserved stock hold used to prevent overselling during checkout. github.com/woocommerce/woocommerce/wiki
- WooCommerce docs: managing WooCommerce stock settings, including the hold stock window. woocommerce.com/document/managing-products/product-inventory
- WordPress.org support thread: reserved stock not releasing after abandoned or failed checkouts. wordpress.org/support/topic/reserved-stock-not-releasing
On the solution:
- WooCommerce REST API: list, update, and add notes to orders. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent to confirm its current status. docs.stripe.com/api/payment_intents/retrieve
- WordPress docs: how WP-Cron scheduling works and why it depends on site visits. developer.wordpress.org/plugins/cron
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 free up your stock?
If this stopped false "out of stock" messages or a wave of oversold orders, 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