Diagnostic WooCommerce core: scheduling, cron, and email
REST pagination breaks on large sets
An export job or sync script pages through /wp-json/wc/v3/orders with page and per_page, and it runs fine on a quiet store. On a busy one, the same job quietly comes back short. A handful of orders never show up in any page, and nobody notices until a report does not add up or a paid order never gets synced anywhere. Here is why the pagination misses rows and a small script that walks the same orders with a stable sort instead.
WooCommerce's default order listing sorts by date, and dates are not a stable or unique key while orders keep being created or edited. Paging with page=N assumes the rows under each page number hold still between requests. On a large or busy store they do not, so rows shift across the page boundary and get skipped entirely. Page with a stable, immutable sort instead, orderby=id&order=asc, and track an id floor rather than a page number. Full code, tests, and a dry run guard are below.
The problem in plain words
Every REST API page walk makes an assumption: that the set of rows does not reshuffle while you are reading it. WooCommerce's orders endpoint defaults to sorting by date, most recent first. That is a fine order for a person browsing the admin screen. It is a poor order for a script that reads page 1, then page 2, then page 3, expecting every order to appear exactly once across the whole walk.
The date a row sorts by can change after you have already fetched the page it used to belong to. A new order lands and pushes everything down one slot. An existing order gets a note, a refund, or a status change and its position in a secondary sort tiebreak shifts. Either way, a row that was about to appear on page 3 can slide onto page 2, which you already read, or a row already counted on page 2 can slide onto page 3 as something else lands ahead of it. The walk finishes, the counts look plausible, and the job reports success. The missing rows never raise an error. They are just gone from the result.
Why it happens
The WooCommerce REST API documents page and per_page as the standard way to page through collections, sorted by orderby and order, which default to date and descending. Nothing in that contract promises the underlying set stays frozen between one request and the next, because nothing about a live store's order table is frozen. A few concrete ways it shows up:
- A busy checkout keeps inserting new orders with a
date_creatednewer than anything already fetched, which pushes every existing row down one position in a descending date sort. - An order gets refunded, edited, or has a note added, which can touch
date_modifiedand change its position if the job sorts by that field instead. - High Performance Order Storage (HPOS) and legacy post based storage can return orders in a very slightly different tie-break order when two orders share the same second-level timestamp, which matters once a page boundary lands between them.
- A batch job or nightly export takes long enough, at thousands of orders and dozens of pages, that the table is guaranteed to change somewhere in the middle of the walk.
None of this is a WooCommerce bug. It is the general truth about offset or page based pagination against any table that keeps changing while it is being read, and it applies the same way to WordPress's own post queries. The fix does not live on the server. It lives in how the walking code asks for the next page.
An order's numeric id is assigned once and never changes and never reorders. A date can shift under you mid-walk. An id cannot. Sorting by id ascending and tracking the highest id seen so far, instead of a page number, turns pagination into a walk that can only move forward and can never re-show or hide a row.
The fix, as a flow
Instead of asking for "page 3", the walk asks for "the next batch of orders in id order". Each response is checked by a small, pure function that decides which rows in that batch are actually new, versus which ones are repeats of an id the walk already passed. That distinction is what lets the walk detect a reshuffle instead of silently losing rows to one, and it is also what makes the whole thing possible to unit test without touching a real store.
Build it step by step
Get access to both systems
You need a WooCommerce REST API key pair (a consumer key and a consumer secret) with at least read access to orders, and a Stripe secret key if you also want the sweep to repair anything the old pagination bug left unpaid. 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 PAGE_SIZE="100"
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 PAGE_SIZE="100"
export DRY_RUN="true" // start safe, change to false to write
Fetch pages sorted by id, not by date
Ask the orders endpoint to sort by id ascending. This one change already removes the reshuffle risk from the sort itself, since ids never move. We still fetch a fixed size batch each call, the same shape as a normal page walk.
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 fetch_page(page_size):
params = {"orderby": "id", "order": "asc", "per_page": page_size}
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
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 fetchPage(pageSize) {
return woo(`/orders?orderby=id&order=asc&per_page=${pageSize}`);
}
Decide what is new, with one pure function
Keep the walk's core decision in its own function that takes a batch of orders and the id floor from the previous request, and returns which orders are new, how many were repeats, and the next floor. A pure function like this is easy to read and easy to test, which we do later. An order counts as new only when its id is strictly above the floor.
def decide_batch(orders, last_seen_id):
new_orders = []
repeats = 0
highest = last_seen_id
for order in orders:
oid = order["id"]
if last_seen_id is not None and oid <= last_seen_id:
repeats += 1
continue
new_orders.append(order)
if highest is None or oid > highest:
highest = oid
return {"new_orders": new_orders, "repeats": repeats, "next_floor": highest}
export function decideBatch(orders, lastSeenId) {
const newOrders = [];
let repeats = 0;
let highest = lastSeenId;
for (const order of orders) {
const oid = order.id;
if (lastSeenId !== null && oid <= lastSeenId) {
repeats++;
continue;
}
newOrders.push(order);
if (highest === null || oid > highest) highest = oid;
}
return { newOrders, repeats, nextFloor: highest };
}
Loop until a batch returns nothing new
Wrap the fetch and the decision in a loop. The floor only ever moves up. When a fetch comes back with no id past the current floor, the walk has caught up with the end of the table and can stop, the same way a normal page walk stops on an empty page.
def walk_all_orders(page_size=100):
last_seen_id = None
while True:
batch = fetch_page(page_size)
if not batch:
return
result = decide_batch(batch, last_seen_id)
for order in result["new_orders"]:
yield order
if result["next_floor"] == last_seen_id:
return
last_seen_id = result["next_floor"]
export async function* walkAllOrders(pageSize = 100) {
let lastSeenId = null;
while (true) {
const batch = await fetchPage(pageSize);
if (!batch.length) return;
const result = decideBatch(batch, lastSeenId);
for (const order of result.newOrders) yield order;
if (result.nextFloor === lastSeenId) return;
lastSeenId = result.nextFloor;
}
}
Cross-check with Stripe and repair what was missed
Once the walk itself is safe, use it for something useful: find orders that the old, unsafe pagination might have skipped and left unpaid, even though Stripe already confirms the charge succeeded. Read the PaymentIntent id from order meta _stripe_intent_id, falling back to transaction_id when it looks like a PaymentIntent id. Keep the amount comparison in cents so rounding never causes a false mismatch.
PAID_STATUSES = {"processing", "completed"}
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 order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(order, intent):
if intent is None:
return ("skip", "no Stripe PaymentIntent on this order")
if order["status"] in PAID_STATUSES:
return ("skip", "order already paid")
if intent.get("status") != "succeeded":
return ("skip", "intent not succeeded")
if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
return ("mismatch", "amount does not match the Stripe charge")
return ("fix", "paid in Stripe, missed during pagination, still unpaid in Woo")
const PAID_STATUSES = new Set(["processing", "completed"]);
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 orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
export function decide(order, intent) {
if (!intent) return ["skip", "no Stripe PaymentIntent on this order"];
if (PAID_STATUSES.has(order.status)) return ["skip", "order already paid"];
if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
return ["mismatch", "amount does not match the Stripe charge"];
}
return ["fix", "paid in Stripe, missed during pagination, still unpaid in Woo"];
}
Always start with DRY_RUN=true. The sweep is read only while it walks orders, but it can write to real orders once it decides to repair one. See its report first, trust it, then switch the flag off. It is safe to run again and again, since it never touches an order that is already paid.
The full code
Here is the complete sweep in one file for each language. It reads settings from the environment, walks orders with the stable, id based sort, logs what it does, respects the dry run flag, and is safe to run again and again.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Walk every WooCommerce order on a large store without dropping rows.
Paging the REST API with page= and per_page= alone is unsafe once the store
is busy: WooCommerce sorts by date by default, and dates are not unique or
stable while new orders keep landing or refunds change updated_at. A row
can slide from page 2 to page 1 between two requests and never appear in
either page you actually fetched, or appear in both.
This walks orders with a stable sort (orderby=id&order=asc) and an id
floor instead of a page number, so a row can only be seen once and nothing
between two ids can be skipped. It cross-checks each order's saved Stripe
PaymentIntent id (meta _stripe_intent_id, falling back to transaction_id)
and reports anything unpaid that Stripe already settled. Read only by
default. Run on a schedule or as a one-off backfill.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("paginate_orders")
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "100"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
PAID_STATUSES = {"processing", "completed"}
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 decide_batch(orders, last_seen_id):
"""Pure. Given one fetched page and the id floor used to fetch it,
return which orders are new to process, how many were unexpected
repeats, and the new floor for the next request.
An order counts as new only when its id is strictly greater than the
floor. A repeat (id at or below the floor) means the server re-served
a row you already passed, exactly the failure mode a naive page= walk
hides on a table that keeps changing while you scan it.
"""
new_orders = []
repeats = 0
highest = last_seen_id
for order in orders:
oid = order["id"]
if last_seen_id is not None and oid <= last_seen_id:
repeats += 1
continue
new_orders.append(order)
if highest is None or oid > highest:
highest = oid
return {"new_orders": new_orders, "repeats": repeats, "next_floor": highest}
def order_amount_minor(order):
return round(float(order["total"]) * 100)
def decide(order, intent):
"""Pure. Given one order and its Stripe PaymentIntent (or None), decide
whether the order needs repair. Only orders Stripe confirms as paid,
but WooCommerce still shows as unpaid, are worth touching.
"""
if intent is None:
return ("skip", "no Stripe PaymentIntent on this order")
if order["status"] in PAID_STATUSES:
return ("skip", "order already paid")
if intent.get("status") != "succeeded":
return ("skip", "intent not succeeded")
if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
return ("mismatch", "amount does not match the Stripe charge")
return ("fix", "paid in Stripe, missed during pagination, still unpaid in Woo")
def fetch_page(min_id, page_size):
"""One page, sorted by id ascending. WooCommerce has no after_id filter,
so we ask for the next page_size rows in id order and let decide_batch
drop anything at or below the floor. That drop is safe because ids are
assigned once and never reused or reordered.
"""
params = {"orderby": "id", "order": "asc", "per_page": page_size}
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def walk_all_orders(page_size=None):
"""Yield every order exactly once, using an id floor instead of a page
number. Stops when a fetch returns no id past the current floor.
"""
page_size = page_size or PAGE_SIZE
last_seen_id = None
while True:
batch = fetch_page(last_seen_id, page_size)
if not batch:
return
result = decide_batch(batch, last_seen_id)
for order in result["new_orders"]:
yield order
if result["next_floor"] == last_seen_id:
return
last_seen_id = result["next_floor"]
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 mark_processing(order_id, intent):
charge_id = intent.get("latest_charge") or intent["id"]
requests.put(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
json={"status": "processing", "transaction_id": charge_id},
auth=AUTH, timeout=30,
).raise_for_status()
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
json={"note": f"Repaired by the pagination sweep. Stripe PaymentIntent {intent['id']} "
f"was succeeded but this order was missed by an earlier page walk."},
auth=AUTH, timeout=30,
).raise_for_status()
def run():
fixed = 0
scanned = 0
for order in walk_all_orders():
scanned += 1
intent = get_intent(intent_id_of(order))
action, reason = decide(order, intent)
if action != "fix":
if action == "mismatch":
log.warning("Order %s amount mismatch: %s", order["id"], reason)
continue
log.info("Order %s: %s. %s", order["id"], reason, "would fix" if DRY_RUN else "fixing")
if not DRY_RUN:
mark_processing(order["id"], intent)
fixed += 1
log.info("Scanned %d order(s). %d %s.", scanned, fixed, "to fix" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Walk every WooCommerce order on a large store without dropping rows.
*
* Paging the REST API with page= and per_page= alone is unsafe once the
* store is busy: WooCommerce sorts by date by default, and dates are not
* unique or stable while new orders keep landing or refunds change
* updated_at. A row can slide from page 2 to page 1 between two requests
* and never appear in either page you actually fetched, or appear in both.
*
* This walks orders with a stable sort (orderby=id&order=asc) and an id
* floor instead of a page number, so a row can only be seen once and
* nothing between two ids can be skipped. It cross-checks each order's
* saved Stripe PaymentIntent id (meta _stripe_intent_id, falling back to
* transaction_id) and reports anything unpaid that Stripe already
* settled. Read only by default. Run on a schedule or as a one-off backfill.
*
* Guide: https://www.allanninal.dev/woocommerce/rest-pagination-breaks-on-large-sets/
*/
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 PAGE_SIZE = Number(process.env.PAGE_SIZE || 100);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAID_STATUSES = new Set(["processing", "completed"]);
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;
}
/**
* Pure. Given one fetched page and the id floor used to fetch it, return
* which orders are new to process, how many were unexpected repeats, and
* the new floor for the next request.
*
* An order counts as new only when its id is strictly greater than the
* floor. A repeat (id at or below the floor) means the server re-served a
* row already passed, exactly the failure mode a naive page= walk hides
* on a table that keeps changing while you scan it.
*/
export function decideBatch(orders, lastSeenId) {
const newOrders = [];
let repeats = 0;
let highest = lastSeenId;
for (const order of orders) {
const oid = order.id;
if (lastSeenId !== null && oid <= lastSeenId) {
repeats++;
continue;
}
newOrders.push(order);
if (highest === null || oid > highest) highest = oid;
}
return { newOrders, repeats, nextFloor: highest };
}
export function orderAmountMinor(order) {
return Math.round(parseFloat(order.total) * 100);
}
/**
* Pure. Given one order and its Stripe PaymentIntent (or null), decide
* whether the order needs repair. Only orders Stripe confirms as paid,
* but WooCommerce still shows as unpaid, are worth touching.
*/
export function decide(order, intent) {
if (!intent) return ["skip", "no Stripe PaymentIntent on this order"];
if (PAID_STATUSES.has(order.status)) return ["skip", "order already paid"];
if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
return ["mismatch", "amount does not match the Stripe charge"];
}
return ["fix", "paid in Stripe, missed during pagination, still unpaid in Woo"];
}
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();
}
/**
* One page, sorted by id ascending. WooCommerce has no after_id filter, so
* we always ask for the next per_page rows in id order and let
* decideBatch drop anything at or below the floor client side. That drop
* is safe because ids are assigned once and never reused or reordered.
*/
async function fetchPage(pageSize) {
return woo(`/orders?orderby=id&order=asc&per_page=${pageSize}`);
}
/**
* Yield every order exactly once, using an id floor instead of a page
* number. Stops when a fetch returns no id past the current floor.
*/
export async function* walkAllOrders(pageSize = PAGE_SIZE) {
let lastSeenId = null;
while (true) {
const batch = await fetchPage(pageSize);
if (!batch.length) return;
const result = decideBatch(batch, lastSeenId);
for (const order of result.newOrders) yield order;
if (result.nextFloor === lastSeenId) return;
lastSeenId = result.nextFloor;
}
}
async function getIntent(intentId) {
if (!intentId) return null;
try {
return await stripe.paymentIntents.retrieve(intentId);
} catch {
return null;
}
}
async function markProcessing(orderId, intent) {
const chargeId = intent.latest_charge || intent.id;
await woo(`/orders/${orderId}`, {
method: "PUT",
body: JSON.stringify({ status: "processing", transaction_id: chargeId }),
});
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({
note: `Repaired by the pagination sweep. Stripe PaymentIntent ${intent.id} ` +
`was succeeded but this order was missed by an earlier page walk.`,
}),
});
}
export async function run() {
let fixed = 0;
let scanned = 0;
for await (const order of walkAllOrders()) {
scanned++;
const intent = await getIntent(intentIdOf(order));
const [action, reason] = decide(order, intent);
if (action !== "fix") {
if (action === "mismatch") console.warn(`Order ${order.id} amount mismatch: ${reason}`);
continue;
}
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
if (!DRY_RUN) await markProcessing(order.id, intent);
fixed++;
}
console.log(`Scanned ${scanned} order(s). ${fixed} ${DRY_RUN ? "to fix" : "fixed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
Two functions are worth testing above everything else here: decide_batch, which is the whole reason the walk stays correct on a changing table, and decide, which decides whether a real order gets written to. Because both are pure, the tests need no network and no Stripe account. They just feed in plain objects and check the result.
from paginate_orders import decide, decide_batch
def intent(**over):
base = {"status": "succeeded", "amount_received": 5000, "id": "pi_1"}
base.update(over)
return base
def order(**over):
base = {"id": 100, "status": "pending", "total": "50.00"}
base.update(over)
return base
def test_first_page_has_no_repeats():
batch = [order(id=1), order(id=2), order(id=3)]
result = decide_batch(batch, None)
assert [o["id"] for o in result["new_orders"]] == [1, 2, 3]
assert result["repeats"] == 0
assert result["next_floor"] == 3
def test_next_page_only_keeps_ids_above_the_floor():
batch = [order(id=3), order(id=4), order(id=5)]
result = decide_batch(batch, 3)
assert [o["id"] for o in result["new_orders"]] == [4, 5]
assert result["repeats"] == 1
def test_a_row_that_shifted_back_a_page_is_dropped_as_a_repeat_not_lost():
batch = [order(id=10), order(id=11)]
result = decide_batch(batch, 11)
assert result["new_orders"] == []
assert result["repeats"] == 2
def test_fix_when_stripe_succeeded_but_order_still_unpaid():
assert decide(order(status="pending"), intent())[0] == "fix"
def test_skip_when_no_intent_saved():
assert decide(order(status="pending"), None)[0] == "skip"
def test_mismatch_when_amount_differs():
assert decide(order(status="pending", total="40.00"), intent())[0] == "mismatch"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, decideBatch } from "./paginate-orders.js";
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, id: "pi_1", ...over });
const order = (over = {}) => ({ id: 100, status: "pending", total: "50.00", ...over });
test("first page has no repeats", () => {
const batch = [order({ id: 1 }), order({ id: 2 }), order({ id: 3 })];
const result = decideBatch(batch, null);
assert.deepEqual(result.newOrders.map((o) => o.id), [1, 2, 3]);
assert.equal(result.repeats, 0);
assert.equal(result.nextFloor, 3);
});
test("next page only keeps ids above the floor", () => {
const batch = [order({ id: 3 }), order({ id: 4 }), order({ id: 5 })];
const result = decideBatch(batch, 3);
assert.deepEqual(result.newOrders.map((o) => o.id), [4, 5]);
assert.equal(result.repeats, 1);
});
test("a row that shifted back a page is dropped as a repeat, not lost", () => {
const batch = [order({ id: 10 }), order({ id: 11 })];
const result = decideBatch(batch, 11);
assert.deepEqual(result.newOrders, []);
assert.equal(result.repeats, 2);
});
test("fix when Stripe succeeded but order still unpaid", () => {
assert.equal(decide(order({ status: "pending" }), intent())[0], "fix");
});
test("skip when no intent saved", () => {
assert.equal(decide(order({ status: "pending" }), null)[0], "skip");
});
test("mismatch when amount differs", () => {
assert.equal(decide(order({ status: "pending", total: "40.00" }), intent())[0], "mismatch");
});
Case studies
The accounting export that was always a few orders short
A store ran a nightly export job that paged through the previous day's orders with page and per_page=50, sorted the default way. Every few nights the export was missing one or two orders, always ones placed close to midnight when the batch job and live checkout traffic overlapped.
Switching the export to the id based walk removed the gap entirely. The team also ran the sweep once against the last 90 days to confirm nothing older had been missed, and it turned up three orders from a previous flash sale that had quietly fallen through.
Orders that never reached the warehouse system
A third-party fulfillment sync pulled new orders every ten minutes using page numbers. During a promotion, order volume was high enough that the sync's own page boundaries kept sliding, and a handful of paid orders never made it into the warehouse queue at all, with no error anywhere in either system's logs.
The store's engineer plugged the id based walk into the same sync job. Volume no longer mattered. Every order was read exactly once, regardless of how many new ones landed while the sync was mid-run.
Once a job walks orders by a stable, immutable key instead of a page number, order volume stops being a risk. It does not matter if a thousand new orders land while the walk is still running, because the walk can only move forward and can only skip a row it has already returned. Reach for this pattern for any export, sync, or reconciliation job that reads more than one page of orders.
FAQ
Why does my WooCommerce REST API export skip orders on a large store?
The default page and per_page pagination sorts by date, and dates are not unique or stable while new orders keep landing or existing orders get updated. A row can slide from one page to another between two requests and never appear in either page you actually fetched. Paging with a stable sort such as orderby=id and order=asc, and an id floor instead of a page number, fixes it.
Is switching to id based pagination safe on a live store?
Yes. Sorting by id ascending does not change any order data, it only changes how you read it. The sweep is read only by default and only writes when DRY_RUN is turned off, and even then it only touches orders it can prove are unpaid but already settled in Stripe.
How big does an order table need to be before this bites?
It depends on traffic, not raw row count. Any store where new orders can land, or existing orders can be edited, while a full export or sync job is still running is at risk, even with a few thousand orders. Batch jobs, nightly syncs, and reporting exports on busy stores are the most common places it shows up.
Related field notes
Citations
On the problem:
- WooCommerce REST API docs: orders list parameters, including page, per_page, orderby, and order. woocommerce.github.io/woocommerce-rest-api-docs
- WordPress developer docs: WP_Query pagination and why offset based paging can skip or repeat rows on a changing table. developer.wordpress.org/reference/classes/wp_query
- General discussion of the keyset (seek method) pagination problem versus offset pagination on a live data set. use-the-index-luke.com/no-offset
On the solution:
- WooCommerce REST API: list orders, filter and sort parameters including orderby=id. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: retrieve a PaymentIntent to confirm a charge's status and amount. docs.stripe.com/api/payment_intents/retrieve
- WooCommerce docs: order data storage and High Performance Order Storage (HPOS). woocommerce.com/document/high-performance-order-storage
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 fix your missing orders?
If this saved you a bad export or a support ticket about orders that never showed up, 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