Repair Catalog, metadata, and scheduling

Backfill order ID metadata

A reconciliation script asks Stripe for succeeded PaymentIntents and matches each one to a WooCommerce order using metadata.order_id. That works fine for anything paid recently. Then you run it against older orders and half of them come back as orphans, not because the payment failed, but because the PaymentIntent was created before your store ever wrote an order id into its metadata. Here is why that gap exists and a small script that backfills it safely.

Python and Node.js Run once, or on a schedule Safe by default (dry run)
Medium.com website displayed on a screen.
Photo by Zulfugar Karimov on Unsplash
The short answer

Stripe metadata is only ever written at the moment your store's code calls the Stripe API. If an order was placed before a plugin update added order_id to that call, or through a checkout path that skipped it, the PaymentIntent for that order exists and succeeded, it just has no order_id in its metadata. Run a small Python or Node.js script that walks paid WooCommerce orders, reads the PaymentIntent id each order already has saved (_stripe_intent_id meta, or transaction_id), and writes order_id onto that PaymentIntent in Stripe when it is missing. Full code, tests, and a dry run guard are below.

The problem in plain words

When a customer pays through the WooCommerce Stripe integration, the plugin creates a PaymentIntent and, as part of that same call, attaches metadata to it, usually including the WooCommerce order id. That metadata is what lets later scripts, and Stripe's own dashboard search, answer "which order does this charge belong to" without opening WooCommerce at all.

That metadata is not retroactive. It is written once, at creation time, by whatever code made the request. If the store was running an older version of the plugin before it started sending order_id, or a custom checkout that called the Payment Intents API directly and never added it, the PaymentIntent still succeeds, the order still gets paid, and the money is completely fine. The only thing missing is the label that says which order it was for. Months later, a reconciler that trusts metadata.order_id to do the matching walks right past these PaymentIntents and reports them as unmatched, even though the order was paid correctly the whole time.

Old order paid before the metadata fix PaymentIntent succeeded, no order_id Reconciler matches by metadata.order_id No key found metadata is empty Reported as unmatched The order was paid correctly. Only the metadata label to find it later is missing.
The PaymentIntent succeeded and the order is fine. The reconciler still fails because the key it searches for was never written.

Why it happens

Stripe's own docs describe metadata as a plain key and value store you attach at the time you create or update an object, there is nothing automatic about it. A few common ways an order ends up without it:

None of this means the payment is wrong. It means the one field that later automation depends on for matching was never written. Any reconciler, refund script, or accounting export that keys off metadata.order_id will treat every one of these PaymentIntents as an orphan, even on a store that has never actually lost a payment.

The key insight

WooCommerce already knows the answer. The order itself stores the PaymentIntent id, in order meta _stripe_intent_id or as the order's transaction_id. You do not need to guess which PaymentIntent belongs to which order, you already have that link, just in the opposite direction. The backfill only has to walk it the other way and write it back onto Stripe.

The fix, as a flow

We do not touch payments, refunds, or order status. We add a job that walks paid WooCommerce orders, reads the PaymentIntent id each order has saved, fetches that PaymentIntent from Stripe, and checks whether its metadata already has order_id. If it is missing, or set to something else, we update the metadata to point at the order it actually belongs to. Anything that already has the right value is left untouched.

Backfill job run once, or scheduled Read saved intent id from the WooCommerce order Fetch the intent from Stripe order_id missing or wrong? yes no, skip Update metadata write order_id on the intent
The job only writes metadata when it is missing or points at the wrong order. Every already-correct PaymentIntent is left exactly as it is.

Build it step by step

1

Get access to both systems

You need a Stripe secret key with permission to update PaymentIntents, and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read 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="365"
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="365"
export DRY_RUN="true"   // start safe, change to false to write
2

Read the PaymentIntent id saved on the order

The WooCommerce Stripe plugin saves the PaymentIntent id in order meta as _stripe_intent_id. Some older orders or other integrations save it as the order's transaction_id instead, prefixed pi_. A transaction_id that starts with ch_ is a charge id, not a PaymentIntent id, and is skipped for this job.

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

Fetch the PaymentIntent and look at its metadata

Retrieve the PaymentIntent from Stripe using the id from step 2. A retrieve is a read, it changes nothing. If the id does not resolve, for example the PaymentIntent was later deleted in test mode, log it and move on rather than failing the whole run.

step3.py
def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the rule in its own function that takes the order and the PaymentIntent and returns an action. A pure function like this is easy to read and easy to test, which we do later. Skip anything the intent has already been tagged correctly, skip anything Stripe cannot find, and only backfill when order_id is missing or points at a different order than the one we are looking at.

decide.py
def decide(order, intent):
    """Pure decision function. No I/O.

    order: a plain dict with at least id and status.
    intent: a plain dict with at least id, status, and metadata, or None
        when Stripe has no matching PaymentIntent.

    Returns (action, reason). action is one of:
      "skip"    - nothing to do, already correct or not worth touching
      "orphan"  - the saved intent id does not resolve in Stripe
      "backfill" - metadata.order_id is missing or wrong, write it
    """
    if intent is None:
        return ("orphan", "no matching PaymentIntent found in Stripe")
    existing = (intent.get("metadata") or {}).get("order_id")
    order_id_str = str(order["id"])
    if existing == order_id_str:
        return ("skip", "metadata.order_id already correct")
    if intent.get("status") not in ("succeeded", "processing"):
        return ("skip", "intent not in a paid state, leave it alone")
    return ("backfill", "metadata.order_id missing or pointing at the wrong order")
decide.js
/**
 * Pure decision function. No I/O.
 *
 * order: a plain object with at least id and status.
 * intent: a plain object with at least id, status, and metadata, or null
 *   when Stripe has no matching PaymentIntent.
 *
 * Returns [action, reason]. action is one of:
 *   "skip"     - nothing to do, already correct or not worth touching
 *   "orphan"   - the saved intent id does not resolve in Stripe
 *   "backfill" - metadata.order_id is missing or wrong, write it
 */
export function decide(order, intent) {
  if (!intent) return ["orphan", "no matching PaymentIntent found in Stripe"];
  const existing = (intent.metadata || {}).order_id;
  const orderIdStr = String(order.id);
  if (existing === orderIdStr) return ["skip", "metadata.order_id already correct"];
  if (!["succeeded", "processing"].includes(intent.status)) {
    return ["skip", "intent not in a paid state, leave it alone"];
  }
  return ["backfill", "metadata.order_id missing or pointing at the wrong order"];
}
5

Write the metadata, merging rather than replacing

Stripe metadata updates merge by key, they do not wipe the object, but it is still worth being deliberate: send only the key you mean to change. Setting order_id does not touch the charge, the amount, or anything else Stripe already has for that PaymentIntent. It is the same kind of update the checkout code should have made the first time.

apply.py
def backfill_metadata(intent_id, order_id):
    stripe.PaymentIntent.modify(
        intent_id,
        metadata={"order_id": str(order_id)},
    )
apply.js
async function backfillMetadata(intentId, orderId) {
  await stripe.paymentIntents.update(intentId, {
    metadata: { order_id: String(orderId) },
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first run, leave DRY_RUN on so the script only reports what it would write. Read the output, spot check a few PaymentIntents in the Stripe dashboard, then switch it off. This is mostly a one-time cleanup job for a large lookback window, but it is just as safe to leave on a weekly schedule so any new gap gets caught early.

Run it safe

Always start with DRY_RUN=true. The script only ever writes a metadata key on Stripe, never a refund, a status change, or a charge, but you still want to see the exact list of PaymentIntents it plans to touch before it touches anything.

The full code

Here is the complete backfill 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 rewrites a PaymentIntent that already has the correct order_id.

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

backfill_order_id_metadata.py
"""Backfill metadata.order_id on old Stripe PaymentIntents that predate it.

Older orders, orders created through a custom checkout, or PaymentIntents
recreated during a gateway migration can succeed without ever getting
order_id written into their Stripe metadata. The payment is fine, only the
label that lets later scripts match the PaymentIntent back to its
WooCommerce order is missing.

This script walks recent paid orders, reads the PaymentIntent id each order
already has saved (meta _stripe_intent_id, falling back to transaction_id),
fetches that PaymentIntent from Stripe, and writes order_id onto its
metadata when it is missing or wrong. It never touches the charge, the
amount, or the order status.

Safe by default. Set DRY_RUN=false to actually write.
"""
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_order_id_metadata")

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

PAID_STATUSES = {"processing", "completed"}


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 decide(order, intent):
    """Pure decision function. No I/O.

    order: a plain dict with at least id and status.
    intent: a plain dict with at least id, status, and metadata, or None
        when Stripe has no matching PaymentIntent.

    Returns (action, reason). action is one of:
      "skip"     - nothing to do, already correct or not worth touching
      "orphan"   - the saved intent id does not resolve in Stripe
      "backfill" - metadata.order_id is missing or wrong, write it
    """
    if intent is None:
        return ("orphan", "no matching PaymentIntent found in Stripe")
    existing = (intent.get("metadata") or {}).get("order_id")
    order_id_str = str(order["id"])
    if existing == order_id_str:
        return ("skip", "metadata.order_id already correct")
    if intent.get("status") not in ("succeeded", "processing"):
        return ("skip", "intent not in a paid state, leave it alone")
    return ("backfill", "metadata.order_id missing or pointing at the wrong order")


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 paid_orders(lookback_days):
    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={"status": "processing,completed", "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 backfill_metadata(intent_id, order_id):
    stripe.PaymentIntent.modify(
        intent_id,
        metadata={"order_id": str(order_id)},
    )


def run():
    fixed = 0
    for order in paid_orders(LOOKBACK_DAYS):
        if order["status"] not in PAID_STATUSES:
            continue
        intent_id = intent_id_of(order)
        intent = get_intent(intent_id)
        action, reason = decide(order, intent)
        if action == "orphan":
            log.warning("Order %s: %s (intent id on order: %s)", order["id"], reason, intent_id)
            continue
        if action == "skip":
            continue
        log.info("Order %s: %s. %s", order["id"], reason, "would backfill" if DRY_RUN else "backfilling")
        if not DRY_RUN:
            backfill_metadata(intent["id"], order["id"])
        fixed += 1
    log.info("Done. %d PaymentIntent(s) %s.", fixed, "to backfill" if DRY_RUN else "backfilled")


if __name__ == "__main__":
    run()
backfill-order-id-metadata.js
/**
 * Backfill metadata.order_id on old Stripe PaymentIntents that predate it.
 *
 * Older orders, orders created through a custom checkout, or PaymentIntents
 * recreated during a gateway migration can succeed without ever getting
 * order_id written into their Stripe metadata. The payment is fine, only the
 * label that lets later scripts match the PaymentIntent back to its
 * WooCommerce order is missing.
 *
 * This script walks recent paid orders, reads the PaymentIntent id each
 * order already has saved (meta _stripe_intent_id, falling back to
 * transaction_id), fetches that PaymentIntent from Stripe, and writes
 * order_id onto its metadata when it is missing or wrong. It never touches
 * the charge, the amount, or the order status.
 *
 * Safe by default. Set DRY_RUN=false to actually write.
 */
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 || 365);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

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

/**
 * Pure decision function. No I/O.
 *
 * order: a plain object with at least id and status.
 * intent: a plain object with at least id, status, and metadata, or null
 *   when Stripe has no matching PaymentIntent.
 *
 * Returns [action, reason]. action is one of:
 *   "skip"     - nothing to do, already correct or not worth touching
 *   "orphan"   - the saved intent id does not resolve in Stripe
 *   "backfill" - metadata.order_id is missing or wrong, write it
 */
export function decide(order, intent) {
  if (!intent) return ["orphan", "no matching PaymentIntent found in Stripe"];
  const existing = (intent.metadata || {}).order_id;
  const orderIdStr = String(order.id);
  if (existing === orderIdStr) return ["skip", "metadata.order_id already correct"];
  if (!["succeeded", "processing"].includes(intent.status)) {
    return ["skip", "intent not in a paid state, leave it alone"];
  }
  return ["backfill", "metadata.order_id missing or pointing at the wrong order"];
}

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* paidOrders(lookbackDays) {
  const after = new Date(Date.now() - lookbackDays * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?status=processing,completed&after=${after}&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function backfillMetadata(intentId, orderId) {
  await stripe.paymentIntents.update(intentId, {
    metadata: { order_id: String(orderId) },
  });
}

export async function run() {
  let fixed = 0;
  for await (const order of paidOrders(LOOKBACK_DAYS)) {
    if (!PAID_STATUSES.has(order.status)) continue;
    const intentId = intentIdOf(order);
    const intent = await getIntent(intentId);
    const [action, reason] = decide(order, intent);
    if (action === "orphan") {
      console.warn(`Order ${order.id}: ${reason} (intent id on order: ${intentId})`);
      continue;
    }
    if (action === "skip") continue;
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would backfill" : "backfilling"}`);
    if (!DRY_RUN) await backfillMetadata(intent.id, order.id);
    fixed++;
  }
  console.log(`Done. ${fixed} PaymentIntent(s) ${DRY_RUN ? "to backfill" : "backfilled"}.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides which PaymentIntents get written to. 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_backfill_decide.py
from backfill_order_id_metadata import decide, intent_id_of


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


def test_backfill_when_metadata_missing():
    order = {"id": 501, "status": "processing"}
    assert decide(order, intent())[0] == "backfill"


def test_skip_when_metadata_already_correct():
    order = {"id": 501, "status": "processing"}
    assert decide(order, intent(metadata={"order_id": "501"}))[0] == "skip"


def test_backfill_when_metadata_points_at_wrong_order():
    order = {"id": 501, "status": "processing"}
    assert decide(order, intent(metadata={"order_id": "999"}))[0] == "backfill"


def test_orphan_when_intent_missing():
    order = {"id": 501, "status": "processing"}
    assert decide(order, None)[0] == "orphan"


def test_skip_when_intent_not_paid():
    order = {"id": 501, "status": "processing"}
    assert decide(order, intent(status="requires_payment_method"))[0] == "skip"


def test_intent_id_from_meta():
    order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
    assert intent_id_of(order) == "pi_123"


def test_intent_id_falls_back_to_transaction_id():
    order = {"meta_data": [], "transaction_id": "pi_456"}
    assert intent_id_of(order) == "pi_456"


def test_intent_id_none_when_transaction_is_a_charge():
    order = {"meta_data": [], "transaction_id": "ch_789"}
    assert intent_id_of(order) is None
backfill-order-id-metadata.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf } from "./backfill-order-id-metadata.js";

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

test("backfill when metadata missing", () => {
  assert.equal(decide({ id: 501, status: "processing" }, intent())[0], "backfill");
});

test("skip when metadata already correct", () => {
  assert.equal(decide({ id: 501, status: "processing" }, intent({ metadata: { order_id: "501" } }))[0], "skip");
});

test("backfill when metadata points at wrong order", () => {
  assert.equal(decide({ id: 501, status: "processing" }, intent({ metadata: { order_id: "999" } }))[0], "backfill");
});

test("orphan when intent missing", () => {
  assert.equal(decide({ id: 501, status: "processing" }, null)[0], "orphan");
});

test("skip when intent not paid", () => {
  assert.equal(decide({ id: 501, status: "processing" }, intent({ status: "requires_payment_method" }))[0], "skip");
});

test("intentIdOf from meta", () => {
  assert.equal(intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }), "pi_123");
});

test("intentIdOf falls back to transaction_id", () => {
  assert.equal(intentIdOf({ meta_data: [], transaction_id: "pi_456" }), "pi_456");
});

test("intentIdOf null when transaction is a charge", () => {
  assert.equal(intentIdOf({ meta_data: [], transaction_id: "ch_789" }), null);
});

Case studies

Plugin upgrade

The store that only saw the gap after adding a reconciler

A shop had used WooCommerce Stripe for four years. Nothing was broken, buyers were charged and orders were fulfilled the whole time. Only after they set up the reconciler for stuck pending orders did they notice roughly 30 percent of their PaymentIntents from before a certain date came back unmatched, because an early version of the gateway plugin never wrote order_id into metadata.

Running the backfill with a 365 day lookback fixed just over a thousand PaymentIntents in one pass. Every one of them was already a successfully paid order, the script only added the missing label so future automation could find them.

Gateway migration

The move from WooPayments that dropped the link

A store migrated from WooPayments to a direct Stripe account. The migration correctly recreated the payment history and kept the orders paid, but the newly created PaymentIntents in the destination Stripe account had generic metadata from the migration tool instead of the original order id.

The team ran the backfill in dry run first against the whole migrated batch, confirmed the list matched the count of migrated orders exactly, then ran it for real. Every PaymentIntent in the new account now points back to its WooCommerce order the same way a normal checkout would have written it.

What good looks like

After the backfill runs, every paid PaymentIntent in Stripe, old or new, carries the same order_id metadata a fresh checkout would write today. Reconciliation, refund tooling, and Stripe dashboard search all start working the same way across your entire order history, not just the orders placed after the fix. Run it once for the backlog, then leave your checkout code writing the metadata correctly going forward so the gap never reopens.

FAQ

Why do some Stripe PaymentIntents not have order_id in their metadata?

Older orders, orders created before a plugin update, or orders from a custom checkout can all produce a PaymentIntent whose metadata never included order_id. WooCommerce still stores the PaymentIntent id on the order, but Stripe was never told which order the payment belongs to, so nothing on the Stripe side points back.

Is it safe to edit metadata on a live Stripe PaymentIntent?

Yes. Metadata is descriptive information Stripe stores alongside the object, it does not touch the charge, the amount, or the customer. Adding order_id to an existing succeeded PaymentIntent changes nothing about the payment itself, it only makes the PaymentIntent easier to match later.

How do I know which order a PaymentIntent belongs to if metadata is missing?

Walk the WooCommerce order, not the Stripe side. Every order that reached Processing or Completed has the PaymentIntent id saved in order meta as _stripe_intent_id, or as the order's transaction_id. Read that id from the order, then write the order's own id back onto the matching PaymentIntent in Stripe.

Related field notes

Citations

On the problem:

  1. Stripe docs: metadata is set at object creation or update time and is never generated automatically. docs.stripe.com/metadata
  2. WooCommerce Stripe plugin docs: how the PaymentIntent is created and what metadata the gateway attaches. woocommerce.com/document/stripe
  3. WooCommerce docs: order meta keys used by core payment gateways, including the saved transaction id. woocommerce.github.io/code-reference

On the solution:

  1. Stripe API: update a PaymentIntent, including changing its metadata without affecting the charge. docs.stripe.com/api/payment_intents/update
  2. Stripe API: retrieve a PaymentIntent by id. docs.stripe.com/api/payment_intents/retrieve
  3. WooCommerce REST API: list and read orders, including meta_data and transaction_id fields. 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 reconciliation reports?

If this cleared out a pile of false orphans, 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