Repair WooCommerce core: stock and inventory
Failed order reduces stock, never restored
A customer's card gets declined, or they abandon checkout, and the order lands on Failed or Cancelled. WooCommerce is supposed to give that stock back automatically. Sometimes it does not, and the count stays down forever. Nobody notices until the product shows "out of stock" while a box of it is still sitting on the shelf. Here is why the restore step gets skipped and a small script that finds every order still holding stock it should have released, and gives it back.
WooCommerce reduces stock at checkout and marks the order with a _order_stock_reduced flag. When the order later fails or is cancelled, WooCommerce should clear that flag and add the stock back, but a late payment decline, a status change made through the REST API, or a cut off request can skip that step. Run a small Python or Node.js script on a schedule that lists recent Failed and Cancelled orders, checks which ones still carry the stock reduced flag, and restores the exact quantity from each line item. Full code, tests, and a dry run guard are below.
The problem in plain words
WooCommerce does not wait for a successful payment before it takes stock away. It reduces stock the moment the order is created, so two shoppers cannot both buy the last unit while one of them is still typing a card number. That is the right call. The other half of the deal is that if the payment never completes, the stock has to go back.
Most of the time it does. WooCommerce marks every order that reduced stock with a small internal flag, and when the order moves to Failed or Cancelled, a core function checks that flag and increases the stock back to where it was. But that check only runs if the status change goes through the normal path and finishes cleanly. When it does not, the flag stays set, the stock stays down, and nothing in the admin screen calls attention to it. The order just looks like any other failed order.
Why it happens
The WooCommerce core code that restores stock runs inside the order status transition. A few common ways that transition never finishes cleanly:
- A payment gateway confirms the decline late, after the checkout request already timed out, so the failure is recorded through a background call that skips the usual hooks.
- A support agent, an import tool, or a script updates the order status straight through the WooCommerce REST API without going through the full order object, so the stock hooks never fire.
- A caching or object cache plugin serves a stale copy of the order during the transition, so the stock reduced flag it reads is out of date.
- The site hits a fatal error, a plugin conflict, or a server restart in the middle of the request that was supposed to cancel the order and restore stock.
- On High Performance Order Storage (HPOS), a direct database write that bypasses the order object entirely can change the status without running any of the stock logic at all.
Merchants notice this as inventory drift: the number in WooCommerce keeps falling behind what is actually on the shelf, and it always seems to trace back to a batch of failed or cancelled orders instead of real sales. This is reported in the WooCommerce core tracker as stock not being restored for orders cancelled or failed outside the normal checkout flow.
An order that is Failed or Cancelled has no business holding stock. If the order's own _order_stock_reduced flag is still set, the shelf and the store disagree, and the shelf is right. A repair script is a safety net that runs on a schedule, checks that one flag, and gives the stock back the same way core would have.
The fix, as a flow
We do not touch checkout or the payment flow. We add a job that runs every so often, looks at orders that are already Failed or Cancelled, and checks whether each one still carries the stock reduced flag. If it does, we add each line item's quantity back to the matching product or variation and clear the flag, the same outcome core's own restore function would produce.
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 read and write access to orders and products. Create it under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
pip install requests
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true" # start safe, change to false to write
npm install
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true" // start safe, change to false to write
List recent Failed and Cancelled orders
Ask the WooCommerce REST API for orders in those two statuses created within your lookback window, paging through the results. This works the same whether the store keeps orders as posts or has High Performance Order Storage (HPOS) turned on, since the REST API handles that for you.
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 failed_or_cancelled_orders(after_iso):
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "failed,cancelled", "after": after_iso, "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");
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* failedOrCancelledOrders(afterIso) {
let page = 1;
while (true) {
const batch = await woo(`/orders?status=failed,cancelled&after=${afterIso}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
Read the stock reduced flag and the line items
WooCommerce marks any order that took stock with an internal _order_stock_reduced meta value of "1", and clears it to "0" once the stock is given back. Each line item carries the product ID, or the variation ID when the product has variations, and the quantity that was taken. This is the only state we need to decide anything.
def reduced_stock_flag(order):
for meta in order.get("meta_data") or []:
if meta.get("key") == "_order_stock_reduced":
return str(meta.get("value")) == "1"
return False
def restockable_items(order):
items = []
for item in order.get("line_items") or []:
product_id = item.get("variation_id") or item.get("product_id")
qty = item.get("quantity") or 0
if product_id and qty > 0:
items.append({"product_id": product_id, "quantity": qty})
return items
export function reducedStockFlag(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_order_stock_reduced") return String(meta.value) === "1";
}
return false;
}
export function restockableItems(order) {
const items = [];
for (const item of order.line_items || []) {
const productId = item.variation_id || item.product_id;
const qty = item.quantity || 0;
if (productId && qty > 0) items.push({ product_id: productId, quantity: qty });
}
return items;
}
Decide, with one pure function
Keep the decision in its own function that takes just the order 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 is not Failed or Cancelled, skip it. If the stock reduced flag is already cleared, skip it, the stock was already given back. If there is nothing to restock, skip it. Otherwise, restore.
RESTOCK_STATUSES = {"failed", "cancelled"}
def decide(order):
if order["status"] not in RESTOCK_STATUSES:
return ("skip", "order not failed or cancelled")
if not reduced_stock_flag(order):
return ("skip", "stock already restored or never reduced")
items = restockable_items(order)
if not items:
return ("skip", "no line items with stock to restore")
return ("restore", f"stock reduced but never restored ({len(items)} line item(s))")
const RESTOCK_STATUSES = new Set(["failed", "cancelled"]);
export function decide(order) {
if (!RESTOCK_STATUSES.has(order.status)) return ["skip", "order not failed or cancelled"];
if (!reducedStockFlag(order)) return ["skip", "stock already restored or never reduced"];
const items = restockableItems(order);
if (items.length === 0) return ["skip", "no line items with stock to restore"];
return ["restore", `stock reduced but never restored (${items.length} line item(s))`];
}
Restore the stock and clear the flag
When the action is restore, add each line item's quantity to the current stock of its product or variation, then clear the order's _order_stock_reduced flag so the job never touches it again. We read the PaymentIntent id from order meta _stripe_intent_id, falling back to transaction_id, only to mention it in the order note, since restoring stock never depends on Stripe. Add a note so the shop manager can see why the count changed.
def get_product_stock(product_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}", auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def restore_stock(order, items, intent_id):
for item in items:
product = get_product_stock(item["product_id"])
if product.get("manage_stock") is not True:
continue
current = product.get("stock_quantity") or 0
new_qty = current + item["quantity"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{item['product_id']}",
json={"stock_quantity": new_qty}, auth=AUTH, timeout=30,
).raise_for_status()
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"meta_data": [{"key": "_order_stock_reduced", "value": "0"}]},
auth=AUTH, timeout=30,
).raise_for_status()
note = (f"Stock restored by restore_failed_stock. Order stayed {order['status']} with "
f"reduced stock never given back.")
if intent_id:
note += f" Stripe PaymentIntent {intent_id}."
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": note}, auth=AUTH, timeout=30,
).raise_for_status()
async function restoreStock(order, items, intentId) {
for (const item of items) {
const product = await woo(`/products/${item.product_id}`);
if (product.manage_stock !== true) continue;
const current = product.stock_quantity || 0;
const newQty = current + item.quantity;
await woo(`/products/${item.product_id}`, {
method: "PUT",
body: JSON.stringify({ stock_quantity: newQty }),
});
}
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key: "_order_stock_reduced", value: "0" }] }),
});
let note = `Stock restored by restore-failed-stock. Order stayed ${order.status} with ` +
`reduced stock never given back.`;
if (intentId) note += ` Stripe PaymentIntent ${intentId}.`;
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
}
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 do. 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. This script writes real stock numbers, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.
The full code
Here is the complete 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 whose stock reduced flag is already cleared.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Restore stock for WooCommerce orders that reduced it and then failed or were cancelled.
WooCommerce reduces stock as soon as an order is placed, before payment is confirmed.
When the order later moves to Failed or Cancelled, WooCommerce is supposed to add that
stock back automatically. That restore step can be skipped: a Stripe decline that lands
after a timeout, a status change made through the REST API or an import tool, a plugin
that short circuits the transition, or a restart mid request. The order is left holding
a `_reduced_stock` flag with no matching stock increase, and the product quietly sells
out early.
This walks recent Failed and Cancelled orders, and for any order still flagged as having
reduced stock, adds each line item's quantity back to the matching product or variation
stock and clears the flag. Safe to run again and again: an order with the flag already
cleared is skipped. Read the PaymentIntent id from order meta `_stripe_intent_id`, falling
back to `transaction_id`, only to record it on the restock note, since the Stripe side of
the payment is not required to restore stock. Dry run by default.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("restore_failed_stock")
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RESTOCK_STATUSES = {"failed", "cancelled"}
def intent_id_of(order):
"""The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id.
Used only to label the restock note. Restoring stock does not depend on Stripe.
"""
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 reduced_stock_flag(order):
"""WooCommerce sets order meta _order_stock_reduced to "1" the moment stock is taken,
and clears it once wc_maybe_increase_stock_levels() successfully restores it.
"""
for meta in order.get("meta_data") or []:
if meta.get("key") == "_order_stock_reduced":
return str(meta.get("value")) == "1"
return False
def restockable_items(order):
"""Line items with a real product id and a positive quantity to give back."""
items = []
for item in order.get("line_items") or []:
product_id = item.get("variation_id") or item.get("product_id")
qty = item.get("quantity") or 0
if product_id and qty > 0:
items.append({"product_id": product_id, "quantity": qty})
return items
def decide(order):
"""Pure decision: should this order's stock be restored right now?
Returns a tuple of (action, reason). No I/O happens here, so this is unit
tested with plain dicts and no network or WooCommerce store.
"""
if order["status"] not in RESTOCK_STATUSES:
return ("skip", "order not failed or cancelled")
if not reduced_stock_flag(order):
return ("skip", "stock already restored or never reduced")
items = restockable_items(order)
if not items:
return ("skip", "no line items with stock to restore")
return ("restore", f"stock reduced but never restored ({len(items)} line item(s))")
def failed_or_cancelled_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"status": "failed,cancelled", "after": after, "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 get_product_stock(product_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}", auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def restore_stock(order, items, intent_id):
for item in items:
product = get_product_stock(item["product_id"])
if product.get("manage_stock") is not True:
continue
current = product.get("stock_quantity") or 0
new_qty = current + item["quantity"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{item['product_id']}",
json={"stock_quantity": new_qty},
auth=AUTH, timeout=30,
).raise_for_status()
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
json={"meta_data": [{"key": "_order_stock_reduced", "value": "0"}]},
auth=AUTH, timeout=30,
).raise_for_status()
note = (
f"Stock restored by restore_failed_stock. Order stayed {order['status']} with "
f"reduced stock never given back."
)
if intent_id:
note += f" Stripe PaymentIntent {intent_id}."
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": note},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
restored = 0
for order in failed_or_cancelled_orders():
action, reason = decide(order)
if action != "restore":
continue
items = restockable_items(order)
intent_id = intent_id_of(order)
log.info("Order %s: %s. %s", order["id"], reason, "would restore" if DRY_RUN else "restoring")
if not DRY_RUN:
restore_stock(order, items, intent_id)
restored += 1
log.info("Done. %d order(s) %s.", restored, "to restore" if DRY_RUN else "restored")
if __name__ == "__main__":
run()
/**
* Restore stock for WooCommerce orders that reduced it and then failed or were cancelled.
*
* WooCommerce reduces stock as soon as an order is placed, before payment is confirmed.
* When the order later moves to Failed or Cancelled, WooCommerce is supposed to add that
* stock back automatically. That restore step can be skipped: a Stripe decline that lands
* after a timeout, a status change made through the REST API or an import tool, a plugin
* that short circuits the transition, or a restart mid request. The order is left holding
* a `_reduced_stock` flag with no matching stock increase, and the product quietly sells
* out early.
*
* This walks recent Failed and Cancelled orders, and for any order still flagged as having
* reduced stock, adds each line item's quantity back to the matching product or variation
* stock and clears the flag. Safe to run again and again. Read only for the Stripe
* PaymentIntent id (order meta `_stripe_intent_id`, falling back to `transaction_id`), used
* only to label the restock note. Dry run by default.
*
* Guide: https://www.allanninal.dev/woocommerce/failed-order-reduces-stock-never-restored/
*/
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_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const RESTOCK_STATUSES = new Set(["failed", "cancelled"]);
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 reducedStockFlag(order) {
for (const meta of order.meta_data || []) {
if (meta.key === "_order_stock_reduced") return String(meta.value) === "1";
}
return false;
}
export function restockableItems(order) {
const items = [];
for (const item of order.line_items || []) {
const productId = item.variation_id || item.product_id;
const qty = item.quantity || 0;
if (productId && qty > 0) items.push({ product_id: productId, quantity: qty });
}
return items;
}
export function decide(order) {
if (!RESTOCK_STATUSES.has(order.status)) return ["skip", "order not failed or cancelled"];
if (!reducedStockFlag(order)) return ["skip", "stock already restored or never reduced"];
const items = restockableItems(order);
if (items.length === 0) return ["skip", "no line items with stock to restore"];
return ["restore", `stock reduced but never restored (${items.length} line item(s))`];
}
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* failedOrCancelledOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?status=failed,cancelled&after=${after}&per_page=50&page=${page}`);
if (!batch.length) return;
for (const order of batch) yield order;
page++;
}
}
async function restoreStock(order, items, intentId) {
for (const item of items) {
const product = await woo(`/products/${item.product_id}`);
if (product.manage_stock !== true) continue;
const current = product.stock_quantity || 0;
const newQty = current + item.quantity;
await woo(`/products/${item.product_id}`, {
method: "PUT",
body: JSON.stringify({ stock_quantity: newQty }),
});
}
await woo(`/orders/${order.id}`, {
method: "PUT",
body: JSON.stringify({ meta_data: [{ key: "_order_stock_reduced", value: "0" }] }),
});
let note = `Stock restored by restore-failed-stock. Order stayed ${order.status} with ` +
`reduced stock never given back.`;
if (intentId) note += ` Stripe PaymentIntent ${intentId}.`;
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
}
export async function run() {
let restored = 0;
for await (const order of failedOrCancelledOrders()) {
const [action, reason] = decide(order);
if (action !== "restore") continue;
const items = restockableItems(order);
const intentId = intentIdOf(order);
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would restore" : "restoring"}`);
if (!DRY_RUN) await restoreStock(order, items, intentId);
restored++;
}
console.log(`Done. ${restored} order(s) ${DRY_RUN ? "to restore" : "restored"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether real stock numbers get changed. Because we kept decide pure, the test needs no network and no store. It just feeds in plain objects and checks the action.
from restore_failed_stock import decide, reduced_stock_flag, restockable_items
def order(**over):
base = {
"status": "failed",
"meta_data": [{"key": "_order_stock_reduced", "value": "1"}],
"line_items": [{"product_id": 101, "quantity": 2}],
}
base.update(over)
return base
def test_restore_when_failed_and_flag_set():
assert decide(order())[0] == "restore"
def test_restore_when_cancelled_and_flag_set():
assert decide(order(status="cancelled"))[0] == "restore"
def test_skip_when_order_still_pending():
assert decide(order(status="pending"))[0] == "skip"
def test_skip_when_flag_already_cleared():
o = order(meta_data=[{"key": "_order_stock_reduced", "value": "0"}])
assert decide(o)[0] == "skip"
def test_skip_when_no_restockable_line_items():
o = order(line_items=[{"product_id": None, "quantity": 2}])
assert decide(o)[0] == "skip"
def test_restockable_items_uses_variation_id_when_present():
o = order(line_items=[{"product_id": 101, "variation_id": 202, "quantity": 3}])
assert restockable_items(o) == [{"product_id": 202, "quantity": 3}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, restockableItems } from "./restore-failed-stock.js";
function order(over = {}) {
return {
status: "failed",
meta_data: [{ key: "_order_stock_reduced", value: "1" }],
line_items: [{ product_id: 101, quantity: 2 }],
...over,
};
}
test("restore when failed and flag set", () => {
assert.equal(decide(order())[0], "restore");
});
test("restore when cancelled and flag set", () => {
assert.equal(decide(order({ status: "cancelled" }))[0], "restore");
});
test("skip when order still pending", () => {
assert.equal(decide(order({ status: "pending" }))[0], "skip");
});
test("skip when flag already cleared", () => {
const o = order({ meta_data: [{ key: "_order_stock_reduced", value: "0" }] });
assert.equal(decide(o)[0], "skip");
});
test("skip when no restockable line items", () => {
const o = order({ line_items: [{ product_id: null, quantity: 2 }] });
assert.equal(decide(o)[0], "skip");
});
test("restockableItems uses variation_id when present", () => {
const o = order({ line_items: [{ product_id: 101, variation_id: 202, quantity: 3 }] });
assert.deepEqual(restockableItems(o), [{ product_id: 202, quantity: 3 }]);
});
Case studies
The bulk cleanup that skipped every hook
A store used a bulk order editor plugin to mark a batch of six month old failed orders as Cancelled, to tidy up reports. The plugin wrote the status straight to the database for speed. Stock hooks never ran, so 340 units across a dozen products stayed reserved for orders that had been dead for months.
Running the script in dry run listed every one of those orders instantly, since each still carried the stock reduced flag. A real run gave the stock back and left a note explaining exactly why the count jumped.
The card that declined ten minutes late
A store's payment gateway sometimes reported a decline well after the checkout page had already timed out and shown the customer an error. WooCommerce marked those orders Failed through a delayed background request that, on a busy day, occasionally got dropped before the stock restore step ran.
The team set the script to run every twenty minutes. It caught the handful of stragglers each week, and the shelf count stopped drifting from what WooCommerce reported.
After this runs on a schedule, a failed or cancelled order is never a silent stock leak again. The worst case becomes a short delay of a few minutes before the job gives the stock back. Keep it running even after you track down the root cause, since imports, plugins, and slow gateways will keep finding new ways to skip the restore step.
FAQ
Why did a failed order take stock and never give it back?
WooCommerce reduces stock the moment an order is placed, before payment is confirmed, and is supposed to add it back when the order moves to Failed or Cancelled. That restore step can be skipped by a late payment decline, a status change made through the REST API or an import tool, or a request that got cut off. The order is left with a stock reduced flag and no matching stock increase.
Is it safe to change stock numbers with a script?
Yes, when the script only acts on orders that are already Failed or Cancelled and still show the stock reduced flag as set, and it skips any order where that flag is already cleared. Start in dry run mode to review the list before it writes.
How often should the stock restore job run?
Every fifteen to thirty minutes on a cron schedule is enough for most stores. It only touches orders that are already failed or cancelled with stock still held, so running it often is safe and cheap.
Related field notes
Citations
On the problem:
- WooCommerce docs: managing stock, and how order status changes are meant to restore or hold stock. woocommerce.com/document/managing-products
- WooCommerce core reference:
wc_maybe_increase_stock_levels()and the order stock reduced flag. woocommerce.github.io/code-reference - WooCommerce core issue tracker: stock not restored for orders changed outside the normal checkout and admin flow. github.com/woocommerce/woocommerce/issues
On the solution:
- WooCommerce REST API: list, read, and update orders, including meta data and status. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce REST API: read and update product and variation stock quantity. woocommerce.github.io/woocommerce-rest-api-docs
- WooCommerce docs: High Performance Order Storage (HPOS) and why the REST API is the safe way to read and write orders. woocommerce.com/document/high-performance-order-storage
Stuck on a tricky one?
If you have a bug in WooCommerce core, stock management, or inventory sync 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 stock count?
If this saved you an overselling scare or a confusing inventory audit, 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