Reconciler Orders and Payments
Order stuck in payment review with no way out
A PayPal fraud filter, an Adyen or Braintree risk check, or a custom gateway adapter flagged the transaction for manual review, and Magento put the order in payment_review to wait for the gateway's own callback. Then the callback never came. There is no invoice, the Cancel button is missing from the admin order view, and the order just sits there holding an inventory reservation forever. Here is why Magento leaves it that way and a small script that finds the stuck ones and safely reports, flags, or cancels them.
Magento sets an order's state to payment_review (Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW) when an async gateway flags a transaction for manual review before authorizing it, and only the gateway's own IPN or webhook calling acceptPayment or denyPayment can move it forward. If that callback never arrives, the order has no Cancel button in the admin grid and no cancel path in the default REST API, so it sits there indefinitely. Run a small Python or Node.js script that lists orders in payment_review older than a threshold, cross-checks the status history to rule out a callback that already fired, and either reports, flags for manual review, or force-cancels via POST /orders/{id}/cancel depending on whether money was actually captured. Full code, tests, and a dry run guard are below.
The problem in plain words
Most Magento payment methods are synchronous. The customer pays at checkout, the gateway answers immediately, and the order becomes Processing with an invoice attached, all inside the same request.
Some gateways are not synchronous. PayPal's fraud and risk filters, Adyen, Braintree, and plenty of custom payment adapters can decide a transaction needs a second look before it is authorized. Magento cannot wait around for that decision inside the checkout request, so it parks the order in payment_review and expects the gateway to call back later, through an IPN or a webhook, with an accept or a deny. Because there is no invoice yet and the payment method reports the order as gateway-held, Magento's admin UI removes the Cancel action so nobody accidentally fights the gateway's own decision. If the callback never shows up, that order has no way out on its own.
Why it happens
Magento hands the release of a payment_review order entirely to the gateway's own async callback, and does not run any timeout logic of its own. A few common ways stores end up with orders stuck like this:
- The PayPal IPN URL, or the Adyen or Braintree webhook endpoint, is misconfigured, blocked by a firewall, or points at the wrong store base URL after a migration.
- A custom payment adapter implements the review state but never wires up the callback controller that would call
acceptPaymentordenyPayment. - The merchant is expected to log in to the payment provider's own dashboard and manually approve or deny a flagged transaction, and nobody on the team does it.
- A webhook fires once, fails silently on the Magento side because of an expired admin session or a code exception, and is never retried by the gateway.
This is a well documented gap. See the citations at the end for the exact GitHub issues describing orders stuck in payment review with the Cancel action missing from both the order grid and the default REST API.
Cancelling a payment_review order is not risk free, because the gateway might still approve it after you have moved on, which risks double fulfillment or a chargeback dispute if you shipped anyway. So the safe rule is not "cancel every stuck order." It is "only cancel the ones where nothing has actually been captured yet." We check total_invoiced for that, and we check the order's own status history to make sure a callback did not just fire moments ago, which would mean cron simply has not caught up yet.
The fix, as a flow
We do not touch live checkout or the gateway integration. We add a job that lists orders in payment_review past an age threshold, reads each one's invoice total and status history, and only then decides between three outcomes: skip it, flag it for a human, or force-cancel it. In dry run, the default, nothing is written at all.
Build it step by step
Get an admin bearer token
Call POST /rest/V1/integration/admin/token with your admin username and password, or use an integration token created in Admin under System, Integrations. Every call below sends it as Authorization: Bearer <token>. Keep the token and the store URL in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export THRESHOLD_HOURS="48"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="your admin bearer token"
export THRESHOLD_HOURS="48"
export DRY_RUN="true" // start safe, change to false to write
Find orders stuck in payment_review
Search GET /orders with a filter group on state equal to payment_review and a second filter group on created_at with conditionType=lteq against now minus the threshold. Sort by created_at so the oldest stuck orders show up first.
import os, datetime, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def stuck_payment_review_orders(threshold_hours, page_size=100):
now = datetime.datetime.now(datetime.timezone.utc)
cutoff = (now - datetime.timedelta(hours=threshold_hours)).strftime("%Y-%m-%d %H:%M:%S")
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "state",
"searchCriteria[filterGroups][0][filters][0][value]": "payment_review",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[filterGroups][1][filters][0][field]": "created_at",
"searchCriteria[filterGroups][1][filters][0][value]": cutoff,
"searchCriteria[filterGroups][1][filters][0][conditionType]": "lteq",
"searchCriteria[sortOrders][0][field]": "created_at",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": 1,
}
return magento_get("/orders", params)["items"]
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function stuckPaymentReviewOrders(thresholdHours, pageSize = 100) {
const cutoff = new Date(Date.now() - thresholdHours * 3600000)
.toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "state",
"searchCriteria[filterGroups][0][filters][0][value]": "payment_review",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[filterGroups][1][filters][0][field]": "created_at",
"searchCriteria[filterGroups][1][filters][0][value]": cutoff,
"searchCriteria[filterGroups][1][filters][0][conditionType]": "lteq",
"searchCriteria[sortOrders][0][field]": "created_at",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": 1,
};
const data = await magentoGet("/orders", params);
return data.items;
}
Read the detail: invoice total and status history
The search result is a summary. Call GET /orders/{id} for each match to read total_invoiced, payment.method, and the embedded status_histories array. If the status history has an entry created after the order's own created_at, a callback already changed something, and this is a race condition where cron just has not caught up, not a truly stuck order.
def order_detail(order_id):
return magento_get(f"/orders/{order_id}")
detail = order_detail(order_id)
order = {
"state": detail.get("state"),
"status": detail.get("status"),
"createdAt": detail.get("created_at"),
"totalInvoiced": detail.get("total_invoiced"),
"statusHistories": [
{"createdAt": h.get("created_at")}
for h in (detail.get("status_histories") or [])
],
}
async function orderDetail(orderId) {
return magentoGet(`/orders/${orderId}`);
}
const detail = await orderDetail(orderId);
const order = {
state: detail.state,
status: detail.status,
createdAt: detail.created_at,
totalInvoiced: detail.total_invoiced,
statusHistories: (detail.status_histories || []).map((h) => ({ createdAt: h.created_at })),
};
Decide, with one pure function
Keep the decision in its own function that takes the order fields, the current time, and the age threshold, and returns an action of skip, flag, or cancel with a reason. It skips anything that is not payment_review, anything younger than the threshold, and anything whose status history moved after creation. Only orders with nothing invoiced get cancel. Orders with money already captured get flag instead, because a captured payment has to go through creditmemo and refund, not a plain cancel.
import datetime
def iso_to_epoch(value):
text = value.replace(" ", "T", 1)
return datetime.datetime.fromisoformat(text).replace(
tzinfo=datetime.timezone.utc
).timestamp()
def decide_stuck_order_action(order, now, threshold_hours):
if order.get("state") != "payment_review":
return {"action": "skip", "reason": "not_in_payment_review"}
created_at = order.get("createdAt")
if not created_at:
return {"action": "skip", "reason": "missing_created_at"}
age_hours = (now.timestamp() - iso_to_epoch(created_at)) / 3600.0
if age_hours < threshold_hours:
return {"action": "skip", "reason": "below_age_threshold"}
for entry in order.get("statusHistories") or []:
entry_created = entry.get("createdAt")
if entry_created and iso_to_epoch(entry_created) > iso_to_epoch(created_at):
return {"action": "skip", "reason": "gateway_callback_already_progressed"}
if (order.get("totalInvoiced") or 0) > 0:
return {"action": "flag", "reason": "payment_captured_needs_manual_review"}
return {"action": "cancel", "reason": "no_gateway_callback_within_threshold"}
function isoToEpochMs(value) {
const text = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
return Date.parse(text);
}
export function decideStuckOrderAction(order, now, thresholdHours) {
if (order.state !== "payment_review") {
return { action: "skip", reason: "not_in_payment_review" };
}
if (!order.createdAt) {
return { action: "skip", reason: "missing_created_at" };
}
const ageHours = (now.getTime() - isoToEpochMs(order.createdAt)) / 3600000;
if (ageHours < thresholdHours) {
return { action: "skip", reason: "below_age_threshold" };
}
const createdEpoch = isoToEpochMs(order.createdAt);
const progressed = (order.statusHistories || []).some(
(entry) => entry.createdAt && isoToEpochMs(entry.createdAt) > createdEpoch
);
if (progressed) {
return { action: "skip", reason: "gateway_callback_already_progressed" };
}
if ((order.totalInvoiced || 0) > 0) {
return { action: "flag", reason: "payment_captured_needs_manual_review" };
}
return { action: "cancel", reason: "no_gateway_callback_within_threshold" };
}
Cancel or flag, and always leave a comment
When the decision is cancel, call POST /orders/{id}/cancel, then POST /orders/{id}/comments with a comment so there is an audit trail explaining why the order was force-cancelled without a gateway callback. When the decision is flag, only post the comment, recommending a human open the order and use Accept Payment or Deny Payment, since the money already moved.
def magento_post(path, payload=None):
r = requests.post(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload or {},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def cancel_order(order_id):
return magento_post(f"/orders/{order_id}/cancel")
def add_comment(order_id, comment):
payload = {
"statusHistory": {
"comment": comment,
"is_customer_notified": 0,
"is_visible_on_front": 0,
}
}
return magento_post(f"/orders/{order_id}/comments", payload)
async function magentoPost(path, payload = {}) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function cancelOrder(orderId) {
return magentoPost(`/orders/${orderId}/cancel`);
}
async function addComment(orderId, comment) {
const payload = {
statusHistory: {
comment,
is_customer_notified: 0,
is_visible_on_front: 0,
},
};
return magentoPost(`/orders/${orderId}/comments`, payload);
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs what it would do for each stuck order, by increment_id, age, and payment method. Read the output, confirm it against the gateway's own dashboard, then switch it off to let it write. Run it on a schedule, for example every hour, with THRESHOLD_HOURS matching how long you are willing to wait for a callback before treating an order as truly stuck.
Always start with DRY_RUN=true. Never cancel an order that already has money captured, since the gateway can still approve it later and force-cancelling risks double fulfillment or a chargeback dispute. Let the script flag those for a human, and only auto-cancel the ones where total_invoiced is genuinely zero.
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 only touches orders that are truly stuck in payment_review past your threshold.
"""Detect and repair Magento 2 orders stuck in payment_review with no gateway callback.
Magento sets an order's state to payment_review
(Magento\\Sales\\Model\\Order::STATE_PAYMENT_REVIEW) when an asynchronous
gateway (PayPal fraud and risk filters, Adyen, Braintree, or a custom payment
adapter) flags a transaction for manual review before authorizing it. Orders
in this state have no invoice yet, and the admin UI hides the Cancel action
whenever a payment method's isGatewayOrPaymentReviewSufficient / canCancel
logic reports the order as gateway-held. The order can only be released by
the gateway's own async callback (IPN or webhook) calling acceptPayment or
denyPayment. If that callback never arrives, the order sits in
payment_review indefinitely with no cancel path in the admin grid or the
default REST API, silently soft-locking inventory reservations tied to it.
If DRY_RUN=true (default), this only reports each stuck order. If
DRY_RUN=false and the order has total_invoiced == 0, it force-cancels the
order via POST /orders/{id}/cancel and leaves a status history comment.
If total_invoiced > 0, it only posts a flagging comment recommending manual
Accept Payment or Deny Payment review, since a captured payment must go
through the creditmemo and refund flow, not order cancel.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_payment_review")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
THRESHOLD_HOURS = float(os.environ.get("THRESHOLD_HOURS", "48"))
AUTO_CANCEL_COMMENT = (
"Auto-cancelled: stuck in payment_review beyond threshold, "
"no gateway callback received"
)
FLAG_COMMENT = (
"Flagged: stuck in payment_review beyond threshold with a captured payment. "
"Needs manual Accept Payment or Deny Payment review in Admin."
)
def magento_get(path, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
r.raise_for_status()
return r.json()
def magento_post(path, payload=None):
r = requests.post(
f"{MAGENTO_URL}/rest/V1{path}",
json=payload or {},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
return r.json()
def iso_to_epoch(value):
text = value.replace(" ", "T", 1)
return datetime.datetime.fromisoformat(text).replace(
tzinfo=datetime.timezone.utc
).timestamp()
def decide_stuck_order_action(order, now, threshold_hours):
"""Pure decision over fields already fetched from REST. No I/O.
order: {state, status, createdAt, totalInvoiced, statusHistories}
statusHistories: list of {createdAt}
now: datetime (UTC)
threshold_hours: float
returns {"action": "skip" | "flag" | "cancel", "reason": str}
"""
if order.get("state") != "payment_review":
return {"action": "skip", "reason": "not_in_payment_review"}
created_at = order.get("createdAt")
if not created_at:
return {"action": "skip", "reason": "missing_created_at"}
age_hours = (now.timestamp() - iso_to_epoch(created_at)) / 3600.0
if age_hours < threshold_hours:
return {"action": "skip", "reason": "below_age_threshold"}
for entry in order.get("statusHistories") or []:
entry_created = entry.get("createdAt")
if entry_created and iso_to_epoch(entry_created) > iso_to_epoch(created_at):
return {"action": "skip", "reason": "gateway_callback_already_progressed"}
if (order.get("totalInvoiced") or 0) > 0:
return {"action": "flag", "reason": "payment_captured_needs_manual_review"}
return {"action": "cancel", "reason": "no_gateway_callback_within_threshold"}
def stuck_payment_review_orders(threshold_hours, page_size=100):
now = datetime.datetime.now(datetime.timezone.utc)
cutoff = (now - datetime.timedelta(hours=threshold_hours)).strftime("%Y-%m-%d %H:%M:%S")
params = {
"searchCriteria[filterGroups][0][filters][0][field]": "state",
"searchCriteria[filterGroups][0][filters][0][value]": "payment_review",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[filterGroups][1][filters][0][field]": "created_at",
"searchCriteria[filterGroups][1][filters][0][value]": cutoff,
"searchCriteria[filterGroups][1][filters][0][conditionType]": "lteq",
"searchCriteria[sortOrders][0][field]": "created_at",
"searchCriteria[pageSize]": page_size,
"searchCriteria[currentPage]": 1,
}
return magento_get("/orders", params)["items"]
def order_detail(order_id):
return magento_get(f"/orders/{order_id}")
def cancel_order(order_id):
return magento_post(f"/orders/{order_id}/cancel")
def add_comment(order_id, comment):
payload = {
"statusHistory": {
"comment": comment,
"is_customer_notified": 0,
"is_visible_on_front": 0,
}
}
return magento_post(f"/orders/{order_id}/comments", payload)
def run():
now = datetime.datetime.now(datetime.timezone.utc)
cancelled = 0
flagged = 0
for summary in stuck_payment_review_orders(THRESHOLD_HOURS):
order_id = summary.get("entity_id")
detail = order_detail(order_id)
order = {
"state": detail.get("state"),
"status": detail.get("status"),
"createdAt": detail.get("created_at"),
"totalInvoiced": detail.get("total_invoiced"),
"statusHistories": [
{"createdAt": h.get("created_at")}
for h in (detail.get("status_histories") or [])
],
}
decision = decide_stuck_order_action(order, now, THRESHOLD_HOURS)
increment_id = detail.get("increment_id")
payment_method = (detail.get("payment") or {}).get("method")
if decision["action"] == "skip":
continue
if decision["action"] == "flag":
log.warning(
"Order %s payment_review with captured payment (method=%s). %s",
increment_id, payment_method,
"would flag" if DRY_RUN else "flagging",
)
if not DRY_RUN:
add_comment(order_id, FLAG_COMMENT)
flagged += 1
continue
log.warning(
"Order %s stuck in payment_review beyond %sh (method=%s). %s",
increment_id, THRESHOLD_HOURS, payment_method,
"would cancel" if DRY_RUN else "cancelling",
)
if not DRY_RUN:
cancel_order(order_id)
add_comment(order_id, AUTO_CANCEL_COMMENT)
cancelled += 1
log.info(
"Done. %d order(s) %s, %d order(s) %s.",
cancelled, "to cancel" if DRY_RUN else "cancelled",
flagged, "to flag" if DRY_RUN else "flagged",
)
if __name__ == "__main__":
run()
/**
* Detect and repair Magento 2 orders stuck in payment_review with no gateway callback.
*
* Magento sets an order's state to payment_review
* (Magento\Sales\Model\Order::STATE_PAYMENT_REVIEW) when an asynchronous
* gateway (PayPal fraud and risk filters, Adyen, Braintree, or a custom
* payment adapter) flags a transaction for manual review before authorizing
* it. Orders in this state have no invoice yet, and the admin UI hides the
* Cancel action whenever a payment method's isGatewayOrPaymentReviewSufficient
* / canCancel logic reports the order as gateway-held. The order can only be
* released by the gateway's own async callback (IPN or webhook) calling
* acceptPayment or denyPayment. If that callback never arrives, the order
* sits in payment_review indefinitely with no cancel path in the admin grid
* or the default REST API, silently soft-locking inventory reservations tied
* to it.
*
* If DRY_RUN=true (default), this only reports each stuck order. If
* DRY_RUN=false and the order has total_invoiced == 0, it force-cancels the
* order via POST /orders/{id}/cancel and leaves a status history comment.
* If total_invoiced > 0, it only posts a flagging comment recommending
* manual Accept Payment or Deny Payment review, since a captured payment
* must go through the creditmemo and refund flow, not order cancel.
* Run on a schedule. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/magento/order-stuck-in-payment-review/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const THRESHOLD_HOURS = Number(process.env.THRESHOLD_HOURS || 48);
const AUTO_CANCEL_COMMENT =
"Auto-cancelled: stuck in payment_review beyond threshold, no gateway callback received";
const FLAG_COMMENT =
"Flagged: stuck in payment_review beyond threshold with a captured payment. " +
"Needs manual Accept Payment or Deny Payment review in Admin.";
function isoToEpochMs(value) {
// Magento REST timestamps are UTC, formatted as "YYYY-MM-DD HH:MM:SS".
const text = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
return Date.parse(text);
}
export function decideStuckOrderAction(order, now, thresholdHours) {
if (order.state !== "payment_review") {
return { action: "skip", reason: "not_in_payment_review" };
}
if (!order.createdAt) {
return { action: "skip", reason: "missing_created_at" };
}
const ageHours = (now.getTime() - isoToEpochMs(order.createdAt)) / 3600000;
if (ageHours < thresholdHours) {
return { action: "skip", reason: "below_age_threshold" };
}
const createdEpoch = isoToEpochMs(order.createdAt);
const progressed = (order.statusHistories || []).some(
(entry) => entry.createdAt && isoToEpochMs(entry.createdAt) > createdEpoch
);
if (progressed) {
return { action: "skip", reason: "gateway_callback_already_progressed" };
}
if ((order.totalInvoiced || 0) > 0) {
return { action: "flag", reason: "payment_captured_needs_manual_review" };
}
return { action: "cancel", reason: "no_gateway_callback_within_threshold" };
}
async function magentoGet(path, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function magentoPost(path, payload = {}) {
const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Magento ${res.status}`);
return res.json();
}
async function stuckPaymentReviewOrders(thresholdHours, pageSize = 100) {
const cutoff = new Date(Date.now() - thresholdHours * 3600000)
.toISOString()
.replace("T", " ")
.replace(/\.\d+Z$/, "");
const params = {
"searchCriteria[filterGroups][0][filters][0][field]": "state",
"searchCriteria[filterGroups][0][filters][0][value]": "payment_review",
"searchCriteria[filterGroups][0][filters][0][conditionType]": "eq",
"searchCriteria[filterGroups][1][filters][0][field]": "created_at",
"searchCriteria[filterGroups][1][filters][0][value]": cutoff,
"searchCriteria[filterGroups][1][filters][0][conditionType]": "lteq",
"searchCriteria[sortOrders][0][field]": "created_at",
"searchCriteria[pageSize]": pageSize,
"searchCriteria[currentPage]": 1,
};
const data = await magentoGet("/orders", params);
return data.items;
}
async function orderDetail(orderId) {
return magentoGet(`/orders/${orderId}`);
}
async function cancelOrder(orderId) {
return magentoPost(`/orders/${orderId}/cancel`);
}
async function addComment(orderId, comment) {
const payload = {
statusHistory: {
comment,
is_customer_notified: 0,
is_visible_on_front: 0,
},
};
return magentoPost(`/orders/${orderId}/comments`, payload);
}
export async function run() {
const now = new Date();
let cancelled = 0;
let flagged = 0;
const summaries = await stuckPaymentReviewOrders(THRESHOLD_HOURS);
for (const summary of summaries) {
const orderId = summary.entity_id;
const detail = await orderDetail(orderId);
const order = {
state: detail.state,
status: detail.status,
createdAt: detail.created_at,
totalInvoiced: detail.total_invoiced,
statusHistories: (detail.status_histories || []).map((h) => ({ createdAt: h.created_at })),
};
const decision = decideStuckOrderAction(order, now, THRESHOLD_HOURS);
const incrementId = detail.increment_id;
const paymentMethod = (detail.payment || {}).method;
if (decision.action === "skip") continue;
if (decision.action === "flag") {
console.warn(
`Order ${incrementId} payment_review with captured payment (method=${paymentMethod}). ${
DRY_RUN ? "would flag" : "flagging"
}`
);
if (!DRY_RUN) await addComment(orderId, FLAG_COMMENT);
flagged++;
continue;
}
console.warn(
`Order ${incrementId} stuck in payment_review beyond ${THRESHOLD_HOURS}h (method=${paymentMethod}). ${
DRY_RUN ? "would cancel" : "cancelling"
}`
);
if (!DRY_RUN) {
await cancelOrder(orderId);
await addComment(orderId, AUTO_CANCEL_COMMENT);
}
cancelled++;
}
console.log(
`Done. ${cancelled} order(s) ${DRY_RUN ? "to cancel" : "cancelled"}, ${flagged} order(s) ${
DRY_RUN ? "to flag" : "flagged"
}.`
);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides whether a real order gets force-cancelled. Because we kept decide_stuck_order_action pure, with the current time passed in as an argument, the test needs no network and no Magento store. It just feeds in plain objects and checks the answer.
import datetime
from reconcile_payment_review import decide_stuck_order_action
NOW = datetime.datetime(2026, 7, 10, 0, 0, 0, tzinfo=datetime.timezone.utc)
def order(**over):
base = {
"state": "payment_review",
"status": "payment_review",
"createdAt": "2026-07-07 00:00:00", # 72 hours before NOW
"totalInvoiced": 0,
"statusHistories": [],
}
base.update(over)
return base
def test_cancel_when_stuck_past_threshold_no_invoice():
result = decide_stuck_order_action(order(), NOW, 48)
assert result == {"action": "cancel", "reason": "no_gateway_callback_within_threshold"}
def test_skip_when_not_payment_review():
result = decide_stuck_order_action(order(state="processing"), NOW, 48)
assert result["action"] == "skip"
assert result["reason"] == "not_in_payment_review"
def test_skip_when_below_age_threshold():
o = order(createdAt="2026-07-09 12:00:00") # 12 hours before NOW
result = decide_stuck_order_action(o, NOW, 48)
assert result["action"] == "skip"
assert result["reason"] == "below_age_threshold"
def test_skip_when_status_history_progressed_after_created():
o = order(statusHistories=[{"createdAt": "2026-07-08 00:00:00"}])
result = decide_stuck_order_action(o, NOW, 48)
assert result["action"] == "skip"
assert result["reason"] == "gateway_callback_already_progressed"
def test_flag_when_payment_captured():
o = order(totalInvoiced=99.99)
result = decide_stuck_order_action(o, NOW, 48)
assert result == {"action": "flag", "reason": "payment_captured_needs_manual_review"}
def test_skip_when_missing_created_at():
o = order(createdAt=None)
result = decide_stuck_order_action(o, NOW, 48)
assert result["action"] == "skip"
assert result["reason"] == "missing_created_at"
def test_exactly_at_threshold_is_stuck():
o = order(createdAt="2026-07-08 00:00:00") # exactly 48 hours before NOW
result = decide_stuck_order_action(o, NOW, 48)
assert result["action"] == "cancel"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideStuckOrderAction } from "./reconcile-payment-review.js";
const NOW = new Date("2026-07-10T00:00:00Z");
const order = (over = {}) => ({
state: "payment_review",
status: "payment_review",
createdAt: "2026-07-07 00:00:00", // 72 hours before NOW
totalInvoiced: 0,
statusHistories: [],
...over,
});
test("cancel when stuck past threshold with no invoice", () => {
assert.deepEqual(decideStuckOrderAction(order(), NOW, 48), {
action: "cancel",
reason: "no_gateway_callback_within_threshold",
});
});
test("skip when not payment_review", () => {
const result = decideStuckOrderAction(order({ state: "processing" }), NOW, 48);
assert.equal(result.action, "skip");
assert.equal(result.reason, "not_in_payment_review");
});
test("skip when below age threshold", () => {
const result = decideStuckOrderAction(
order({ createdAt: "2026-07-09 12:00:00" }), NOW, 48
);
assert.equal(result.action, "skip");
assert.equal(result.reason, "below_age_threshold");
});
test("skip when status history progressed after created", () => {
const result = decideStuckOrderAction(
order({ statusHistories: [{ createdAt: "2026-07-08 00:00:00" }] }), NOW, 48
);
assert.equal(result.action, "skip");
assert.equal(result.reason, "gateway_callback_already_progressed");
});
test("flag when payment captured", () => {
assert.deepEqual(decideStuckOrderAction(order({ totalInvoiced: 99.99 }), NOW, 48), {
action: "flag",
reason: "payment_captured_needs_manual_review",
});
});
test("skip when missing created at", () => {
const result = decideStuckOrderAction(order({ createdAt: null }), NOW, 48);
assert.equal(result.action, "skip");
assert.equal(result.reason, "missing_created_at");
});
test("exactly at threshold is stuck", () => {
const result = decideStuckOrderAction(
order({ createdAt: "2026-07-08 00:00:00" }), NOW, 48
);
assert.equal(result.action, "cancel");
});
Case studies
The webhook pointed at the old domain
A store migrated to a new domain and forgot to update the PayPal IPN URL in the payment configuration. PayPal kept flagging a slice of transactions for review, sending the accept or deny callback to a URL that no longer resolved, and those orders piled up in payment_review with no invoice and no Cancel button for weeks.
Running the script in dry run surfaced forty two stuck orders in one report, all older than two days, none with a captured payment. The team fixed the IPN URL for new orders, then ran the script for real to clear the backlog, since nothing had been charged on any of them.
Some orders had already been captured
A custom Adyen integration handled the happy path well but never wired up a webhook handler for the risk review outcome. A handful of orders sat in payment_review, and a few of those had actually captured the payment before Adyen decided to flag them for a second look.
The script split the report cleanly: orders with total_invoiced at zero were safe to auto-cancel, and the ones that had already captured money were flagged with a comment asking a human to check the Adyen dashboard and use Accept Payment or Deny Payment from Admin, so nobody accidentally cancelled an order the customer had already paid for.
After this runs on a schedule, an order stuck in payment_review never sits there for more than your threshold without someone knowing about it. The ones with nothing captured get cleared automatically, freeing the inventory reservation they were holding. The ones with money already captured land in front of a human with the exact context needed to accept or deny the payment safely. No order silently rots, and nothing gets cancelled that should not be.
FAQ
Why does a Magento order get stuck in payment_review?
Magento sets an order's state to payment_review when an asynchronous gateway such as PayPal fraud filters, Adyen, or Braintree flags the transaction for manual review before authorizing it. The order only moves forward when the gateway sends its own callback, an IPN or webhook, that calls acceptPayment or denyPayment. If that callback never arrives, because the webhook is misconfigured, the IPN URL is unreachable, or nobody responds in the provider dashboard, the order simply stays in payment_review with no timeout.
Why is there no Cancel button on an order stuck in payment review?
The admin order view hides the Cancel action whenever the payment method reports the order as gateway-held, through its isGatewayOrPaymentReviewSufficient and canCancel logic. Since the gateway technically owns the decision until its callback arrives, Magento assumes cancelling locally could conflict with a callback that is still in flight, so it removes the button rather than risk that race.
Is it safe to auto-cancel every order stuck in payment_review?
No, only when total_invoiced is zero, meaning no money has actually been captured yet. If an amount has already been invoiced, cancelling the order does not reverse the charge, it just desyncs Magento from the gateway, and the correct path is a creditmemo and refund after a human reviews it. That is why the script flags invoiced orders for manual Accept Payment or Deny Payment review instead of cancelling them.
Related field notes
Citations
On the problem:
- GitHub magento/magento2 Issue #26158: Unable to cancel order in Payment Review status. github.com/magento/magento2/issues/26158
- GitHub magento/magento2 Issue #25166: Order Status stuck in payment review in order grid. github.com/magento/magento2/issues/25166
- GitHub magento/magento2 Issue #35382: Order state stuck in Payment_review after IPN update. github.com/magento/magento2/issues/35382
On the solution:
- Adobe Commerce Developer Documentation: Orders API, cancel a specified order (POST /V1/orders/{id}/cancel). developer.adobe.com/commerce/webapi/rest/tutorials/orders/order-management-create-order
- Adobe Commerce Developer Documentation: Search using REST APIs, searchCriteria filter groups. developer.adobe.com/commerce/webapi/rest/use-rest/search-endpoints
- Magento 2 Merchant Documentation: Understanding the Order Lifecycle. docs.magento-opensource.com/merchant/handle-orders/explanation-understanding-the-order-lifecycle
Stuck on a tricky one?
If you have a problem in Magento or Adobe Commerce orders, payments, MSI inventory, indexing, or cron 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 clear your payment review backlog?
If this saved you a pile of manual clicks in Admin or a stuck inventory reservation, 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