Repair Order status and webhooks

The Stripe webhook cannot find the order, so it never updates

Your Stripe logs are full of one line: Could not find order via intent ID. The payment went through, but WooCommerce never moved the order because the PaymentIntent ID was never saved onto it. The webhook arrives, looks for the order, finds nothing, and gives up. Here is how to recover the lost ID from Stripe and write it back so these orders can be matched, completed, and refunded again.

Python and Node.js One off repair Safe by default (dry run)
A bunch of blue wires connected to each other
Photo by Scott Rodgerson on Unsplash
The short answer

The payment succeeded on Stripe but the order never saved its PaymentIntent ID, so the webhook logs Could not find order via intent ID and gives up. The plugin still saved the order ID on the Stripe side as metadata.order_id, so a Python or Node.js script can search Stripe by that field, recover the pi_ and ch_ IDs, and write them back onto the order. Full code, tests, and a dry run guard are below.

The problem in plain words

Every Stripe payment has a PaymentIntent, and its ID looks like pi_123. When the plugin works normally, it saves that ID onto the order as a piece of order meta called _stripe_intent_id. Later, when Stripe sends the webhook, the plugin looks the order up by that ID and finishes it.

Some orders never get that ID saved. It happens with orders paid through the pay for order link, orders created by an admin, and some express checkout paths. The payment still succeeds on Stripe, but WooCommerce has nothing to look up. So the webhook logs Could not find order via intent ID and the order sits on Pending. These same orders also cannot be refunded from the admin, because the refund needs that ID too.

Order created pay link or admin Intent ID not saved _stripe_intent_id empty Webhook arrives looks up by ID Not found stays pending
The payment is fine on Stripe. The order just has no ID for the webhook to match, so it is left behind.

Why it happens

The WooCommerce Stripe plugin has open reports of exactly this. Orders paid through the order pay endpoint, or created outside the normal checkout, can miss the write that stores _stripe_intent_id, and the webhook then logs Could not find order via intent ID or via charge ID. On some stores this touches a small but steady share of orders. The citations at the end link the exact threads.

The good news is that the plugin does write the order ID into the other direction. It sets metadata.order_id on the PaymentIntent in Stripe. So even when the order lost the link, Stripe still knows which order the payment belongs to. That is the thread we pull to repair it.

The key insight

The link is broken on the WooCommerce side but intact on the Stripe side. Stripe holds metadata.order_id on the PaymentIntent. We search Stripe by that field, recover the pi_ and ch_ IDs, and write them back onto the order.

The fix, as a flow

We find WooCommerce orders that used Stripe, are still unpaid, and have no _stripe_intent_id. For each one we search Stripe by the order ID, take the successful PaymentIntent, and write its IDs back onto the order. Then the order can move to Processing and can be refunded like any other.

Unpaid Stripe orders with no intent id Search Stripe by metadata.order_id Recover IDs pi_ and ch_ Backfill meta + status
Search Stripe for the lost link, then write the IDs back onto the order so both systems agree again.

Build it step by step

1

Find unpaid Stripe orders that have no intent ID

Ask the WooCommerce REST API for orders paid with Stripe that are still pending or on hold. For each one, read the order meta and check whether _stripe_intent_id is missing. The small helper below reads a value out of the order meta array.

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

def get_meta(order, key):
    for m in order.get("meta_data", []):
        if m.get("key") == key:
            return m.get("value")
    return None

def needs_backfill(order):
    if not order["payment_method"].startswith("stripe"):
        return False
    if order["status"] in PAID_STATUSES:
        return False
    return not get_meta(order, "_stripe_intent_id")
step1.js
const PAID_STATUSES = new Set(["processing", "completed"]);

export function getMeta(order, key) {
  const hit = (order.meta_data || []).find((m) => m.key === key);
  return hit ? hit.value : null;
}

export function needsBackfill(order) {
  if (!order.payment_method.startsWith("stripe")) return false;
  if (PAID_STATUSES.has(order.status)) return false;
  return !getMeta(order, "_stripe_intent_id");
}
2

Search Stripe by the order ID

The plugin stores the order ID on the PaymentIntent as metadata.order_id. The Stripe search API lets us find it. We only want a payment that actually succeeded. Search can lag a few seconds behind live data, so for very recent orders a list by created time and amount is a good fallback.

step2.py
import stripe

def find_intent(order_id):
    query = f"metadata['order_id']:'{order_id}' AND status:'succeeded'"
    result = stripe.PaymentIntent.search(query=query, limit=1)
    return result.data[0] if result.data else None
step2.js
export async function findIntent(stripe, orderId) {
  const query = `metadata['order_id']:'${orderId}' AND status:'succeeded'`;
  const result = await stripe.paymentIntents.search({ query, limit: 1 });
  return result.data.length ? result.data[0] : null;
}
3

Write the IDs back and finish the order

Write both IDs onto the order as meta, set the transaction ID, and move the order to Processing. WooCommerce custom meta goes in the meta_data array on the order update. Add a note so the shop manager can see what happened. Everything goes through the REST API, so it works the same with High Performance Order Storage.

step3.py
def backfill(order_id, intent):
    charge_id = intent.get("latest_charge") or intent["id"]
    body = {
        "status": "processing",
        "transaction_id": charge_id,
        "meta_data": [
            {"key": "_stripe_intent_id", "value": intent["id"]},
            {"key": "_stripe_charge_id", "value": charge_id},
        ],
    }
    put_order(order_id, body)
    add_note(order_id, f"Recovered Stripe PaymentIntent {intent['id']} and backfilled the order. "
                       f"Marked processing by the repair script.")
step3.js
async function backfill(orderId, intent) {
  const chargeId = intent.latest_charge || intent.id;
  await putOrder(orderId, {
    status: "processing",
    transaction_id: chargeId,
    meta_data: [
      { key: "_stripe_intent_id", value: intent.id },
      { key: "_stripe_charge_id", value: chargeId },
    ],
  });
  await addNote(orderId, `Recovered Stripe PaymentIntent ${intent.id} and backfilled the order. ` +
                         `Marked processing by the repair script.`);
}
Run it safe

Start with DRY_RUN=true. Read the list of orders it would repair, confirm the amounts look right, then let it write. The script only touches orders that are unpaid and have no intent ID, so a second run is safe.

The full code

The complete repair script pages through unpaid Stripe orders, searches Stripe for the lost link, and backfills the ones it can recover. Orders with no successful payment on Stripe are reported and left alone.

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

backfill_intent_id.py
"""Recover the Stripe PaymentIntent ID for WooCommerce orders that lost it,
so the order can be matched, completed, and refunded again.
"""
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("backfill_intent_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"])
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def get_meta(order, key):
    for m in order.get("meta_data", []):
        if m.get("key") == key:
            return m.get("value")
    return None


def needs_backfill(order):
    if not order["payment_method"].startswith("stripe"):
        return False
    if order["status"] in PAID_STATUSES:
        return False
    return not get_meta(order, "_stripe_intent_id")


def unpaid_stripe_orders():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "pending,on-hold", "payment_method": "stripe",
                    "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        orders = r.json()
        if not orders:
            return
        for order in orders:
            yield order
        page += 1


def find_intent(order_id):
    query = f"metadata['order_id']:'{order_id}' AND status:'succeeded'"
    result = stripe.PaymentIntent.search(query=query, limit=1)
    return result.data[0] if result.data else None


def backfill(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,
            "meta_data": [
                {"key": "_stripe_intent_id", "value": intent["id"]},
                {"key": "_stripe_charge_id", "value": 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 Stripe PaymentIntent {intent['id']} and backfilled the order. "
                      f"Marked processing by the repair script."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    fixed = 0
    for order in unpaid_stripe_orders():
        if not needs_backfill(order):
            continue
        order_id = order["id"]
        intent = find_intent(order_id)
        if intent is None:
            log.warning("Order %s has no successful payment on Stripe. Left alone.", order_id)
            continue
        log.info("Order %s: recovered %s. %s", order_id, intent.id, "would fix" if DRY_RUN else "fixing")
        if not DRY_RUN:
            backfill(order_id, intent)
        fixed += 1
    log.info("Done. %d order(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")


if __name__ == "__main__":
    run()
backfill-intent-id.js
/**
 * Recover the Stripe PaymentIntent ID for WooCommerce orders that lost it,
 * so the order can be matched, completed, and refunded 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 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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

function getMeta(order, key) {
  const hit = (order.meta_data || []).find((m) => m.key === key);
  return hit ? hit.value : null;
}

function needsBackfill(order) {
  if (!order.payment_method.startsWith("stripe")) return false;
  if (PAID_STATUSES.has(order.status)) return false;
  return !getMeta(order, "_stripe_intent_id");
}

async function* unpaidStripeOrders() {
  let page = 1;
  while (true) {
    const orders = await woo(`/orders?status=pending,on-hold&payment_method=stripe&per_page=50&page=${page}`);
    if (!orders.length) return;
    for (const order of orders) yield order;
    page++;
  }
}

async function findIntent(orderId) {
  const query = `metadata['order_id']:'${orderId}' AND status:'succeeded'`;
  const result = await stripe.paymentIntents.search({ query, limit: 1 });
  return result.data.length ? result.data[0] : null;
}

async function backfill(orderId, intent) {
  const chargeId = intent.latest_charge || intent.id;
  await woo(`/orders/${orderId}`, {
    method: "PUT",
    body: JSON.stringify({
      status: "processing",
      transaction_id: chargeId,
      meta_data: [
        { key: "_stripe_intent_id", value: intent.id },
        { key: "_stripe_charge_id", value: chargeId },
      ],
    }),
  });
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Recovered Stripe PaymentIntent ${intent.id} and backfilled the order. ` +
            `Marked processing by the repair script.`,
    }),
  });
}

async function run() {
  let fixed = 0;
  for await (const order of unpaidStripeOrders()) {
    if (!needsBackfill(order)) continue;
    const orderId = order.id;
    const intent = await findIntent(orderId);
    if (!intent) { console.warn(`Order ${orderId} has no successful payment on Stripe. Left alone.`); continue; }
    console.log(`Order ${orderId}: recovered ${intent.id}. ${DRY_RUN ? "would fix" : "fixing"}`);
    if (!DRY_RUN) await backfill(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 two pure helpers decide which orders to touch, so they are the ones to test. No network is needed. We feed in plain order objects and check the answer.

test_backfill.py
from backfill_intent_id import get_meta, needs_backfill


def order(**over):
    base = {"payment_method": "stripe", "status": "pending", "meta_data": []}
    base.update(over)
    return base


def test_needs_backfill_when_id_missing():
    assert needs_backfill(order()) is True


def test_skip_when_id_present():
    o = order(meta_data=[{"key": "_stripe_intent_id", "value": "pi_1"}])
    assert needs_backfill(o) is False


def test_skip_when_already_paid():
    assert needs_backfill(order(status="processing")) is False


def test_skip_non_stripe():
    assert needs_backfill(order(payment_method="paypal")) is False


def test_get_meta_reads_value():
    o = order(meta_data=[{"key": "_stripe_charge_id", "value": "ch_9"}])
    assert get_meta(o, "_stripe_charge_id") == "ch_9"
backfill.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { getMeta, needsBackfill } from "./step1.js";

const order = (over = {}) => ({ payment_method: "stripe", status: "pending", meta_data: [], ...over });

test("needs backfill when id missing", () => {
  assert.equal(needsBackfill(order()), true);
});

test("skip when id present", () => {
  assert.equal(needsBackfill(order({ meta_data: [{ key: "_stripe_intent_id", value: "pi_1" }] })), false);
});

test("skip when already paid", () => {
  assert.equal(needsBackfill(order({ status: "processing" })), false);
});

test("skip non stripe", () => {
  assert.equal(needsBackfill(order({ payment_method: "paypal" })), false);
});

test("getMeta reads value", () => {
  assert.equal(getMeta(order({ meta_data: [{ key: "_stripe_charge_id", value: "ch_9" }] }), "_stripe_charge_id"), "ch_9");
});

Case studies

Pay for order link

The invoices that could not be refunded

A store sent custom invoices and let customers pay through the pay for order link. Those orders completed, but months later the team could not refund two of them from the admin. The refund button failed because the orders had no charge ID saved.

The script found both orders, searched Stripe by order ID, and wrote the pi_ and ch_ IDs back. The refunds went through the next day with no manual copy and paste from the Stripe dashboard.

Admin created orders

Phone orders stuck on pending

A shop took phone orders, created them in the admin, and charged a saved card. Several never left Pending because the intent ID was not stored on that path, so the webhook could not match them.

Running the repair in dry run listed nine orders. The team recognized every one, ran it for real, and all nine moved to Processing with a clear note explaining the recovery.

What good looks like

Once the IDs are back on the order, everything downstream works again. The order shows as paid, refunds work from the admin, and your reports tie the order to the right Stripe payment. Keep the script handy and run it after any bulk import or any change to how orders are created.

FAQ

What does Could not find order via intent ID mean in Stripe logs?

It means the webhook received a payment event but the WooCommerce order does not have the matching PaymentIntent ID saved, so the plugin cannot find the order to update. The payment still succeeded on Stripe.

Can I recover the Stripe ID for an old order?

Yes. The plugin stores the order ID on the PaymentIntent as metadata.order_id, so you can search Stripe by that field, find the succeeded payment, and write its IDs back onto the order.

Why can I not refund the order from the WooCommerce admin?

The refund needs the Stripe charge or intent ID on the order. If that ID was never saved, the refund fails. Backfilling the ID restores the ability to refund from the admin.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: Could not find order via intent ID on orders paid through the order pay endpoint. github.com/woocommerce/woocommerce-gateway-stripe/issues/2693
  2. WooCommerce Stripe plugin issue: order not updated because the intent could not be matched. github.com/woocommerce/woocommerce-gateway-stripe/issues/230
  3. WooCommerce docs: Stripe order meta and how the webhook matches an order. woocommerce.com/document/stripe

On the solution:

  1. Stripe docs: search PaymentIntents by metadata. docs.stripe.com/search
  2. Stripe API: the PaymentIntent object and latest_charge. docs.stripe.com/api/payment_intents/object
  3. WooCommerce REST API: update an order with meta_data and add a 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 recover your orders?

If this got your stuck orders paid and refundable again, you can buy me a coffee. It keeps these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes