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.

Python and Node.js Runs on a schedule Safe by default (dry run)
A lot of cardboard boxes
Photo by CHUTTERSNAP on Unsplash
The short answer

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.

Checkout starts stock reduced Payment fails order marked Failed restore skipped Stock stays down flag still set Sells out too early
Stock is taken at checkout. When the failed order's restore step is skipped, that stock never comes back, even though the shelf still has it.

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:

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.

The key insight

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.

Scheduled job every 15 to 30 min List failed and cancelled orders Read line items and stock flag Flag still set? yes no, skip Restore stock clear flag + add note
The job only touches orders that are already Failed or Cancelled and still show the stock reduced flag as set. Every other order is left alone.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
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
2

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.

step2.py
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
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");

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++;
  }
}
3

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.

read_state.py
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
read-state.js
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;
}
4

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.

decide.py
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))")
decide.js
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))`];
}
5

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.

apply.py
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()
apply.js
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 }),
  });
}
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 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.

Run it safe

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_failed_stock.py
"""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-failed-stock.js
/**
 * 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.

test_stock_restore_decide.py
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}]
restore-failed-stock.test.js
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

Import tool

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.

Slow decline

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.

What good looks like

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:

  1. WooCommerce docs: managing stock, and how order status changes are meant to restore or hold stock. woocommerce.com/document/managing-products
  2. WooCommerce core reference: wc_maybe_increase_stock_levels() and the order stock reduced flag. woocommerce.github.io/code-reference
  3. 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:

  1. WooCommerce REST API: list, read, and update orders, including meta data and status. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: read and update product and variation stock quantity. woocommerce.github.io/woocommerce-rest-api-docs
  3. 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.

Contact me on LinkedIn

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

Back to all WooCommerce field notes