Reconciler Payment lifecycle

WooCommerce paid orders reverted to failed by a late Stripe event

An order was paid. The buyer got charged, the order moved to Processing, everyone was happy. Then hours later it flips back to Failed on its own, and stock is released while the money is still with you. The cause is a Stripe failure event for an earlier attempt that arrived late and out of order. Here is why that happens and a small job that finds these orders and restores them in a safe way.

Python and Node.js Runs on a schedule Safe by default (dry run)
Curve road signage
Photo by Jim Wilson on Unsplash
The short answer

Stripe does not promise that webhook events arrive in order, so a failure event for an old attempt can land after the success and flip a paid order back to Failed. Run a small Python or Node.js job that lists succeeded PaymentIntents, matches each to its order by metadata.order_id, and moves any order that is currently failed or cancelled but paid in Stripe back to Processing. Full code, tests, and a dry run guard are below.

The problem in plain words

A single checkout can make more than one payment attempt. The first card try fails, the buyer tries again, and the second one succeeds. Stripe sends an event for each thing that happens: one for the failure, one for the success.

The trouble is that these events do not always arrive in the order they happened. If the success is applied first and the old failure shows up a few seconds or minutes later, the gateway can act on that stale failure and flip the order back to Failed. The order looks broken even though the money is safely captured in Stripe.

Second try succeeds Order paid Processing old failure arrives late Reverted back to Failed Stock released
The payment succeeded and the order moved on, but a stale failure event arrived later and reverted a good order.

Why it happens

The root cause is event ordering. Stripe is clear that it does not guarantee events are delivered in the order they were created. A few ways this shows up:

Because the events can arrive in any order, acting on each one as it comes is risky. The safe move is to treat Stripe's current state as the truth and reconcile against it. See the citations at the end for the exact docs on event ordering.

The key insight

An event tells you something happened in the past. The PaymentIntent tells you where things stand right now. If the PaymentIntent is succeeded today, the payment is good no matter what an old failure event said. Trust the current state, not the order events happen to arrive in.

The fix, as a flow

We do not touch the live checkout. We add a job that lists recent successful payments in Stripe and checks each matching order. If Stripe says succeeded but the order sits at failed or cancelled, and the amounts agree, we move it back to Processing and write a note explaining why.

Scheduled job every few minutes List succeeded PaymentIntents Read order_id from metadata Failed but amount ok? yes no, skip Restore order back to processing
The job restores only orders that are currently failed while Stripe shows the payment succeeded and the 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="72"
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="72"
export DRY_RUN="true"   // start safe, change to false to write
2

List the successful payments

Ask Stripe for PaymentIntents created in your lookback window and keep only the ones with status succeeded. These are the payments that are truly good right now, whatever old failure events may still be floating around. The order ID lives in the PaymentIntent metadata.

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

Decide, with one pure function

Keep the decision in its own function that takes an order and an intent and returns an action. The rule is narrow so we only touch orders that were wrongly reverted. If the order is not currently failed or cancelled, skip it. If the amount does not match, skip and warn. Otherwise, restore it.

decide.py
REVERTED_STATUSES = {"failed", "cancelled"}

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"] not in REVERTED_STATUSES:
        return ("skip", "order not in a failed state")
    if abs(order_amount_minor(order) - intent["amount_received"]) > 1:
        return ("mismatch", "amount does not match")
    return ("restore", "paid in Stripe but order was reverted to failed")
decide.js
const REVERTED_STATUSES = new Set(["failed", "cancelled"]);

export function orderAmountMinor(order) {
  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 (!REVERTED_STATUSES.has(order.status)) return ["skip", "order not in a failed state"];
  if (Math.abs(orderAmountMinor(order) - intent.amount_received) > 1) {
    return ["mismatch", "amount does not match"];
  }
  return ["restore", "paid in Stripe but order was reverted to failed"];
}
4

Restore the order and leave a note

When the action is restore, set the order back to Processing and save the charge id as the transaction id, so the payment is linked again. Then add an order note that explains the order was reverted by a late failure and restored from the current Stripe state. Status and notes go through the REST API, so HPOS is handled for you.

apply.py
import 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 restore(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"Restored to processing. Stripe PaymentIntent {intent['id']} is "
                      f"succeeded, so a late failure event had reverted a paid order."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function restore(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: `Restored to processing. Stripe PaymentIntent ${intent.id} is succeeded, ` +
            `so a late failure event had reverted a paid order.`,
    }),
  });
}
5

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 job only reports which orders it would restore. Read the output, agree with it, then switch it off. Run it every few minutes so a wrongly reverted order is put right almost as soon as it happens.

Run it safe

Always start with DRY_RUN=true. This job writes to real orders, so review its plan first. It only ever restores an order that is failed or cancelled while Stripe says the payment succeeded, so it will not touch a genuinely failed order.

The full code

Here is the complete job 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 restores orders that are failed while Stripe shows a matching succeeded payment.

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

restore_paid.py
"""Restore WooCommerce orders that a late Stripe failure event reverted to failed.
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("restore_paid")

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", "72"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

REVERTED_STATUSES = {"failed", "cancelled"}


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"] not in REVERTED_STATUSES:
        return ("skip", "order not in a failed state")
    if abs(order_amount_minor(order) - intent["amount_received"]) > 1:
        return ("mismatch", "amount does not match")
    return ("restore", "paid in Stripe but order was reverted to failed")


def restore(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"Restored to processing. Stripe PaymentIntent {intent['id']} is "
                      f"succeeded, so a late failure event had reverted a paid order."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    restored = 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 restore" if DRY_RUN else "restoring")
        if not DRY_RUN:
            restore(order_id, intent)
        restored += 1
    log.info("Done. %d order(s) %s.", restored, "to restore" if DRY_RUN else "restored")


if __name__ == "__main__":
    run()
restore-paid.js
/**
 * Restore WooCommerce orders that a late Stripe failure event reverted to failed.
 * 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 || 72);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const REVERTED_STATUSES = new Set(["failed", "cancelled"]);

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 (!REVERTED_STATUSES.has(order.status)) return ["skip", "order not in a failed state"];
  if (Math.abs(orderAmountMinor(order) - intent.amount_received) > 1) {
    return ["mismatch", "amount does not match"];
  }
  return ["restore", "paid in Stripe but order was reverted to failed"];
}

async function restore(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: `Restored to processing. Stripe PaymentIntent ${intent.id} is succeeded, ` +
            `so a late failure event had reverted a paid order.`,
    }),
  });
}

async function run() {
  let restored = 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 restore" : "restoring"}`);
    if (!DRY_RUN) await restore(orderId, intent);
    restored++;
  }
  console.log(`Done. ${restored} order(s) ${DRY_RUN ? "to restore" : "restored"}.`);
}

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 an order gets restored. 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_restore_decide.py
from restore_paid import decide


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


def test_restore_when_failed_but_paid():
    order = {"status": "failed", "total": "50.00"}
    assert decide(order, intent())[0] == "restore"


def test_restore_when_cancelled_but_paid():
    order = {"status": "cancelled", "total": "50.00"}
    assert decide(order, intent())[0] == "restore"


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


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


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

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

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

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

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

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

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

Case studies

Card retry

The second card that got punished

A buyer's first card was declined, so they paid with a second card that went through. The order moved to Processing. A minute later the old decline event arrived and the gateway flipped the order to Failed, releasing the stock while the store kept the money.

The reconciler on a five minute schedule saw that Stripe still showed the payment succeeded and moved the order back to Processing with a clear note, before the buyer even noticed.

Webhook retry

The replay that rewound a good order

A store had a brief outage, and when it came back Stripe retried a batch of older events. One of them was a stale failure that landed after the success and reverted a paid order.

The team ran the job in dry run first, saw the single affected order, agreed it was a false failure, and let the job restore it. They kept the job running as a safety net for the next time events arrive out of order.

What good looks like

After this runs on a schedule, an out of order event can no longer quietly undo a sale. A wrongly reverted order is put back within minutes, with a note that explains what happened. Keep it running even after things look calm, because event ordering is never something you control.

FAQ

Why did my paid WooCommerce order change back to failed?

A Stripe failure event for an earlier payment attempt can arrive after the successful one, because webhook events are not guaranteed to be delivered in order. When the gateway applies that late failure, it flips an order that was already paid back to failed. Checking the current state in Stripe and restoring the order fixes it.

Are Stripe webhook events delivered in order?

No. Stripe does not guarantee that events arrive in the order they happened, so a later event can be delivered before or after an earlier one. That is why you should trust the current object state in the Stripe API rather than the order events happen to arrive in.

Is it safe to restore an order with a script?

Yes, when the script only restores orders that are currently failed or cancelled while Stripe shows the PaymentIntent as succeeded with a matching amount. Start in dry run mode to review the list before it writes.

Related field notes

Citations

On the problem:

  1. Stripe docs: webhook event delivery is not guaranteed to be in order. docs.stripe.com/webhooks (event ordering)
  2. Stripe docs: best practices, use the API to fetch the current state rather than trusting event order. docs.stripe.com/webhooks (best practices)
  3. WooCommerce Stripe gateway: order status handling on failed and successful payment events. woocommerce.com/document/stripe

On the solution:

  1. Stripe API: list PaymentIntents with a created filter and auto pagination. docs.stripe.com/api/payment_intents/list
  2. Stripe API: the PaymentIntent status field as the current source of truth. docs.stripe.com/api/payment_intents/object
  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 rescue a good order?

If this put a wrongly failed order back the way it should be, 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