Repair Order status and webhooks

WooCommerce orders dropped by a Stripe webhook API version mismatch

Everything used to work. Then one day orders that Stripe shows as paid stop getting a transaction id, and some of them never leave Pending. Nothing in your code changed. What changed is the shape of the object Stripe sends back, because your webhook handler was written for an old API version that had a charges array on every PaymentIntent, and newer versions do not send that array anymore. Here is why that breaks silently and a small script that finds and repairs every order it dropped.

Python and Node.js Runs on a schedule Safe by default (dry run)
Assorted electric cables
Photo by John Barkiple on Unsplash
The short answer

Stripe API versions from 2022-11-15 onward no longer return the charges array on a PaymentIntent by default. Code that reads intent.charges.data[0].id finds nothing, decides the payment is not resolved, and quietly skips the order. Read intent.latest_charge first, since that is where the charge id lives now, and only fall back to the old charges list for PaymentIntents created under an older API version. Run a small Python or Node.js job that finds orders with a saved PaymentIntent id but no transaction id, resolves the charge id from whichever shape is present, and writes it back. Full code, tests, and a dry run guard are below.

The problem in plain words

A PaymentIntent is just a JSON object that Stripe hands back to you. For years, that object included a nested charges list, so a lot of integration code, including parts of the older WooCommerce Stripe gateway and plenty of custom webhook handlers, reached into charges.data[0].id to get the charge id for the order.

Stripe changed that. Starting with API version 2022-11-15, a PaymentIntent no longer includes charges by default. The charge id now lives directly on the PaymentIntent as latest_charge, a plain string. If your Stripe account, a plugin update, or a new API key was set to a newer version while your webhook handler still expects the old shape, the handler asks for charges.data[0], gets an empty list, and treats the payment as if it never produced a charge. It does not throw an error. It just silently skips writing the transaction id, and depending on how the handler is built, it can skip the whole status update too.

payment_intent .succeeded webhook Handler reads charges.data[0].id array is empty Order dropped no transaction id No error logged Meanwhile latest_charge already has the id, sitting unread.
The webhook still arrives and the PaymentIntent still succeeds. The failure is entirely in how the handler reads the response shape.

Why it happens

Stripe's own upgrade notes are direct about this: the charges field was removed from the default PaymentIntent response as part of the API version bump, and integrations should switch to latest_charge. A few ways stores end up on the wrong side of that change:

This is a known, documented change on Stripe's side, not a bug in Stripe. The fix is entirely about reading the object correctly. See the citations at the end for the exact upgrade notes.

The key insight

The charge id was never actually missing. It was sitting on latest_charge the whole time. The order looks unpaid only because the code asked the wrong field for the answer. A repair job that checks both fields, new first, then old, finds every order the mismatch dropped without needing to know which API version created each one.

The fix, as a flow

We do not touch the live webhook endpoint here. We add a job that looks at recent orders, finds the ones with a saved PaymentIntent id but no transaction id yet, and asks Stripe directly what that PaymentIntent looks like now. From there, one small function resolves the charge id no matter which API version produced the response, and writes it onto the order the way the webhook should have.

Scheduled job finds orders with no transaction_id Retrieve the saved PaymentIntent latest_charge present? yes no, try legacy charges list Charge id resolved amount checked Save transaction_id + order note
The repair job checks the new field first, falls back to the legacy field only when needed, and only writes when the amount still matches the order.

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

Find the saved PaymentIntent id on the order

The WooCommerce Stripe gateway usually saves the PaymentIntent id in order meta under _stripe_intent_id. Some setups instead store it as the order's transaction_id before it gets overwritten with the charge id. Check both, and treat an already-set transaction_id that looks like a charge, not an intent, as a sign the order is already fine.

step2.py
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
step2.js
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;
}
3

Retrieve the PaymentIntent and read the WooCommerce order

Fetch the PaymentIntent from Stripe by id, and load the matching order through the WooCommerce REST API so the code works the same whether the store has High Performance Order Storage (HPOS) turned on or not.

step3.py
import os, requests
from requests.auth import HTTPBasicAuth
import stripe

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])

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

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
4

Resolve the charge id from either API shape

This is the actual fix. Try latest_charge first, since that is a plain string on every API version once the field exists. Only fall back to the legacy charges list for PaymentIntents that predate the change. Keep this in its own small function so both the new and old shape are handled in exactly one place.

charge_id.py
def charge_id_of(intent):
    """Resolve a charge id from a PaymentIntent regardless of API version.

    Newer API versions (2022-11-15 and later) put the charge on
    latest_charge. Older versions only have the charges list.
    """
    latest = intent.get("latest_charge")
    if isinstance(latest, str) and latest:
        return latest
    if isinstance(latest, dict) and latest.get("id"):
        return latest["id"]
    charges = intent.get("charges") or {}
    data = charges.get("data") or []
    if data and data[0].get("id"):
        return data[0]["id"]
    return None
charge-id.js
export function chargeIdOf(intent) {
  const latest = intent.latest_charge;
  if (typeof latest === "string" && latest) return latest;
  if (latest && typeof latest === "object" && latest.id) return latest.id;
  const data = (intent.charges && intent.charges.data) || [];
  if (data.length && data[0].id) return data[0].id;
  return null;
}
5

Decide, with one pure function

Keep the decision in its own function that takes an order and an intent and returns an action, in minor units so amount checks stay exact. Skip orders with no saved intent id or one that already has a transaction id. Flag a succeeded intent with no charge id on either shape as an orphan worth a manual look, since that should not normally happen. Only repair when the amount still matches.

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

def decide(order, intent):
    if intent_id_of(order) is None:
        return ("skip", "no saved PaymentIntent id on this order")
    if order.get("transaction_id"):
        return ("skip", "order already has a transaction id")
    if intent is None:
        return ("skip", "PaymentIntent not found on Stripe")
    if intent.get("status") != "succeeded":
        return ("skip", "PaymentIntent is not succeeded yet")
    charge_id = charge_id_of(intent)
    if charge_id is None:
        return ("orphan", "succeeded but no charge id on either API shape")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("mismatch", "amount does not match the PaymentIntent")
    return ("repair", "succeeded in Stripe, charge id was never saved")
decide.js
export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function decide(order, intent) {
  if (intentIdOf(order) === null) return ["skip", "no saved PaymentIntent id on this order"];
  if (order.transaction_id) return ["skip", "order already has a transaction id"];
  if (!intent) return ["skip", "PaymentIntent not found on Stripe"];
  if (intent.status !== "succeeded") return ["skip", "PaymentIntent is not succeeded yet"];
  const chargeId = chargeIdOf(intent);
  if (chargeId === null) return ["orphan", "succeeded but no charge id on either API shape"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["mismatch", "amount does not match the PaymentIntent"];
  }
  return ["repair", "succeeded in Stripe, charge id was never saved"];
}
6

Wire it together with a dry run guard

The loop finds candidate orders, retrieves each intent, decides, and only writes when the action is repair. Notice the dry run guard. Leave DRY_RUN on for the first few runs so the script only reports what it would do. Once the report looks right, switch it off and run it on a schedule.

Run it safe

Always start with DRY_RUN=true. A repair job 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 repair job in one file for each language. It reads settings from the environment, resolves the charge id from either API shape, respects the dry run flag, and is safe to run again and again because it never touches an order that already has a transaction id.

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

resolve_charge_id.py
"""Repair WooCommerce orders dropped by a Stripe API version mismatch.

Older Stripe API versions (before 2022-11-15) returned a `charges` list on
every PaymentIntent, so `intent["charges"]["data"][0]["id"]` worked. On newer
API versions that list is gone by default; the charge lives on
`intent["latest_charge"]` instead. A webhook handler or script still written
for the old shape reads an empty `charges` list, decides there is no charge
yet, and skips the order, so it never gets a transaction id and can be left
on Pending even though Stripe already has a succeeded charge.

This walks orders that have a saved PaymentIntent id but no transaction id,
reads the intent from Stripe, resolves the charge id from whichever field is
present, and writes it onto the order along with a note. Safe to run again
and again. Read only until DRY_RUN is turned off.
"""
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("resolve_charge_id")

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


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 charge_id_of(intent):
    """Resolve a charge id from a PaymentIntent regardless of API version.

    Newer API versions (2022-11-15 and later) put the charge on
    latest_charge. Older versions only have the charges list. Try the new
    field first since it is a plain string once it exists, then fall back
    to the legacy nested list.
    """
    latest = intent.get("latest_charge")
    if isinstance(latest, str) and latest:
        return latest
    if isinstance(latest, dict) and latest.get("id"):
        return latest["id"]
    charges = intent.get("charges") or {}
    data = charges.get("data") or []
    if data and data[0].get("id"):
        return data[0]["id"]
    return None


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


def decide(order, intent):
    """Pure decision. No I/O. Returns (action, reason)."""
    if intent_id_of(order) is None:
        return ("skip", "no saved PaymentIntent id on this order")
    if order.get("transaction_id"):
        return ("skip", "order already has a transaction id")
    if intent is None:
        return ("skip", "PaymentIntent not found on Stripe")
    if intent.get("status") != "succeeded":
        return ("skip", "PaymentIntent is not succeeded yet")
    charge_id = charge_id_of(intent)
    if charge_id is None:
        return ("orphan", "succeeded but no charge id on either API shape")
    if abs(order_amount_minor(order) - intent.get("amount_received", 0)) > 1:
        return ("mismatch", "amount does not match the PaymentIntent")
    return ("repair", "succeeded in Stripe, charge id was never saved")


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 candidate_orders():
    page = 1
    after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"after": after, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def apply_charge_id(order_id, charge_id, 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"Recovered charge {charge_id} from PaymentIntent {intent_id}. "
                      f"The webhook handler could not read the newer API response shape, "
                      f"this was backfilled by the reconciler."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    repaired = 0
    for order in candidate_orders():
        intent_id = intent_id_of(order)
        if intent_id is None:
            continue
        intent = get_intent(intent_id)
        action, reason = decide(order, intent)
        if action == "orphan":
            log.warning("Order %s: %s", order["id"], reason)
            continue
        if action in ("skip", "mismatch"):
            if action == "mismatch":
                log.warning("Order %s amount mismatch: %s", order["id"], reason)
            continue
        charge_id = charge_id_of(intent)
        log.info("Order %s: %s. %s", order["id"], reason, "would repair" if DRY_RUN else "repairing")
        if not DRY_RUN:
            apply_charge_id(order["id"], charge_id, intent_id)
        repaired += 1
    log.info("Done. %d order(s) %s.", repaired, "to repair" if DRY_RUN else "repaired")


if __name__ == "__main__":
    run()
resolve-charge-id.js
/**
 * Repair WooCommerce orders dropped by a Stripe API version mismatch.
 *
 * Older Stripe API versions (before 2022-11-15) returned a `charges` list on
 * every PaymentIntent, so `intent.charges.data[0].id` worked. On newer API
 * versions that list is gone by default; the charge lives on
 * `intent.latest_charge` instead. A webhook handler or script still written
 * for the old shape reads an empty `charges` list, decides there is no
 * charge yet, and skips the order, so it never gets a transaction id and can
 * be left on Pending even though Stripe already has a succeeded charge.
 *
 * This walks orders that have a saved PaymentIntent id but no transaction
 * id, reads the intent from Stripe, resolves the charge id from whichever
 * field is present, and writes it onto the order along with a note. Safe to
 * run again and again. Read only until DRY_RUN is turned off.
 */
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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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 chargeIdOf(intent) {
  const latest = intent.latest_charge;
  if (typeof latest === "string" && latest) return latest;
  if (latest && typeof latest === "object" && latest.id) return latest.id;
  const data = (intent.charges && intent.charges.data) || [];
  if (data.length && data[0].id) return data[0].id;
  return null;
}

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

export function decide(order, intent) {
  if (intentIdOf(order) === null) return ["skip", "no saved PaymentIntent id on this order"];
  if (order.transaction_id) return ["skip", "order already has a transaction id"];
  if (!intent) return ["skip", "PaymentIntent not found on Stripe"];
  if (intent.status !== "succeeded") return ["skip", "PaymentIntent is not succeeded yet"];
  const chargeId = chargeIdOf(intent);
  if (chargeId === null) return ["orphan", "succeeded but no charge id on either API shape"];
  if (Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) > 1) {
    return ["mismatch", "amount does not match the PaymentIntent"];
  }
  return ["repair", "succeeded in Stripe, charge id was never saved"];
}

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 getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

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

async function applyChargeId(orderId, chargeId, intentId) {
  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: `Recovered charge ${chargeId} from PaymentIntent ${intentId}. ` +
            `The webhook handler could not read the newer API response shape, ` +
            `this was backfilled by the reconciler.`,
    }),
  });
}

export async function run() {
  let repaired = 0;
  for await (const order of candidateOrders()) {
    const intentId = intentIdOf(order);
    if (intentId === null) continue;
    const intent = await getIntent(intentId);
    const [action, reason] = decide(order, intent);
    if (action === "orphan") { console.warn(`Order ${order.id}: ${reason}`); continue; }
    if (action === "skip" || action === "mismatch") {
      if (action === "mismatch") console.warn(`Order ${order.id} amount mismatch: ${reason}`);
      continue;
    }
    const chargeId = chargeIdOf(intent);
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
    if (!DRY_RUN) await applyChargeId(order.id, chargeId, intentId);
    repaired++;
  }
  console.log(`Done. ${repaired} order(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The part most worth testing here is exactly the part that broke in production: reading the charge id from a response that might be either shape. Because chargeIdOf and decide are pure, no network and no Stripe account is needed. Just feed in plain objects shaped like each API version and check the result.

test_webhook_charge_id.py
from resolve_charge_id import decide, charge_id_of, intent_id_of, order_amount_minor


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


def order(**over):
    base = {
        "status": "pending",
        "total": "50.00",
        "transaction_id": "",
        "meta_data": [{"key": "_stripe_intent_id", "value": "pi_1"}],
    }
    base.update(over)
    return base


def test_charge_id_prefers_latest_charge_string():
    assert charge_id_of({"latest_charge": "ch_new_1", "charges": {"data": [{"id": "ch_old_1"}]}}) == "ch_new_1"


def test_charge_id_falls_back_to_legacy_charges_list():
    assert charge_id_of({"latest_charge": None, "charges": {"data": [{"id": "ch_old_1"}]}}) == "ch_old_1"


def test_repair_when_new_shape_only_and_no_transaction_id():
    assert decide(order(), intent())[0] == "repair"


def test_repair_when_only_legacy_charges_shape_present():
    old_shape = intent(latest_charge=None, charges={"data": [{"id": "ch_old_1"}]})
    assert decide(order(), old_shape)[0] == "repair"


def test_skip_when_order_already_has_transaction_id():
    assert decide(order(transaction_id="ch_already_set"), intent())[0] == "skip"


def test_orphan_when_succeeded_but_no_charge_id_on_either_shape():
    assert decide(order(), intent(latest_charge=None, charges={"data": []}))[0] == "orphan"


def test_mismatch_when_amount_differs():
    assert decide(order(total="80.00"), intent())[0] == "mismatch"
resolve-charge-id.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, chargeIdOf, intentIdOf, orderAmountMinor } from "./resolve-charge-id.js";

const intent = (over = {}) => ({
  status: "succeeded", amount_received: 5000, latest_charge: "ch_new_1", ...over,
});
const order = (over = {}) => ({
  status: "pending", total: "50.00", transaction_id: "",
  meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }], ...over,
});

test("chargeIdOf prefers latest_charge string", () => {
  assert.equal(chargeIdOf({ latest_charge: "ch_new_1", charges: { data: [{ id: "ch_old_1" }] } }), "ch_new_1");
});

test("chargeIdOf falls back to legacy charges list", () => {
  assert.equal(chargeIdOf({ latest_charge: null, charges: { data: [{ id: "ch_old_1" }] } }), "ch_old_1");
});

test("repair when new shape only and no transaction id", () => {
  assert.equal(decide(order(), intent())[0], "repair");
});

test("repair when only legacy charges shape present", () => {
  const oldShape = intent({ latest_charge: null, charges: { data: [{ id: "ch_old_1" }] } });
  assert.equal(decide(order(), oldShape)[0], "repair");
});

test("skip when order already has a transaction id", () => {
  assert.equal(decide(order({ transaction_id: "ch_already_set" }), intent())[0], "skip");
});

test("orphan when succeeded but no charge id on either shape", () => {
  assert.equal(decide(order(), intent({ latest_charge: null, charges: { data: [] } }))[0], "orphan");
});

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

Case studies

Silent Stripe upgrade

The store that upgraded without upgrading

A merchant never touched their Stripe integration, but Stripe rolled the account's default API version forward as part of routine maintenance. A custom webhook handler built years earlier, reading charges.data[0].id, started finding an empty list on every new PaymentIntent. Orders kept succeeding on Stripe. Roughly one in five never got a transaction id and looked unpaid in reports.

The repair job found the whole backlog on its first dry run. Once the team confirmed the list matched their own records in Stripe, they let it write, and every dropped order got its charge id back within minutes.

Plugin update

The gateway update that outran a custom hook

A WooCommerce Stripe gateway update moved to a newer API version internally. A site-specific hook that added extra logic on top of the gateway's own webhook handling still read the old charges field to double check the charge before running its own logic. After the update it silently stopped running that extra logic on every order.

Reading latest_charge first fixed new orders immediately. The repair job cleared the short backlog that had built up in the few days before anyone noticed.

What good looks like

Once the handler reads latest_charge first and falls back only when needed, a future Stripe API version bump stops being a silent order killer. Keep the repair job around on a light schedule anyway. It costs nothing to run against orders that already have a transaction id, since it skips them immediately, and it catches any other path that still writes the old shape.

FAQ

Why did my webhook handler stop finding the charge after a Stripe upgrade?

On Stripe API versions from 2022-11-15 onward, the charges array is no longer returned on a PaymentIntent by default. Code that reads charges.data[0].id finds an empty list and treats the payment as unresolved, so it skips the order. Read latest_charge first and fall back to the old charges list only for older API versions.

Is it safe to repair these orders with a script?

Yes, when the script only repairs orders that have a saved PaymentIntent id, no transaction id yet, a succeeded PaymentIntent on Stripe, and an amount that matches the order total. Start in dry run mode to review the list before it writes anything.

Do I need to pin my Stripe API version to avoid this?

Pinning helps prevent new breakage, but it does not fix orders already dropped by a mismatch, and it does not help if the WooCommerce Stripe gateway itself is on a newer version than your custom code expects. Reading latest_charge with a fallback, plus a repair job for the backlog, covers both cases.

Related field notes

Citations

On the problem:

  1. Stripe API changelog: the charges field was removed from the PaymentIntent object as of the 2022-11-15 API version. docs.stripe.com/changelog/2022-11-15
  2. Stripe docs: PaymentIntent object reference, including the latest_charge field and version notes. docs.stripe.com/api/payment_intents/object
  3. Stripe docs: upgrading your API version and what changes between versions. docs.stripe.com/upgrades

On the solution:

  1. Stripe API: retrieve a PaymentIntent by id. docs.stripe.com/api/payment_intents/retrieve
  2. WooCommerce REST API: update an order and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce docs: Stripe order statuses and how the gateway records the transaction id. woocommerce.com/document/stripe

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 get your orders unstuck?

If this saved you a pile of support tickets or a scary looking revenue report, 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