Repair WooCommerce core: stock and inventory

WooCommerce reduced stock twice for one order

A shopper bought three units of a product, and stock dropped by six. Nobody placed a second order, no refund happened, and the order itself still shows quantity three. Somewhere in the background, the same order ran through the stock reduction step twice. Here is why that happens and a small script that finds every order this hit and adds the extra stock back.

Python and Node.js Runs on demand or on a schedule Safe by default (dry run)
A brown cardboard box on a white table
Photo by Mediamodifier on Unsplash
The short answer

WooCommerce guards stock reduction with an order meta flag called _order_stock_reduced, but if two triggers race each other, such as a duplicate payment webhook and a manual "reduce stock" action from wp-admin, the guard can be read before it is set and the reduction runs a second time. Run a small Python or Node.js script that walks recent orders, compares the recorded reduction against the order's real line quantities, and adds back any extra units it finds. Full code, tests, and a dry run guard are below.

The problem in plain words

When an order is paid, WooCommerce runs a routine called wc_reduce_stock_levels(). It walks every line item, subtracts the quantity from each product's stock, and then writes a flag on the order, _order_stock_reduced set to 1, so it knows not to do this again for that order.

That flag is the entire safety net. If something calls the reduction routine for the same order a second time and, for whatever reason, the flag is not set yet or is not checked at that moment, the routine runs again. It has no memory of "I already did this," so it happily subtracts the same quantities a second time. The order still says it sold three units. The warehouse count says six left the shelf. The gap between those two numbers is the bug, and it only gets worse the longer it goes unnoticed.

Order paid first payment event Stock reduced flag set: reduced = 1 Duplicate trigger webhook retry or manual click flag not seen yet Reduction runs again same line items, same order Stock short by the order qty 3 sold, 6 removed from stock product looks out of stock too soon
The guard flag exists, but a second trigger can slip past it and call the reduction routine again for the same order.

Why it happens

WooCommerce core checks _order_stock_reduced before it calls wc_reduce_stock_levels(), but that check only helps when every code path goes through the same guarded function at the same moment. A few common ways it slips through:

WooCommerce core has open reports of this exact pattern, usually tied to gateways or custom code that call the reduction function outside the normal order flow. The flag is meant to make the routine idempotent, but idempotent only works if every caller respects it.

The key insight

You cannot always stop every duplicate trigger, but you can always detect the result. If an order's line items add up to three units and the store's stock log shows six units removed for that same order, the extra three are a mistake, not a second sale. A repair script compares what an order actually sold against what was actually removed, and restores only the difference.

The fix, as a flow

We do not change how checkout works. We add a script that looks at recent orders, works out how many units each order should have removed from stock based on its line items, and compares that to how many times the order shows a completed reduction. When the recorded reduction is a clean multiple of the real amount, greater than one, we know it ran more than once, and we add the extra units back to each product and clear the order so it cannot happen again from the same stale trigger.

Scheduled job or run on demand Sum real line qty from order line items Read recorded reduction from the stock reduction log Clean multiple above 1x? yes no, skip Restore extra units add note, keep first reduction
The script trusts the order's own line items as the truth for how much stock should be gone, and restores only the amount beyond that.

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="14"
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="14"
export DRY_RUN="true"   // start safe, change to false to write
2

Work out the order's real quantity

The order's line items are the source of truth for how many units it actually sold. We add up the quantity across every line item, since that is the amount stock should have dropped by exactly once, no matter how many times a plugin tried to reduce it.

step2.py
def order_expected_qty(order):
    """Total units this order should have removed from stock, once."""
    return sum(int(item.get("quantity") or 0) for item in order.get("line_items") or [])
step2.js
export function orderExpectedQty(order) {
  // Total units this order should have removed from stock, once.
  return (order.line_items || []).reduce((sum, item) => sum + (Number(item.quantity) || 0), 0);
}
3

Read how much was actually reduced

WooCommerce writes a stock reduction record to the product's stock change log every time wc_reduce_stock_levels() runs for an order. We read the order's meta for the recorded total, which our helper plugin or store logs under _stock_reduced_qty alongside the usual _order_stock_reduced flag. Where a store has no such log, the same signal shows up as the order's _order_stock_reduced flag being set while a stock audit trail shows two matching negative movements for the same order id, so either source works with the same decision rule.

step3.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 recorded_reduced_qty(order):
    """Total units actually removed from stock for this order, from meta."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stock_reduced_qty" and meta.get("value"):
            return int(meta["value"])
    return None


def get_order(order_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()
step3.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");

export function recordedReducedQty(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stock_reduced_qty" && meta.value) return Number(meta.value);
  }
  return null;
}

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.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order's expected quantity and its recorded reduced quantity and returns an action. The rule is careful on purpose. We only call it a double reduction when the recorded amount is a whole multiple of the expected amount and that multiple is two or more, since a partial mismatch could mean something else entirely, like a partial refund, and deserves a human, not a script.

decide.py
def decide(order, expected_qty, recorded_qty):
    if order is None:
        return ("orphan", "order not found", 0)
    if order.get("status") not in ("processing", "completed", "on-hold"):
        return ("skip", "order not in a stock-reduced state", 0)
    if not expected_qty:
        return ("skip", "order has no line item quantity", 0)
    if recorded_qty is None:
        return ("skip", "no recorded reduction to compare", 0)
    if recorded_qty <= expected_qty:
        return ("skip", "reduction matches or is under the order total", 0)
    if recorded_qty % expected_qty != 0:
        return ("review", "reduction is extra but not a clean multiple", 0)
    times = recorded_qty // expected_qty
    if times < 2:
        return ("skip", "reduction matches the order total", 0)
    extra_units = expected_qty * (times - 1)
    return ("fix", f"stock reduced {times}x for one order", extra_units)
decide.js
export function decide(order, expectedQty, recordedQty) {
  if (!order) return ["orphan", "order not found", 0];
  if (!["processing", "completed", "on-hold"].includes(order.status)) {
    return ["skip", "order not in a stock-reduced state", 0];
  }
  if (!expectedQty) return ["skip", "order has no line item quantity", 0];
  if (recordedQty === null || recordedQty === undefined) {
    return ["skip", "no recorded reduction to compare", 0];
  }
  if (recordedQty <= expectedQty) return ["skip", "reduction matches or is under the order total", 0];
  if (recordedQty % expectedQty !== 0) return ["review", "reduction is extra but not a clean multiple", 0];
  const times = recordedQty / expectedQty;
  if (times < 2) return ["skip", "reduction matches the order total", 0];
  const extraUnits = expectedQty * (times - 1);
  return ["fix", `stock reduced ${times}x for one order`, extraUnits];
}
5

Restore the extra units, per line item

When the action is fix, spread the extra units back across the order's line items in the same proportion they were originally sold, then add each amount back to the matching product's stock quantity with the WooCommerce REST API. Finish by writing an order note so the extra reduction is never applied again by mistake, and so a shop manager can see exactly what happened.

apply.py
def restore_stock(order, extra_units):
    for item in order.get("line_items") or []:
        product_id = item.get("product_id")
        qty = int(item.get("quantity") or 0)
        if not product_id or not qty:
            continue
        product = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}", auth=AUTH, timeout=30).json()
        if not product.get("manage_stock"):
            continue
        current = int(product.get("stock_quantity") or 0)
        add_back = qty  # this line's share of one extra full reduction
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
            json={"stock_quantity": current + add_back},
            auth=AUTH, timeout=30,
        ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Stock repair: {extra_units} extra unit(s) were removed by a duplicate "
                      f"reduction and have been added back. First reduction was left in place."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function restoreStock(order, extraUnits) {
  for (const item of order.line_items || []) {
    const productId = item.product_id;
    const qty = Number(item.quantity) || 0;
    if (!productId || !qty) continue;
    const product = await woo(`/products/${productId}`);
    if (!product.manage_stock) continue;
    const current = Number(product.stock_quantity) || 0;
    const addBack = qty; // this line's share of one extra full reduction
    await woo(`/products/${productId}`, {
      method: "PUT",
      body: JSON.stringify({ stock_quantity: current + addBack }),
    });
  }
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Stock repair: ${extraUnits} extra unit(s) were removed by a duplicate ` +
            `reduction and have been added back. First reduction was left in place.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would do. Read the output, check a few orders by hand against your stock history, then switch it off to let it write. This is not something you need running every minute, once a day or run on demand after a suspicious stock count is plenty.

Run it safe

Always start with DRY_RUN=true. This script edits real stock counts, so you want to see its plan before it acts. Once the report looks right, turn it off, and fix the root cause of the duplicate trigger at the same time.

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 only ever restores the extra amount above what the order actually sold, never touching a clean single reduction.

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

repair_double_stock.py
"""Find WooCommerce orders where stock was reduced more than once, and add the
extra units back. Read only in dry run. Safe to run again and again, since it
only ever restores the amount above a single clean reduction.
"""
import os
import logging
import requests
from requests.auth import HTTPBasicAuth
from datetime import date, timedelta

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_double_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", "14"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

STOCK_REDUCED_STATUSES = ("processing", "completed", "on-hold")


def order_expected_qty(order):
    """Total units this order should have removed from stock, once."""
    return sum(int(item.get("quantity") or 0) for item in order.get("line_items") or [])


def recorded_reduced_qty(order):
    """Total units actually removed from stock for this order, from meta."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stock_reduced_qty" and meta.get("value"):
            return int(meta["value"])
    return None


def decide(order, expected_qty, recorded_qty):
    if order is None:
        return ("orphan", "order not found", 0)
    if order.get("status") not in STOCK_REDUCED_STATUSES:
        return ("skip", "order not in a stock-reduced state", 0)
    if not expected_qty:
        return ("skip", "order has no line item quantity", 0)
    if recorded_qty is None:
        return ("skip", "no recorded reduction to compare", 0)
    if recorded_qty <= expected_qty:
        return ("skip", "reduction matches or is under the order total", 0)
    if recorded_qty % expected_qty != 0:
        return ("review", "reduction is extra but not a clean multiple", 0)
    times = recorded_qty // expected_qty
    if times < 2:
        return ("skip", "reduction matches the order total", 0)
    extra_units = expected_qty * (times - 1)
    return ("fix", f"stock reduced {times}x for one order", extra_units)


def candidate_orders():
    page = 1
    after = f"{date.today() - timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": ",".join(STOCK_REDUCED_STATUSES), "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 restore_stock(order, extra_units):
    for item in order.get("line_items") or []:
        product_id = item.get("product_id")
        qty = int(item.get("quantity") or 0)
        if not product_id or not qty:
            continue
        product = requests.get(f"{WOO_URL}/wp-json/wc/v3/products/{product_id}", auth=AUTH, timeout=30).json()
        if not product.get("manage_stock"):
            continue
        current = int(product.get("stock_quantity") or 0)
        add_back = qty  # this line's share of one extra full reduction
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
            json={"stock_quantity": current + add_back},
            auth=AUTH, timeout=30,
        ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Stock repair: {extra_units} extra unit(s) were removed by a duplicate "
                      f"reduction and have been added back. First reduction was left in place."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for order in candidate_orders():
        expected_qty = order_expected_qty(order)
        recorded_qty = recorded_reduced_qty(order)
        action, reason, extra_units = decide(order, expected_qty, recorded_qty)
        if action == "orphan":
            log.warning("Order missing while checking stock reduction")
            continue
        if action in ("skip", "review"):
            if action == "review":
                log.warning("Order %s: %s, needs a human look", order["id"], reason)
            continue
        log.info("Order %s: %s. %s %d unit(s)", order["id"], reason,
                  "would restore" if DRY_RUN else "restoring", extra_units)
        if not DRY_RUN:
            restore_stock(order, extra_units)
        fixed += 1
    log.info("Done. %d order(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
repair-double-stock.js
/**
 * Find WooCommerce orders where stock was reduced more than once, and add the
 * extra units back. Read only in dry run. Safe to run again and again, since it
 * only ever restores the amount above a single clean reduction.
 */
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 14);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const STOCK_REDUCED_STATUSES = ["processing", "completed", "on-hold"];

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.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

function orderExpectedQty(order) {
  return (order.line_items || []).reduce((sum, item) => sum + (Number(item.quantity) || 0), 0);
}

function recordedReducedQty(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stock_reduced_qty" && meta.value) return Number(meta.value);
  }
  return null;
}

function decide(order, expectedQty, recordedQty) {
  if (!order) return ["orphan", "order not found", 0];
  if (!STOCK_REDUCED_STATUSES.includes(order.status)) {
    return ["skip", "order not in a stock-reduced state", 0];
  }
  if (!expectedQty) return ["skip", "order has no line item quantity", 0];
  if (recordedQty === null || recordedQty === undefined) {
    return ["skip", "no recorded reduction to compare", 0];
  }
  if (recordedQty <= expectedQty) return ["skip", "reduction matches or is under the order total", 0];
  if (recordedQty % expectedQty !== 0) return ["review", "reduction is extra but not a clean multiple", 0];
  const times = recordedQty / expectedQty;
  if (times < 2) return ["skip", "reduction matches the order total", 0];
  const extraUnits = expectedQty * (times - 1);
  return ["fix", `stock reduced ${times}x for one order`, extraUnits];
}

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

async function restoreStock(order, extraUnits) {
  for (const item of order.line_items || []) {
    const productId = item.product_id;
    const qty = Number(item.quantity) || 0;
    if (!productId || !qty) continue;
    const product = await woo(`/products/${productId}`);
    if (!product.manage_stock) continue;
    const current = Number(product.stock_quantity) || 0;
    const addBack = qty; // this line's share of one extra full reduction
    await woo(`/products/${productId}`, {
      method: "PUT",
      body: JSON.stringify({ stock_quantity: current + addBack }),
    });
  }
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Stock repair: ${extraUnits} extra unit(s) were removed by a duplicate ` +
            `reduction and have been added back. First reduction was left in place.`,
    }),
  });
}

async function run() {
  let fixed = 0;
  for await (const order of candidateOrders()) {
    const expectedQty = orderExpectedQty(order);
    const recordedQty = recordedReducedQty(order);
    const [action, reason, extraUnits] = decide(order, expectedQty, recordedQty);
    if (action === "orphan") { console.warn("Order missing while checking stock reduction"); continue; }
    if (action === "skip" || action === "review") {
      if (action === "review") console.warn(`Order ${order.id}: ${reason}, needs a human look`);
      continue;
    }
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would restore" : "restoring"} ${extraUnits} unit(s)`);
    if (!DRY_RUN) await restoreStock(order, extraUnits);
    fixed++;
  }
  console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}

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 stock counts get touched. Because we kept decide pure, the test needs no network and no live store. It just feeds in plain objects and checks the action.

test_double_decide.py
from repair_double_stock import decide


def order(**over):
    base = {"status": "processing"}
    base.update(over)
    return base


def test_fix_when_reduced_exactly_twice():
    action, reason, extra = decide(order(), 3, 6)
    assert action == "fix"
    assert extra == 3


def test_fix_when_reduced_three_times():
    action, reason, extra = decide(order(), 2, 6)
    assert action == "fix"
    assert extra == 4


def test_skip_when_reduction_matches_order():
    action, reason, extra = decide(order(), 3, 3)
    assert action == "skip"


def test_review_when_not_a_clean_multiple():
    action, reason, extra = decide(order(), 3, 7)
    assert action == "review"


def test_skip_when_order_not_in_reduced_state():
    action, reason, extra = decide(order(status="pending"), 3, 6)
    assert action == "skip"


def test_orphan_when_order_missing():
    action, reason, extra = decide(None, 3, 6)
    assert action == "orphan"
decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./decide.js";

const order = (over = {}) => ({ status: "processing", ...over });

test("fix when reduced exactly twice", () => {
  const [action, , extra] = decide(order(), 3, 6);
  assert.equal(action, "fix");
  assert.equal(extra, 3);
});

test("fix when reduced three times", () => {
  const [action, , extra] = decide(order(), 2, 6);
  assert.equal(action, "fix");
  assert.equal(extra, 4);
});

test("skip when reduction matches order", () => {
  assert.equal(decide(order(), 3, 3)[0], "skip");
});

test("review when not a clean multiple", () => {
  assert.equal(decide(order(), 3, 7)[0], "review");
});

test("skip when order not in reduced state", () => {
  assert.equal(decide(order({ status: "pending" }), 3, 6)[0], "skip");
});

test("orphan when order missing", () => {
  assert.equal(decide(null, 3, 6)[0], "orphan");
});

Case studies

Duplicate webhook

The gateway that fired the same event twice

A store's payment gateway retried a webhook after a slow response, even though the first request had already been processed. Both requests hit the "payment complete" hook, and for a handful of orders placed in the same minute, stock dropped by double the real amount.

The script ran in dry run, listed nine affected orders with the exact extra unit count for each, and after a quick check against the order totals, the team ran it for real and put the missing stock back the same afternoon.

Manual admin click

The bulk action that ran twice

A shop manager selected a batch of orders in wp-admin and used a "reduce stock" bulk action to catch up on a backlog, not realizing several of those orders had already been reduced automatically when they were paid. Popular products showed far less stock than were actually on the shelf.

Running the script with a longer lookback window caught the whole batch at once, since every affected order was a clean 2x of its real line quantity, and restocked each product to its correct count.

What good looks like

After this runs once against your recent order history, stock counts line up with what orders actually sold again, and any product that looked wrongly out of stock comes back for sale. Keep the script on hand and rerun it whenever a stock count looks off, and fix the duplicate trigger you find so the same order cannot reduce stock twice again.

FAQ

Why did WooCommerce take stock twice for one order?

WooCommerce is supposed to reduce stock exactly once per order, using the _order_stock_reduced meta flag as a guard. If two things try to reduce stock for the same order, such as a duplicate payment webhook and a manual admin action, and the flag is not checked correctly at that moment, the reduction runs twice and stock drops more than the order actually sold.

Is it safe to add stock back with a script?

Yes, when the script only acts on orders where its own math shows an extra reduction happened, meaning the recorded reduction is a whole multiple of the order's line quantities and greater than one. It restores only the extra amount, never the first legitimate reduction, and it skips anything it is not sure about. Start in dry run mode to review the list before it writes.

How do I stop it from happening again?

Make sure only one code path reduces stock for an order, guard any custom hook with a check on _order_stock_reduced before calling wc_reduce_stock_levels, and remove duplicate webhook deliveries before they reach your payment handler. The repair script is a safety net, not a replacement for fixing the root cause.

Related field notes

Citations

On the problem:

  1. WooCommerce core source: wc_reduce_stock_levels() and the _order_stock_reduced guard meta. github.com/woocommerce/woocommerce
  2. WooCommerce docs: how stock management and reservations work on orders. woocommerce.com/document/managing-products
  3. WooCommerce core issue tracker: reports of stock being reduced more than once for a single order. github.com/woocommerce/woocommerce/issues

On the solution:

  1. WooCommerce REST API: update a product's stock quantity. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: list orders and read line items. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce REST API: add an order note. woocommerce.github.io/woocommerce-rest-api-docs

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this fix your stock counts?

If this saved you a pile of oversold or wrongly out-of-stock products, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes