Reconciler Order status and webhooks

WooCommerce orders stuck on Pending after a successful Stripe payment

The card went through. Stripe shows the money. The buyer got charged. But the WooCommerce order still says Pending payment, no email went out, and stock is still held. This is the most common Stripe problem a WooCommerce store will ever file a ticket about. Here is why it happens and a small script that finds every affected order and finishes it in a safe way.

Python and Node.js Runs on a schedule Safe by default (dry run)
Person using laptop computer holding card
Photo by rupixen on Unsplash
The short answer

Stripe took the money but the webhook that finishes the order never arrived, so the order is stuck on Pending payment. Run a small Python or Node.js reconciler on a schedule that lists succeeded PaymentIntents from Stripe, matches each one to its order by metadata.order_id, and moves any still unpaid order to Processing when the amount matches. Full code, tests, and a dry run guard are below.

The problem in plain words

When a buyer pays, Stripe takes the money right away. The WooCommerce order only moves to Processing when your store receives a small message from Stripe called a webhook. That webhook is the signal that says "this payment is done, finish the order."

If that one message never arrives, the money is still taken but the order is frozen. Stripe keeps the charge. WooCommerce keeps waiting. The two systems now disagree, and only the buyer notices, usually in an angry email.

Buyer checks out and pays Stripe charges status: succeeded webhook lost Order frozen Pending payment No email Stock held
The money is taken at the charge step. The order never moves because the webhook that should finish it is lost.

Why it happens

The official WooCommerce docs are clear that the automatic order updates only work when webhooks reach the store. When they do not, the order stays where it is. A few common reasons the webhook gets lost:

This is reported often. The WooCommerce Stripe plugin has an open issue where a PaymentIntent is confirmed and succeeds on Stripe, yet the order is left on Pending because the success event is not applied. See the citations at the end for the exact threads.

The key insight

Stripe is the source of truth for money. If Stripe says a PaymentIntent is succeeded and the WooCommerce order is not yet paid, the order is wrong, not Stripe. A reconciler is a safety net that runs on a schedule, reads the truth from Stripe, and repairs the orders the webhook missed.

The fix, as a flow

We do not touch the live checkout. We add a job that runs every few minutes, looks at recent successful payments in Stripe, and checks each matching order in WooCommerce. If Stripe was paid and the order is not, and the amounts agree, we move the order to Processing and write the charge ID onto it, the same way the webhook would have.

Scheduled job every few minutes List succeeded PaymentIntents (last 24h) Read order_id from metadata Pending and amount ok? yes no, skip Mark Processing save charge id + note
The reconciler reads the truth from Stripe and only repairs orders that are still pending and whose amount matches. Everything else is left alone.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
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 LOOKBACK_HOURS="24"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
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 LOOKBACK_HOURS="24"
export DRY_RUN="true"   // start safe, change to false to write
2

List the successful payments from the last day

Ask Stripe for PaymentIntents created in your lookback window. We page through all of them and keep only the ones with status succeeded. The WooCommerce Stripe plugin writes the order ID into the PaymentIntent metadata, so that field is how we find the order.

step2.py
import os, time, stripe

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

def recent_succeeded(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
        if intent.status == "succeeded" and intent.metadata.get("order_id"):
            yield intent
step2.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function* recentSucceeded(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
    if (intent.status === "succeeded" && intent.metadata.order_id) {
      yield intent;
    }
  }
}
3

Load the matching WooCommerce order

Use the WooCommerce REST API to read the order by ID. Going through the REST API means the code works the same on stores with High Performance Order Storage (HPOS) turned on, because WooCommerce handles the storage for you. A missing order is worth logging, since it can point to a different problem.

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 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");

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 an order and an intent and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If the order is already paid, skip it. If the amount does not match, skip it and warn. Otherwise, fix it.

decide.py
PAID_STATUSES = {"processing", "completed"}

def order_amount_minor(order):
    # Works for two decimal currencies. Zero decimal currencies (JPY and friends)
    # have their own guide, since 50.00 is wrong for those.
    return round(float(order["total"]) * 100)

def decide(order, intent):
    if intent.get("status") != "succeeded":
        return ("skip", "intent not succeeded")
    if order is None:
        return ("orphan", "order not found")
    if order["status"] in PAID_STATUSES:
        return ("skip", "order already paid")
    if abs(order_amount_minor(order) - intent["amount_received"]) > 1:
        return ("mismatch", "amount does not match")
    return ("fix", "paid in Stripe, still pending in Woo")
decide.js
const PAID_STATUSES = new Set(["processing", "completed"]);

export function orderAmountMinor(order) {
  // Works for two decimal currencies. Zero decimal currencies (JPY and friends)
  // have their own guide, since 50.00 is wrong for those.
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent) {
  if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
  if (!order) return ["orphan", "order not found"];
  if (PAID_STATUSES.has(order.status)) return ["skip", "order already paid"];
  if (Math.abs(orderAmountMinor(order) - intent.amount_received) > 1) {
    return ["mismatch", "amount does not match"];
  }
  return ["fix", "paid in Stripe, still pending in Woo"];
}
5

Finish the order the way the webhook would

When the action is fix, set the order to Processing and save the charge ID as the transaction ID. Newer Stripe API versions put the charge in latest_charge. Then add an order note so the shop manager can see the order was repaired and why. Notes and status both go through the REST API, so HPOS is handled for you.

apply.py
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"Reconciled from Stripe PaymentIntent {intent['id']}. "
                      f"Payment was succeeded on Stripe. Marked processing by the reconciler."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
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: `Reconciled from Stripe PaymentIntent ${intent.id}. ` +
            `Payment was succeeded on Stripe. Marked processing by the reconciler.`,
    }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron every five or ten minutes.

Run it safe

Always start with DRY_RUN=true. A reconciler writes to real orders, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete reconciler in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches an order that is already paid.

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

reconcile_pending.py
"""Finish WooCommerce orders that Stripe already paid but the webhook missed.
Run on a schedule. Safe to run again and again.
"""
import os
import time
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("reconcile_pending")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def recent_succeeded(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
        if intent.status == "succeeded" and intent.metadata.get("order_id"):
            yield intent


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()


def order_amount_minor(order):
    return round(float(order["total"]) * 100)


def decide(order, intent):
    if intent.get("status") != "succeeded":
        return ("skip", "intent not succeeded")
    if order is None:
        return ("orphan", "order not found")
    if order["status"] in PAID_STATUSES:
        return ("skip", "order already paid")
    if abs(order_amount_minor(order) - intent["amount_received"]) > 1:
        return ("mismatch", "amount does not match")
    return ("fix", "paid in Stripe, still pending in Woo")


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"Reconciled from Stripe PaymentIntent {intent['id']}. "
                      f"Payment was succeeded on Stripe. Marked processing by the reconciler."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for intent in recent_succeeded(LOOKBACK_HOURS):
        order_id = intent.metadata["order_id"]
        order = get_order(order_id)
        action, reason = decide(order, intent)
        if action == "orphan":
            log.warning("Intent %s points to order %s which is missing", intent.id, order_id)
            continue
        if action in ("skip", "mismatch"):
            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("Done. %d order(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
reconcile-pending.js
/**
 * Finish WooCommerce orders that Stripe already paid but the webhook missed.
 * Run on a schedule. Safe to run again and again.
 */
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
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_HOURS = Number(process.env.LOOKBACK_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

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();
}

async function* recentSucceeded(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
    if (intent.status === "succeeded" && intent.metadata.order_id) yield intent;
  }
}

function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

function decide(order, intent) {
  if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
  if (!order) return ["orphan", "order not found"];
  if (PAID_STATUSES.has(order.status)) return ["skip", "order already paid"];
  if (Math.abs(orderAmountMinor(order) - intent.amount_received) > 1) {
    return ["mismatch", "amount does not match"];
  }
  return ["fix", "paid in Stripe, still pending in Woo"];
}

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: `Reconciled from Stripe PaymentIntent ${intent.id}. ` +
            `Payment was succeeded on Stripe. Marked processing by the reconciler.`,
    }),
  });
}

async function run() {
  let fixed = 0;
  for await (const intent of recentSucceeded(LOOKBACK_HOURS)) {
    const orderId = intent.metadata.order_id;
    const order = await woo(`/orders/${orderId}`);
    const [action, reason] = decide(order, intent);
    if (action === "orphan") { console.warn(`Intent ${intent.id} points to missing order ${orderId}`); continue; }
    if (action === "skip" || action === "mismatch") {
      if (action === "mismatch") console.warn(`Order ${orderId} amount mismatch: ${reason}`);
      continue;
    }
    console.log(`Order ${orderId}: ${reason}. ${DRY_RUN ? "would fix" : "fixing"}`);
    if (!DRY_RUN) await markProcessing(orderId, intent);
    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 money orders get touched. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_decide.py
from reconcile_pending import decide


def intent(**over):
    base = {"status": "succeeded", "amount_received": 5000, "id": "pi_1"}
    base.update(over)
    return base


def test_fix_when_pending_and_paid():
    order = {"status": "pending", "total": "50.00"}
    assert decide(order, intent())[0] == "fix"


def test_skip_when_already_processing():
    order = {"status": "processing", "total": "50.00"}
    assert decide(order, intent())[0] == "skip"


def test_mismatch_when_amount_differs():
    order = {"status": "pending", "total": "40.00"}
    assert decide(order, intent())[0] == "mismatch"


def test_orphan_when_order_missing():
    assert decide(None, intent())[0] == "orphan"
decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./decide.js";

const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, id: "pi_1", ...over });

test("fix when pending and paid", () => {
  assert.equal(decide({ status: "pending", total: "50.00" }, intent())[0], "fix");
});

test("skip when already processing", () => {
  assert.equal(decide({ status: "processing", total: "50.00" }, intent())[0], "skip");
});

test("mismatch when amount differs", () => {
  assert.equal(decide({ status: "pending", total: "40.00" }, intent())[0], "mismatch");
});

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

Case studies

Firewall rule

The store that blocked its own payments

A shop added a security plugin that blocked any request without a browser user agent. Stripe webhooks have no browser, so every event was denied. For two days, paid orders piled up on Pending while support drowned in "where is my order" emails.

The reconciler on a ten minute schedule cleared the backlog on its first real run and kept new ones from slipping through while the firewall rule was fixed.

Flash sale

The sale that outran the webhooks

During a flash sale the store got a burst of traffic and a handful of webhook deliveries timed out under load. About one percent of orders sat on Pending with the money already taken.

The team ran the script in dry run first, saw the exact list of forty orders, agreed it was correct, then ran it for real. All forty moved to Processing with a clear note on each one.

What good looks like

After this runs on a schedule, a lost webhook is no longer a lost sale. The worst case becomes a short delay of a few minutes before the reconciler finishes the order. Keep it running even after you fix the root cause, because webhooks will always fail once in a while.

FAQ

Why is my WooCommerce order still on Pending when Stripe shows the payment succeeded?

The order only moves when Stripe sends a webhook to your store. If that webhook is blocked, times out, or fails, Stripe keeps the charge while the order stays on Pending. A reconciler that reads succeeded PaymentIntents from Stripe and updates the matching orders fixes it.

Is it safe to change an order status with a script?

Yes, when the script confirms Stripe shows the payment as succeeded and the amount matches the order total, and it skips orders that are already paid. Start in dry run mode to review the list before it writes.

How often should the reconciler run?

Every five to ten minutes on a cron schedule is enough for most stores. It only acts on orders that are still pending, so running it often is safe and cheap.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: PaymentIntent confirmed and succeeded but the order is left on Pending. github.com/woocommerce/woocommerce-gateway-stripe/issues/3154
  2. WooCommerce docs: Stripe order statuses and how automatic updates depend on webhooks. woocommerce.com/document/stripe
  3. Guide: troubleshooting Stripe webhook delivery issues on WooCommerce. woohelpdesk.com

On the solution:

  1. Stripe docs: reconcile by listing events and objects rather than relying on the webhook alone. docs.stripe.com/webhooks/process-undelivered-events
  2. Stripe API: list PaymentIntents with auto pagination and a created filter. docs.stripe.com/api/payment_intents/list
  3. WooCommerce REST API: update an order and 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 stuck orders?

If this saved you a pile of support tickets or a chargeback, 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