Reconciler Refunds and disputes

Disputes not recorded on the order

The bank files a chargeback, Stripe pulls the money out of your balance right away, and the case clock starts ticking. But open the WooCommerce order and it looks exactly like it did the day it was paid. No note, no warning, no deadline. The shop manager finds out from the bank statement, not from the store. Here is why the dispute never reaches the order and a small script that writes it there safely.

Python and Node.js Runs on a schedule Read only by default (dry run)
Closeup of 100 us dollar banknotes
Photo by Pepi Stojanovski on Unsplash
The short answer

WooCommerce only learns about a dispute from a charge.dispute.* webhook, and that message is missed just as often as a payment webhook is. Run a small Python or Node.js script on a schedule that lists recent disputes from Stripe, follows the charge back to its PaymentIntent, finds the WooCommerce order that saved that intent id, and writes the dispute's status, amount, and evidence deadline onto the order as a note and a meta field. It never files or answers the dispute for you. Full code, tests, and a dry run guard are below.

The problem in plain words

A dispute, sometimes called a chargeback, happens when a cardholder tells their bank the charge was wrong. Stripe pulls the disputed amount plus a fee out of your balance immediately, while the bank investigates. This is a real cash event on day one, not a maybe.

WooCommerce has no way to know any of this happened unless your store is told. The Stripe gateway plugin listens for dispute webhooks and, when it catches one, adds a note to the order. If that message never lands, or the handler for it errors out, the order sits there looking untouched. The evidence deadline, usually seven to twenty one days, keeps counting down with nobody watching it from inside the store.

Cardholder files a dispute Stripe opens case funds removed now webhook lost Order untouched still looks paid No note Deadline ticking
The money leaves the balance at the moment the case opens. The order never shows it because the webhook that should record it is lost.

Why it happens

Disputes are rarer than payments, so this gap tends to hide for months before anyone notices. A few common reasons the dispute never makes it to the order:

The Stripe docs are direct about this: your integration must specifically listen for dispute events, they do not ride along with payment events. See the citations at the end for the exact references.

The key insight

Stripe is the source of truth for disputes, the same way it is for payments. If Stripe has an open or closed dispute against a charge and the WooCommerce order has no record of it, the order is out of date, not Stripe. A small reconciler that reads dispute state from Stripe and writes it onto the order closes that gap without needing to touch your webhook setup.

The fix, as a flow

We do not answer or contest anything. We add a job that runs on a schedule, lists disputes Stripe has opened recently, follows each one back to the charge and its PaymentIntent, and finds the WooCommerce order that saved that PaymentIntent id. If the order's saved dispute status does not match what Stripe currently reports, we add a note with the status, amount, reason, and evidence deadline, and save the status in order meta so the next run knows it is already recorded.

Scheduled job every hour List disputes from Stripe (recent) Find the order by PaymentIntent id Status is new or changed? yes no, skip Write note + meta status, amount, deadline
The reconciler only writes when the dispute is new or its status has moved on since the last run. 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 HOLD_ON_OPEN_DISPUTE="false"   # true also moves open disputes to on-hold
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 HOLD_ON_OPEN_DISPUTE="false"   // true also moves open disputes to on-hold
export DRY_RUN="true"   // start safe, change to false to write
2

List recent disputes from Stripe

Ask Stripe for disputes created within your lookback window. A dispute object carries the charge it is against, but not the PaymentIntent id directly on every API version, so we follow the charge to get it.

step2.py
import os, time, stripe

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

def recent_disputes(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    disputes = stripe.Dispute.list(limit=100, created={"gte": since})
    for dispute in disputes.auto_paging_iter():
        yield dispute

def intent_id_of_dispute(dispute):
    charge = dispute.get("charge")
    if isinstance(charge, dict):
        return charge.get("payment_intent")
    if isinstance(charge, str):
        try:
            full_charge = stripe.Charge.retrieve(charge)
            return full_charge.get("payment_intent")
        except stripe.error.InvalidRequestError:
            return None
    return None
step2.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function* recentDisputes(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const dispute of stripe.disputes.list({ limit: 100, created: { gte: since } })) {
    yield dispute;
  }
}

async function intentIdOfDispute(dispute) {
  const charge = dispute.charge;
  if (charge && typeof charge === "object") return charge.payment_intent || null;
  if (typeof charge === "string") {
    try {
      const fullCharge = await stripe.charges.retrieve(charge);
      return fullCharge.payment_intent || null;
    } catch {
      return null;
    }
  }
  return null;
}
3

Find the order that was charged

Use the WooCommerce REST API to search for the order whose saved _stripe_intent_id meta matches the PaymentIntent, with a fallback to a search on transaction_id for stores that store it there instead. This works the same on stores with High Performance Order Storage (HPOS) turned on, since we go through the REST API rather than the database.

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

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

def find_order_by_intent(intent_id):
    if not intent_id:
        return None
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"meta_key": "_stripe_intent_id", "meta_value": intent_id, "per_page": 1},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    batch = r.json()
    if batch:
        return batch[0]
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"search": intent_id, "per_page": 5},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    for order in r.json():
        if order.get("transaction_id") == intent_id:
            return order
    return None
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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function findOrderByIntent(intentId) {
  if (!intentId) return null;
  const byMeta = await woo(
    `/orders?meta_key=_stripe_intent_id&meta_value=${encodeURIComponent(intentId)}&per_page=1`
  );
  if (byMeta.length) return byMeta[0];
  const bySearch = await woo(`/orders?search=${encodeURIComponent(intentId)}&per_page=5`);
  return bySearch.find((order) => order.transaction_id === intentId) || null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes an order and a dispute and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If no order matches, flag it as an orphan for a manual look. If the order already has this exact dispute status saved, skip it. Otherwise, record it.

decide.py
DISPUTE_META_KEY = "_dispute_status"

def order_dispute_meta(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == DISPUTE_META_KEY:
            return meta.get("value") or None
    return None

def decide(order, dispute):
    if order is None:
        return ("orphan", "no order matches this dispute's PaymentIntent")
    recorded = order_dispute_meta(order)
    if recorded == dispute["status"]:
        return ("skip", "order already shows this dispute status")
    return ("record", "dispute status changed or was never recorded")
decide.js
const DISPUTE_META_KEY = "_dispute_status";

export function orderDisputeMeta(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === DISPUTE_META_KEY) return meta.value || null;
  }
  return null;
}

export function decide(order, dispute) {
  if (!order) return ["orphan", "no order matches this dispute's PaymentIntent"];
  const recorded = orderDisputeMeta(order);
  if (recorded === dispute.status) return ["skip", "order already shows this dispute status"];
  return ["record", "dispute status changed or was never recorded"];
}
5

Write the note and the meta field

When the action is record, add a note with the status, amount, reason, and evidence deadline, then save the status in order meta so the next run can tell a truly new change from one it already recorded. Dispute amounts from Stripe already arrive in minor units (cents), so there is no currency conversion to do here, unlike an order total.

apply.py
import time

def dispute_amount_minor(dispute):
    # Stripe already reports this in cents, so no conversion is needed.
    return int(dispute["amount"])

def format_note(dispute, reason):
    amount = dispute_amount_minor(dispute) / 100
    currency = dispute.get("currency", "usd").upper()
    deadline = dispute.get("evidence_details", {}).get("due_by")
    deadline_str = (
        time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(deadline)) if deadline else "no deadline given"
    )
    return (
        f"Stripe dispute {dispute['id']} is {dispute['status']} for {amount:.2f} {currency}. "
        f"Reason: {dispute.get('reason', 'unknown')}. Evidence due by {deadline_str}. "
        f"({reason})"
    )

def record(order, dispute):
    note = format_note(dispute, "recorded by the disputes reconciler")
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": note}, auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"meta_data": [{"key": DISPUTE_META_KEY, "value": dispute["status"]}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
export function disputeAmountMinor(dispute) {
  // Stripe already reports this in cents, so no conversion is needed.
  return Number(dispute.amount);
}

export function formatNote(dispute, reason) {
  const amount = (disputeAmountMinor(dispute) / 100).toFixed(2);
  const currency = (dispute.currency || "usd").toUpperCase();
  const dueBy = dispute.evidence_details && dispute.evidence_details.due_by;
  const deadline = dueBy
    ? new Date(dueBy * 1000).toISOString().slice(0, 16).replace("T", " ") + " UTC"
    : "no deadline given";
  return (
    `Stripe dispute ${dispute.id} is ${dispute.status} for ${amount} ${currency}. ` +
    `Reason: ${dispute.reason || "unknown"}. Evidence due by ${deadline}. (${reason})`
  );
}

async function record(order, dispute) {
  const note = formatNote(dispute, "recorded by the disputes reconciler");
  await woo(`/orders/${order.id}/notes`, { method: "POST", body: JSON.stringify({ note }) });
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: DISPUTE_META_KEY, value: dispute.status }] }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard, and the optional HOLD_ON_OPEN_DISPUTE flag that, when turned on, also moves the order to On hold while the case is still open so it visually stands out in the orders list. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Run it on a schedule with cron about once an hour.

Run it safe

Always start with DRY_RUN=true. This script never contacts Stripe about the dispute itself, but it does write notes and order status, so you want to see its plan before it acts.

The full code

Here is the complete script 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 writes when the dispute status is new or has changed since the last run.

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

record_disputes.py
"""Record Stripe disputes and chargebacks on the matching WooCommerce order.

A chargeback pulls funds out of your Stripe balance the moment the bank files it,
but nothing about that event reaches WooCommerce on its own unless the
charge.dispute.* webhooks are wired up and processed. When they are missed, the
order still shows its normal paid total, the shop manager has no idea money left
the account, and the evidence deadline can pass unnoticed. This walks recent
disputes from Stripe, finds the order that was charged, and writes the dispute
status, amount, and evidence deadline onto the order as a note (and an order
meta field), so the loss and the deadline are visible where the shop manager
already works. Read only by default. Run on a schedule.
"""
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("record_disputes")

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

# Statuses where the case is still open and needs evidence or a decision.
OPEN_STATUSES = {
    "warning_needs_response",
    "warning_under_review",
    "needs_response",
    "under_review",
}
# Statuses where Stripe has finished the case.
CLOSED_STATUSES = {"won", "lost", "warning_closed", "charge_refunded"}

DISPUTE_META_KEY = "_dispute_status"


def recent_disputes(lookback_hours):
    """Yield Stripe disputes created within the lookback window, oldest fields expanded."""
    since = int(time.time()) - lookback_hours * 3600
    disputes = stripe.Dispute.list(limit=100, created={"gte": since})
    for dispute in disputes.auto_paging_iter():
        yield dispute


def intent_id_of_dispute(dispute):
    """The PaymentIntent id behind a dispute, straight from the charge it disputes."""
    charge = dispute.get("charge")
    if isinstance(charge, dict):
        return charge.get("payment_intent")
    # Some API versions return the charge as an id string. Retrieve it to get
    # the PaymentIntent id. This is the one network call we cannot avoid.
    if isinstance(charge, str):
        try:
            full_charge = stripe.Charge.retrieve(charge)
            return full_charge.get("payment_intent")
        except stripe.error.InvalidRequestError:
            return None
    return None


def find_order_by_intent(intent_id):
    """Look up the order whose saved PaymentIntent id matches, via the WooCommerce
    REST API search on meta. Falls back to a direct meta query."""
    if not intent_id:
        return None
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"meta_key": "_stripe_intent_id", "meta_value": intent_id, "per_page": 1},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    batch = r.json()
    if batch:
        return batch[0]
    # Fall back to transaction_id, which some setups use instead of order meta.
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/orders",
        params={"search": intent_id, "per_page": 5},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    for order in r.json():
        if order.get("transaction_id") == intent_id:
            return order
    return None


def order_dispute_meta(order):
    """The dispute status already recorded on the order, or None if never recorded."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == DISPUTE_META_KEY:
            return meta.get("value") or None
    return None


def decide(order, dispute):
    """Pure decision function: no I/O, just data in, action out.

    Returns a tuple of (action, reason). action is one of:
      "orphan"  - the dispute has no matching order, worth a manual look
      "skip"    - the order already has this exact dispute status recorded
      "record"  - write the dispute status onto the order
    """
    if order is None:
        return ("orphan", "no order matches this dispute's PaymentIntent")
    recorded = order_dispute_meta(order)
    if recorded == dispute["status"]:
        return ("skip", "order already shows this dispute status")
    return ("record", "dispute status changed or was never recorded")


def dispute_amount_minor(dispute):
    """Stripe already reports dispute amounts in minor units (cents), unlike the
    WooCommerce order total, so no conversion is needed here."""
    return int(dispute["amount"])


def format_note(dispute, reason):
    amount = dispute_amount_minor(dispute) / 100
    currency = dispute.get("currency", "usd").upper()
    deadline = dispute.get("evidence_details", {}).get("due_by")
    deadline_str = (
        time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(deadline)) if deadline else "no deadline given"
    )
    return (
        f"Stripe dispute {dispute['id']} is {dispute['status']} for {amount:.2f} {currency}. "
        f"Reason: {dispute.get('reason', 'unknown')}. Evidence due by {deadline_str}. "
        f"({reason})"
    )


def record(order, dispute):
    note = format_note(dispute, "recorded by the disputes reconciler")
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": note},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"meta_data": [{"key": DISPUTE_META_KEY, "value": dispute["status"]}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if HOLD_ON_OPEN_DISPUTE and dispute["status"] in OPEN_STATUSES and order["status"] not in ("on-hold", "refunded", "cancelled"):
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
            json={"status": "on-hold"},
            auth=AUTH, timeout=30,
        ).raise_for_status()


def run():
    recorded = 0
    for dispute in recent_disputes(LOOKBACK_HOURS):
        intent_id = intent_id_of_dispute(dispute)
        order = find_order_by_intent(intent_id)
        action, reason = decide(order, dispute)
        if action == "orphan":
            log.warning("Dispute %s (intent %s): %s", dispute["id"], intent_id, reason)
            continue
        if action == "skip":
            continue
        log.info(
            "Dispute %s on order %s: %s. %s",
            dispute["id"], order["id"], reason, "would record" if DRY_RUN else "recording",
        )
        if not DRY_RUN:
            record(order, dispute)
        recorded += 1
    log.info("Done. %d dispute(s) %s.", recorded, "to record" if DRY_RUN else "recorded")


if __name__ == "__main__":
    run()
record-disputes.js
/**
 * Record Stripe disputes and chargebacks on the matching WooCommerce order.
 *
 * A chargeback pulls funds out of your Stripe balance the moment the bank files
 * it, but nothing about that event reaches WooCommerce on its own unless the
 * charge.dispute.* webhooks are wired up and processed. When they are missed,
 * the order still shows its normal paid total, the shop manager has no idea
 * money left the account, and the evidence deadline can pass unnoticed. This
 * walks recent disputes from Stripe, finds the order that was charged, and
 * writes the dispute status, amount, and evidence deadline onto the order as a
 * note (and an order meta field), so the loss and the deadline are visible
 * where the shop manager already works. Read only by default. Run on a
 * schedule.
 */
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_HOURS = Number(process.env.LOOKBACK_HOURS || 72);
const HOLD_ON_OPEN_DISPUTE = (process.env.HOLD_ON_OPEN_DISPUTE || "false").toLowerCase() === "true";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Statuses where the case is still open and needs evidence or a decision.
const OPEN_STATUSES = new Set([
  "warning_needs_response",
  "warning_under_review",
  "needs_response",
  "under_review",
]);

const DISPUTE_META_KEY = "_dispute_status";

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* recentDisputes(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const dispute of stripe.disputes.list({ limit: 100, created: { gte: since } })) {
    yield dispute;
  }
}

export async function intentIdOfDispute(dispute) {
  const charge = dispute.charge;
  if (charge && typeof charge === "object") return charge.payment_intent || null;
  if (typeof charge === "string") {
    try {
      const fullCharge = await stripe.charges.retrieve(charge);
      return fullCharge.payment_intent || null;
    } catch {
      return null;
    }
  }
  return null;
}

async function findOrderByIntent(intentId) {
  if (!intentId) return null;
  const byMeta = await woo(
    `/orders?meta_key=_stripe_intent_id&meta_value=${encodeURIComponent(intentId)}&per_page=1`
  );
  if (byMeta.length) return byMeta[0];
  const bySearch = await woo(`/orders?search=${encodeURIComponent(intentId)}&per_page=5`);
  return bySearch.find((order) => order.transaction_id === intentId) || null;
}

export function orderDisputeMeta(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === DISPUTE_META_KEY) return meta.value || null;
  }
  return null;
}

/**
 * Pure decision function: no I/O, just data in, action out.
 * Returns [action, reason]. action is one of "orphan", "skip", "record".
 */
export function decide(order, dispute) {
  if (!order) return ["orphan", "no order matches this dispute's PaymentIntent"];
  const recorded = orderDisputeMeta(order);
  if (recorded === dispute.status) return ["skip", "order already shows this dispute status"];
  return ["record", "dispute status changed or was never recorded"];
}

export function disputeAmountMinor(dispute) {
  // Stripe already reports dispute amounts in minor units (cents), unlike the
  // WooCommerce order total, so no conversion is needed here.
  return Number(dispute.amount);
}

export function formatNote(dispute, reason) {
  const amount = (disputeAmountMinor(dispute) / 100).toFixed(2);
  const currency = (dispute.currency || "usd").toUpperCase();
  const dueBy = dispute.evidence_details && dispute.evidence_details.due_by;
  const deadline = dueBy
    ? new Date(dueBy * 1000).toISOString().slice(0, 16).replace("T", " ") + " UTC"
    : "no deadline given";
  return (
    `Stripe dispute ${dispute.id} is ${dispute.status} for ${amount} ${currency}. ` +
    `Reason: ${dispute.reason || "unknown"}. Evidence due by ${deadline}. (${reason})`
  );
}

async function record(order, dispute) {
  const note = formatNote(dispute, "recorded by the disputes reconciler");
  await woo(`/orders/${order.id}/notes`, { method: "POST", body: JSON.stringify({ note }) });
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: DISPUTE_META_KEY, value: dispute.status }] }),
  });
  if (
    HOLD_ON_OPEN_DISPUTE &&
    OPEN_STATUSES.has(dispute.status) &&
    !["on-hold", "refunded", "cancelled"].includes(order.status)
  ) {
    await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "on-hold" }) });
  }
}

export async function run() {
  let recorded = 0;
  for await (const dispute of recentDisputes(LOOKBACK_HOURS)) {
    const intentId = await intentIdOfDispute(dispute);
    const order = await findOrderByIntent(intentId);
    const [action, reason] = decide(order, dispute);
    if (action === "orphan") {
      console.warn(`Dispute ${dispute.id} (intent ${intentId}): ${reason}`);
      continue;
    }
    if (action === "skip") continue;
    console.log(
      `Dispute ${dispute.id} on order ${order.id}: ${reason}. ${DRY_RUN ? "would record" : "recording"}`
    );
    if (!DRY_RUN) await record(order, dispute);
    recorded++;
  }
  console.log(`Done. ${recorded} dispute(s) ${DRY_RUN ? "to record" : "recorded"}.`);
}

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 whether real order notes and order status get written. 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_disputes_decide.py
from record_disputes import decide, order_dispute_meta, dispute_amount_minor, intent_id_of_dispute


def dispute(**over):
    base = {
        "id": "dp_1",
        "status": "warning_needs_response",
        "amount": 5000,
        "currency": "usd",
        "reason": "fraudulent",
        "evidence_details": {"due_by": 1_800_000_000},
    }
    base.update(over)
    return base


def test_record_when_never_recorded():
    order = {"id": 10, "status": "processing", "meta_data": []}
    assert decide(order, dispute())[0] == "record"


def test_skip_when_status_unchanged():
    order = {
        "id": 10,
        "status": "processing",
        "meta_data": [{"key": "_dispute_status", "value": "warning_needs_response"}],
    }
    assert decide(order, dispute())[0] == "skip"


def test_record_when_status_moved_on():
    order = {
        "id": 10,
        "status": "processing",
        "meta_data": [{"key": "_dispute_status", "value": "warning_needs_response"}],
    }
    assert decide(order, dispute(status="lost"))[0] == "record"


def test_orphan_when_order_missing():
    action, reason = decide(None, dispute())
    assert action == "orphan"
    assert "no order" in reason
record-disputes.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, orderDisputeMeta } from "./record-disputes.js";

const dispute = (over = {}) => ({
  id: "dp_1",
  status: "warning_needs_response",
  amount: 5000,
  currency: "usd",
  reason: "fraudulent",
  evidence_details: { due_by: 1800000000 },
  ...over,
});

test("record when never recorded", () => {
  const order = { id: 10, status: "processing", meta_data: [] };
  assert.equal(decide(order, dispute())[0], "record");
});

test("skip when status unchanged", () => {
  const order = {
    id: 10, status: "processing",
    meta_data: [{ key: "_dispute_status", value: "warning_needs_response" }],
  };
  assert.equal(decide(order, dispute())[0], "skip");
});

test("record when status moved on", () => {
  const order = {
    id: 10, status: "processing",
    meta_data: [{ key: "_dispute_status", value: "warning_needs_response" }],
  };
  assert.equal(decide(order, dispute({ status: "lost" }))[0], "record");
});

test("orphan when order missing", () => {
  const [action, reason] = decide(null, dispute());
  assert.equal(action, "orphan");
  assert.match(reason, /no order/);
});

Case studies

Missing event subscription

The store that only listened for payments

A shop set up its Stripe webhook endpoint years ago and only ever selected payment and refund events. When disputes started arriving as the store grew, nothing broke, nothing errored, the events simply were never sent because the endpoint never subscribed to them.

Three months of disputes had piled up with zero record on any order. The reconciler's first run surfaced all of them in dry run mode, evidence deadlines and all, letting the owner see which ones could still be answered before running for real.

Silent handler bug

The handler that swallowed dispute events

A custom webhook handler had a catch-all that logged unknown event types and returned 200 without acting on them, including charge.dispute.created. Stripe considered every delivery successful, so it never retried, and the events were gone for good from Stripe's perspective, though the dispute itself still lived on the Stripe side.

Running the reconciler on a schedule going forward meant new disputes were caught within the hour regardless of what the webhook handler did with them, since the script reads dispute state straight from Stripe rather than depending on a webhook delivery at all.

What good looks like

After this runs on a schedule, a missed dispute webhook is no longer an invisible loss. The shop manager sees the note, the amount, and the deadline on the order itself, in the same place they already look for order history. Keep it running even after the webhook subscription is fixed, since it costs nothing to double check.

FAQ

Why does a Stripe dispute not show up on the WooCommerce order?

WooCommerce only learns about a dispute from a charge.dispute.created (or updated) webhook. If that webhook is not set up, fails, or is silently ignored by the handler, Stripe still holds the case and takes the funds, but the order keeps showing its normal paid status with no note about it.

Will a script like this fight the dispute for me?

No. It only records what Stripe already knows, the status, amount, reason, and evidence deadline, as a note and a meta field on the order. Submitting evidence is still a manual step you or your payment processor handles in the Stripe dashboard.

How often should this run?

Every hour is enough for most stores. Disputes are rare compared to orders, and the evidence window is usually seven to twenty one days, so there is no need to run this every few minutes like a payment reconciler.

Related field notes

Citations

On the problem:

  1. Stripe docs: disputes and how to receive dispute-related webhook events. docs.stripe.com/disputes
  2. Stripe docs: the full list of dispute event types, including charge.dispute.created and charge.dispute.closed. docs.stripe.com/api/events/types
  3. WooCommerce docs: how the Stripe gateway plugin uses webhooks to keep orders in sync. woocommerce.com/document/stripe

On the solution:

  1. Stripe API: list disputes with a created filter and auto pagination. docs.stripe.com/api/disputes/list
  2. Stripe API: the Dispute object, including amount, status, reason, and evidence_details.due_by. docs.stripe.com/api/disputes/object
  3. WooCommerce REST API: update an order's meta data 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 catch a dispute you missed?

If this helped you spot a chargeback before the evidence deadline slipped past, 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