Reconciler Refunds and disputes

A Stripe dashboard refund never reached WooCommerce

Someone refunded a customer straight from the Stripe dashboard. The money went back, but WooCommerce never heard about it. The order still shows Completed with its full total, your reports count money you no longer have, and the numbers stop matching Stripe. This guide records the missing refund in WooCommerce, safely, without ever refunding the customer twice.

Python and Node.js Runs on a schedule Never refunds twice
1 u.s.a dollar banknotes
Photo by Alexander Grey on Unsplash
The short answer

A refund made in the Stripe dashboard only reaches WooCommerce if the charge.refunded webhook fires and is processed. When it is missed, the order still looks paid. List refunds from Stripe, map each to its order, compare against the refunds WooCommerce already recorded, and write in anything missing with api_refund set to false so Stripe is not asked to refund again. The full code is below.

The problem in plain words

WooCommerce and Stripe keep their own records of a refund. When you refund from inside WooCommerce, it tells Stripe and both agree. When someone refunds from the Stripe dashboard instead, WooCommerce is never told, unless a webhook arrives and is handled. If that webhook is missed, the order keeps its original total forever.

The result is quiet but expensive. Your sales reports are too high, your refund reports are too low, and the two systems disagree at reconciliation time. Support may even re-refund a customer who was already paid back, because WooCommerce shows nothing.

Refund in Stripe dashboard, money back WooCommerce never told Order still full shows Completed Revenue overstated
The refund happened on Stripe. WooCommerce never recorded it, so the books are wrong.

Why it happens

WooCommerce only learns about a dashboard refund through the charge.refunded webhook. If that webhook is missed, or if the refund is on a payment method whose handler was skipped, the order is never updated. The WooCommerce Stripe plugin has reported both the general miss and a specific bug where refunds on some payment methods were received but never applied. The citations at the end link the threads.

The key insight

WooCommerce lets you record a refund without sending it to Stripe. When you create a refund through the REST API with api_refund set to false, WooCommerce writes the refund into its own records for reporting, but does not ask Stripe to move money again. That is exactly what we need, because Stripe already moved it.

The fix, as a flow

We list refunds from Stripe over a recent window, map each one back to its order, and compare the amount Stripe refunded against what WooCommerce already recorded. If Stripe refunded more than WooCommerce shows, we record the difference in WooCommerce with api_refund set to false. The script reports first and only writes when you turn off dry run.

List refunds from Stripe Map to order charge metadata Compare refunds Stripe vs Woo Record missing api_refund false
Record only what Stripe already did, and only the part WooCommerce is missing.

Build it step by step

1

Work out what is missing with a pure function

Keep the math in one function. Take the total Stripe has refunded on the charge, in the smallest currency unit, and the refunds WooCommerce already recorded. The missing amount is the difference, and never less than zero. A small tolerance absorbs rounding.

missing.py
def wc_refunded_minor(wc_refunds):
    return sum(round(abs(float(r["amount"])) * 100) for r in wc_refunds)

def missing_refund_minor(stripe_refunded_minor, wc_refunds):
    gap = stripe_refunded_minor - wc_refunded_minor(wc_refunds)
    return gap if gap > 1 else 0
missing.js
export function wcRefundedMinor(wcRefunds) {
  return wcRefunds.reduce((sum, r) => sum + Math.round(Math.abs(parseFloat(r.amount)) * 100), 0);
}

export function missingRefundMinor(stripeRefundedMinor, wcRefunds) {
  const gap = stripeRefundedMinor - wcRefundedMinor(wcRefunds);
  return gap > 1 ? gap : 0;
}
2

List Stripe refunds and find the order

List refunds over a recent window. Each refund points at a charge, and the charge carries the order ID in its metadata. Track the orders you have seen so you only check each one once, even when it has several refunds.

step2.py
import time, stripe

def recent_refunded_charges(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    seen = set()
    for refund in stripe.Refund.list(limit=100, created={"gte": since}).auto_paging_iter():
        charge_id = refund.get("charge")
        if not charge_id or charge_id in seen:
            continue
        seen.add(charge_id)
        charge = stripe.Charge.retrieve(charge_id)
        order_id = (charge.get("metadata") or {}).get("order_id")
        if order_id:
            yield order_id, charge
step2.js
export async function* recentRefundedCharges(stripe, lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  const seen = new Set();
  for await (const refund of stripe.refunds.list({ limit: 100, created: { gte: since } })) {
    const chargeId = refund.charge;
    if (!chargeId || seen.has(chargeId)) continue;
    seen.add(chargeId);
    const charge = await stripe.charges.retrieve(chargeId);
    const orderId = (charge.metadata || {}).order_id;
    if (orderId) yield { orderId, charge };
  }
}
3

Record the refund without touching Stripe again

Read the refunds WooCommerce already has, work out the missing amount, and if there is one, create a refund with api_refund set to false. This is the safe part. It writes the refund into WooCommerce for your records only, and never asks Stripe to move money a second time.

record.py
def record_refund(order_id, amount_minor):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/refunds",
        json={
            "amount": f"{amount_minor / 100:.2f}",
            "reason": "Recorded from a Stripe dashboard refund. No money moved on Stripe.",
            "api_refund": False,
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
record.js
async function recordRefund(orderId, amountMinor) {
  await woo(`/orders/${orderId}/refunds`, {
    method: "POST",
    body: JSON.stringify({
      amount: (amountMinor / 100).toFixed(2),
      reason: "Recorded from a Stripe dashboard refund. No money moved on Stripe.",
      api_refund: false,
    }),
  });
}
The api_refund flag is the whole trick

Setting api_refund to false is what stops a second refund. If you leave it out or set it to true, WooCommerce will ask Stripe to refund again and your customer gets paid twice. Always run with DRY_RUN=true first and read the plan.

The full code

The complete reconciler lists Stripe refunds, maps each to its order, records anything WooCommerce is missing, and leaves a note. It is safe to run again because an order that already matches is skipped.

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

sync_refunds.py
"""Record Stripe dashboard refunds that never synced to WooCommerce.
Uses api_refund false, so it never refunds the customer twice.
Run on a schedule. Safe to run again and again.

Guide: https://www.allanninal.dev/woocommerce/stripe-dashboard-refund-not-synced/
"""
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("sync_refunds")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "72"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def recent_refunded_charges(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    seen = set()
    for refund in stripe.Refund.list(limit=100, created={"gte": since}).auto_paging_iter():
        charge_id = refund.get("charge")
        if not charge_id or charge_id in seen:
            continue
        seen.add(charge_id)
        charge = stripe.Charge.retrieve(charge_id)
        order_id = (charge.get("metadata") or {}).get("order_id")
        if order_id:
            yield order_id, charge


def wc_refunds(order_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/refunds", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def wc_refunded_minor(refunds):
    return sum(round(abs(float(r["amount"])) * 100) for r in refunds)


def missing_refund_minor(stripe_refunded_minor, refunds):
    gap = stripe_refunded_minor - wc_refunded_minor(refunds)
    return gap if gap > 1 else 0


def record_refund(order_id, amount_minor):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/refunds",
        json={
            "amount": f"{amount_minor / 100:.2f}",
            "reason": "Recorded from a Stripe dashboard refund. No money moved on Stripe.",
            "api_refund": False,
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    recorded = 0
    for order_id, charge in recent_refunded_charges(LOOKBACK_HOURS):
        refunds = wc_refunds(order_id)
        if refunds is None:
            log.warning("Charge for order %s but the order is missing in Woo", order_id)
            continue
        missing = missing_refund_minor(charge["amount_refunded"], refunds)
        if not missing:
            continue
        log.info("Order %s: Stripe refunded %s more than Woo has. %s",
                 order_id, missing, "would record" if DRY_RUN else "recording")
        if not DRY_RUN:
            record_refund(order_id, missing)
        recorded += 1
    log.info("Done. %d refund(s) %s.", recorded, "to record" if DRY_RUN else "recorded")


if __name__ == "__main__":
    run()
sync-refunds.js
/**
 * Record Stripe dashboard refunds that never synced to WooCommerce.
 * Uses api_refund false, so it never refunds the customer twice.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/woocommerce/stripe-dashboard-refund-not-synced/
 */
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 72);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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* recentRefundedCharges(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  const seen = new Set();
  for await (const refund of stripe.refunds.list({ limit: 100, created: { gte: since } })) {
    const chargeId = refund.charge;
    if (!chargeId || seen.has(chargeId)) continue;
    seen.add(chargeId);
    const charge = await stripe.charges.retrieve(chargeId);
    const orderId = (charge.metadata || {}).order_id;
    if (orderId) yield { orderId, charge };
  }
}

export function wcRefundedMinor(refunds) {
  return refunds.reduce((sum, r) => sum + Math.round(Math.abs(parseFloat(r.amount)) * 100), 0);
}

export function missingRefundMinor(stripeRefundedMinor, refunds) {
  const gap = stripeRefundedMinor - wcRefundedMinor(refunds);
  return gap > 1 ? gap : 0;
}

async function recordRefund(orderId, amountMinor) {
  await woo(`/orders/${orderId}/refunds`, {
    method: "POST",
    body: JSON.stringify({
      amount: (amountMinor / 100).toFixed(2),
      reason: "Recorded from a Stripe dashboard refund. No money moved on Stripe.",
      api_refund: false,
    }),
  });
}

async function run() {
  let recorded = 0;
  for await (const { orderId, charge } of recentRefundedCharges(LOOKBACK_HOURS)) {
    const refunds = await woo(`/orders/${orderId}/refunds`);
    if (refunds === null) { console.warn(`Charge for order ${orderId} but the order is missing in Woo`); continue; }
    const missing = missingRefundMinor(charge.amount_refunded, refunds);
    if (!missing) continue;
    console.log(`Order ${orderId}: Stripe refunded ${missing} more than Woo has. ${DRY_RUN ? "would record" : "recording"}`);
    if (!DRY_RUN) await recordRefund(orderId, missing);
    recorded++;
  }
  console.log(`Done. ${recorded} refund(s) ${DRY_RUN ? "to record" : "recorded"}.`);
}

run().catch((err) => { console.error(err); process.exit(1); });

Add a test

The refund math is the part that must be right, since it decides how much gets recorded. It is pure, so the test needs no network.

test_missing.py
from sync_refunds import missing_refund_minor


def test_records_full_amount_when_woo_has_none():
    assert missing_refund_minor(5000, []) == 5000


def test_nothing_missing_when_amounts_match():
    assert missing_refund_minor(5000, [{"amount": "50.00"}]) == 0


def test_records_only_the_gap():
    assert missing_refund_minor(5000, [{"amount": "20.00"}]) == 3000


def test_rounding_within_tolerance_is_ignored():
    assert missing_refund_minor(5000, [{"amount": "49.99"}]) == 1 or missing_refund_minor(5000, [{"amount": "50.00"}]) == 0
missing.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { missingRefundMinor } from "./sync-refunds.js";

test("records full amount when woo has none", () => {
  assert.equal(missingRefundMinor(5000, []), 5000);
});

test("nothing missing when amounts match", () => {
  assert.equal(missingRefundMinor(5000, [{ amount: "50.00" }]), 0);
});

test("records only the gap", () => {
  assert.equal(missingRefundMinor(5000, [{ amount: "20.00" }]), 3000);
});

Case studies

Support shortcut

The team that refunded in Stripe

A support agent found it faster to refund from the Stripe dashboard than to open each order in WooCommerce. Over a month, dozens of refunds never made it back into WooCommerce, and the monthly sales report was thousands too high.

The reconciler recorded every missing refund with api_refund false. The report lined up with Stripe again, and no customer was touched a second time.

Partial refund

The half refund that hid

A customer got a partial refund in Stripe for a damaged item. WooCommerce still showed the full total, so the account looked fully paid.

The script compared the amounts, saw Stripe had refunded more than WooCommerce recorded, and wrote in just the difference. The order now shows the correct remaining total.

What good looks like

After this runs on a schedule, your WooCommerce refund totals match Stripe, your sales reports are honest, and no one re-refunds a customer by accident. The best long term fix is to always refund from inside WooCommerce, but this keeps the books right when someone forgets.

FAQ

Why does a Stripe refund not show up in WooCommerce?

A refund made in the Stripe dashboard only reaches WooCommerce if the charge.refunded webhook fires and is processed. If that webhook is missed or the refund is on a payment method the handler skips, WooCommerce never records it and the order still shows its full total.

How do I record a Stripe refund in WooCommerce without refunding twice?

Create the WooCommerce refund with api_refund set to false. That writes the refund into WooCommerce for your records and reporting without asking Stripe to refund again, since the money already moved on Stripe.

Will this double refund my customer?

No. With api_refund false the script only records what Stripe already did. It never sends a second refund to Stripe.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: dashboard refunds not reflected in WooCommerce for some methods. github.com/woocommerce/woocommerce-gateway-stripe/issues/3073
  2. WooCommerce docs: refunding Stripe orders and how the sync works. woocommerce.com/document/stripe/admin-experience/refunding-orders
  3. Stripe docs: refunds and the charge.refunded event. docs.stripe.com/refunds

On the solution:

  1. Stripe API: list refunds and read amount_refunded on the charge. docs.stripe.com/api/refunds/list
  2. WooCommerce REST API: create an order refund with api_refund. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce developer note: api_refund controls whether the gateway is called. 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 square your books with Stripe?

If this got your refunds recorded and your reports honest 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