Reconciler Charges and money

Stripe charged the customer twice for one WooCommerce order

Now and then a customer gets billed twice for a single order. Stripe shows two successful charges a minute apart, both for the same order, while WooCommerce only knows about one. It comes from a race between the browser redirect and the webhook, and it is a fast track to chargebacks. Here is how to find every double charge and refund the extra one in a safe, repeatable way.

Python and Node.js Runs on a schedule Safe by default (dry run)
Person holding black and white electronic device
Photo by Towfiqu barbhuiya on Unsplash
The short answer

Two code paths can complete the same payment, the redirect after checkout and the Stripe webhook, and under a race both create a charge. List recent charges from Stripe, group them by metadata.order_id, and flag any order with more than one succeeded charge for the same amount. Keep the charge recorded on the order and refund the extra. The script below reports first and only refunds when you turn off dry run.

The problem in plain words

When a customer pays, two things try to finish the order. The browser comes back to your store and runs the return handler, and Stripe also sends a webhook. Both are supposed to complete the same single payment. If they run at nearly the same moment, before the order is marked done, each one can create its own charge. The result is two successful charges for one order, usually less than a minute apart and for the exact same amount.

WooCommerce records only one of them as the order transaction. The other charge is real money taken from the customer that no one is watching. The customer notices, asks for their money back, and sometimes goes straight to their bank for a chargeback.

Buyer pays one order Redirect handler creates charge A Webhook creates charge B Two charges same order_id Chargeback risk
Both paths finish the same payment. Under a race, each makes a charge, and the order ends up double billed.

Why it happens

The WooCommerce Stripe plugin has reported cases of duplicate charges from this exact race, and from a gap where the idempotency key was not applied on the newer payment path. An idempotency key is a value that tells Stripe two requests are really the same, so it should only charge once. When it is missing on one path, a retry or a second path can slip through and charge again. The citations at the end link the plugin issues.

The key insight

The plugin writes the order ID onto every charge as metadata.order_id. So even though the two charges live in different PaymentIntents, they share the same order ID. Group charges by that field, and any order that owns more than one succeeded charge for the same amount is a duplicate you can act on.

The fix, as a flow

We list recent charges, group them by order ID, and find the groups that hold more than one succeeded charge for the same amount. For each group we keep the charge that the WooCommerce order records as its transaction, and refund the others. The script reports first. It only refunds when you turn off dry run.

List charges recent window Group by order metadata.order_id More than one succeeded, same amount Refund extra keep recorded one
Duplicates share an order ID. Keep the one the order recorded and refund the rest.

Build it step by step

1

Group charges and find the duplicates

Keep the detection in a pure function. Group succeeded, unrefunded charges by order ID, then within each order group by amount. Any amount that shows up more than once for the same order is a duplicate set. Grouping by amount avoids treating a legitimate second order, or a separate partial payment, as a duplicate.

detect.py
from collections import defaultdict

def duplicate_sets(charges):
    by_order = defaultdict(list)
    for ch in charges:
        oid = (ch.get("metadata") or {}).get("order_id")
        if oid and ch.get("status") == "succeeded" and not ch.get("refunded"):
            by_order[oid].append(ch)
    duplicates = {}
    for oid, group in by_order.items():
        by_amount = defaultdict(list)
        for ch in group:
            by_amount[ch["amount"]].append(ch)
        for amount, same in by_amount.items():
            if len(same) > 1:
                duplicates[(oid, amount)] = same
    return duplicates
detect.js
export function duplicateSets(charges) {
  const byOrder = new Map();
  for (const ch of charges) {
    const oid = (ch.metadata || {}).order_id;
    if (oid && ch.status === "succeeded" && !ch.refunded) {
      if (!byOrder.has(oid)) byOrder.set(oid, []);
      byOrder.get(oid).push(ch);
    }
  }
  const duplicates = new Map();
  for (const [oid, group] of byOrder) {
    const byAmount = new Map();
    for (const ch of group) {
      if (!byAmount.has(ch.amount)) byAmount.set(ch.amount, []);
      byAmount.get(ch.amount).push(ch);
    }
    for (const [amount, same] of byAmount) {
      if (same.length > 1) duplicates.set(`${oid}:${amount}`, same);
    }
  }
  return duplicates;
}
2

Pick the charge to keep

The keeper is the charge the WooCommerce order already records as its transaction. Read the order, take its transaction_id, and keep the charge whose ID matches. If nothing matches, keep the earliest charge, since that is the one most likely tied to the order. Everything else in the set gets refunded.

keeper.py
def choose_extras(same, order_transaction_id):
    keeper = next((c for c in same if c["id"] == order_transaction_id), None)
    if keeper is None:
        keeper = min(same, key=lambda c: c["created"])
    return [c for c in same if c["id"] != keeper["id"]]
keeper.js
export function chooseExtras(same, orderTransactionId) {
  let keeper = same.find((c) => c.id === orderTransactionId);
  if (!keeper) keeper = same.reduce((a, b) => (a.created <= b.created ? a : b));
  return same.filter((c) => c.id !== keeper.id);
}
3

Refund the extra charge and note the order

Refund each extra charge on Stripe and leave a note on the order so the shop manager sees what happened and why. The note keeps a clear paper trail, which matters when a customer asks about the second line on their statement.

refund.py
def refund_extra(order_id, charge):
    stripe.Refund.create(charge=charge["id"], reason="duplicate")
    add_note(order_id, f"Refunded duplicate Stripe charge {charge['id']} "
                       f"({charge['amount']} {charge['currency']}). Kept the charge on the order.")
refund.js
async function refundExtra(orderId, charge) {
  await stripe.refunds.create({ charge: charge.id, reason: "duplicate" });
  await addNote(orderId, `Refunded duplicate Stripe charge ${charge.id} ` +
                         `(${charge.amount} ${charge.currency}). Kept the charge on the order.`);
}
Refunds move real money

This script issues refunds when dry run is off. Always run it with DRY_RUN=true first and read the list of charges it would refund. Confirm each one is truly a duplicate for the same order and amount before you let it act.

The full code

The complete reconciler pages recent charges, groups them, keeps the recorded charge, and refunds the extras. It is safe to run again because a refunded charge is skipped on the next pass.

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

refund_duplicates.py
"""Find WooCommerce orders charged twice on Stripe and refund the extra charge.
Run on a schedule. Safe to run again and again.
"""
import os
import time
import logging
from collections import defaultdict
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("refund_duplicates")

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


def recent_charges(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    return stripe.Charge.list(limit=100, created={"gte": since}).auto_paging_iter()


def duplicate_sets(charges):
    by_order = defaultdict(list)
    for ch in charges:
        oid = (ch.get("metadata") or {}).get("order_id")
        if oid and ch.get("status") == "succeeded" and not ch.get("refunded"):
            by_order[oid].append(ch)
    duplicates = {}
    for oid, group in by_order.items():
        by_amount = defaultdict(list)
        for ch in group:
            by_amount[ch["amount"]].append(ch)
        for amount, same in by_amount.items():
            if len(same) > 1:
                duplicates[(oid, amount)] = same
    return duplicates


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


def choose_extras(same, order_transaction):
    keeper = next((c for c in same if c["id"] == order_transaction), None)
    if keeper is None:
        keeper = min(same, key=lambda c: c["created"])
    return [c for c in same if c["id"] != keeper["id"]]


def add_note(order_id, note):
    requests.post(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
                  json={"note": note}, auth=AUTH, timeout=30).raise_for_status()


def run():
    charges = list(recent_charges(LOOKBACK_HOURS))
    duplicates = duplicate_sets(charges)
    refunded = 0
    for (order_id, amount), same in duplicates.items():
        extras = choose_extras(same, order_transaction_id(order_id))
        for charge in extras:
            log.info("Order %s: duplicate charge %s for %s. %s",
                     order_id, charge["id"], amount, "would refund" if DRY_RUN else "refunding")
            if not DRY_RUN:
                stripe.Refund.create(charge=charge["id"], reason="duplicate")
                add_note(order_id, f"Refunded duplicate Stripe charge {charge['id']} "
                                   f"({charge['amount']} {charge['currency']}). Kept the charge on the order.")
            refunded += 1
    log.info("Done. %d duplicate charge(s) %s.", refunded, "to refund" if DRY_RUN else "refunded")


if __name__ == "__main__":
    run()
refund-duplicates.js
/**
 * Find WooCommerce orders charged twice on Stripe and refund the extra charge.
 * Run on a schedule. Safe to run again and 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 LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 48);
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 recentCharges(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  const out = [];
  for await (const ch of stripe.charges.list({ limit: 100, created: { gte: since } })) out.push(ch);
  return out;
}

export function duplicateSets(charges) {
  const byOrder = new Map();
  for (const ch of charges) {
    const oid = (ch.metadata || {}).order_id;
    if (oid && ch.status === "succeeded" && !ch.refunded) {
      if (!byOrder.has(oid)) byOrder.set(oid, []);
      byOrder.get(oid).push(ch);
    }
  }
  const duplicates = new Map();
  for (const [oid, group] of byOrder) {
    const byAmount = new Map();
    for (const ch of group) {
      if (!byAmount.has(ch.amount)) byAmount.set(ch.amount, []);
      byAmount.get(ch.amount).push(ch);
    }
    for (const [amount, same] of byAmount) {
      if (same.length > 1) duplicates.set(`${oid}:${amount}`, { oid, same });
    }
  }
  return duplicates;
}

export function chooseExtras(same, orderTransactionId) {
  let keeper = same.find((c) => c.id === orderTransactionId);
  if (!keeper) keeper = same.reduce((a, b) => (a.created <= b.created ? a : b));
  return same.filter((c) => c.id !== keeper.id);
}

async function run() {
  const charges = await recentCharges(LOOKBACK_HOURS);
  const duplicates = duplicateSets(charges);
  let refunded = 0;
  for (const { oid, same } of duplicates.values()) {
    const order = await woo(`/orders/${oid}`);
    const extras = chooseExtras(same, order ? order.transaction_id : null);
    for (const charge of extras) {
      console.log(`Order ${oid}: duplicate charge ${charge.id}. ${DRY_RUN ? "would refund" : "refunding"}`);
      if (!DRY_RUN) {
        await stripe.refunds.create({ charge: charge.id, reason: "duplicate" });
        await woo(`/orders/${oid}/notes`, {
          method: "POST",
          body: JSON.stringify({
            note: `Refunded duplicate Stripe charge ${charge.id} ` +
                  `(${charge.amount} ${charge.currency}). Kept the charge on the order.`,
          }),
        });
      }
      refunded++;
    }
  }
  console.log(`Done. ${refunded} duplicate charge(s) ${DRY_RUN ? "to refund" : "refunded"}.`);
}

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

Add a test

The detection and the keeper choice are the risky parts, since they decide what gets refunded. Both are pure, so we test them with plain charge objects and no network.

test_duplicates.py
from refund_duplicates import duplicate_sets, choose_extras


def charge(cid, order_id, amount=5000, created=1, refunded=False, status="succeeded"):
    return {"id": cid, "amount": amount, "currency": "usd", "created": created,
            "refunded": refunded, "status": status, "metadata": {"order_id": order_id}}


def test_detects_two_charges_same_order_and_amount():
    dups = duplicate_sets([charge("ch_1", "42"), charge("ch_2", "42")])
    assert ("42", 5000) in dups


def test_ignores_single_charge():
    assert duplicate_sets([charge("ch_1", "42")]) == {}


def test_ignores_different_amounts():
    dups = duplicate_sets([charge("ch_1", "42", amount=5000), charge("ch_2", "42", amount=1000)])
    assert dups == {}


def test_ignores_refunded():
    dups = duplicate_sets([charge("ch_1", "42"), charge("ch_2", "42", refunded=True)])
    assert dups == {}


def test_keeps_recorded_charge():
    same = [charge("ch_1", "42", created=1), charge("ch_2", "42", created=2)]
    extras = choose_extras(same, "ch_2")
    assert [c["id"] for c in extras] == ["ch_1"]


def test_keeps_earliest_when_none_recorded():
    same = [charge("ch_1", "42", created=1), charge("ch_2", "42", created=2)]
    extras = choose_extras(same, None)
    assert [c["id"] for c in extras] == ["ch_2"]
duplicates.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { duplicateSets, chooseExtras } from "./refund-duplicates.js";

const charge = (id, orderId, over = {}) => ({
  id, amount: 5000, currency: "usd", created: 1, refunded: false,
  status: "succeeded", metadata: { order_id: orderId }, ...over,
});

test("detects two charges for the same order and amount", () => {
  const dups = duplicateSets([charge("ch_1", "42"), charge("ch_2", "42")]);
  assert.equal(dups.size, 1);
});

test("ignores a single charge", () => {
  assert.equal(duplicateSets([charge("ch_1", "42")]).size, 0);
});

test("ignores different amounts", () => {
  const dups = duplicateSets([charge("ch_1", "42"), charge("ch_2", "42", { amount: 1000 })]);
  assert.equal(dups.size, 0);
});

test("keeps the recorded charge", () => {
  const same = [charge("ch_1", "42", { created: 1 }), charge("ch_2", "42", { created: 2 })];
  assert.deepEqual(chooseExtras(same, "ch_2").map((c) => c.id), ["ch_1"]);
});

test("keeps the earliest when none recorded", () => {
  const same = [charge("ch_1", "42", { created: 1 }), charge("ch_2", "42", { created: 2 })];
  assert.deepEqual(chooseExtras(same, null).map((c) => c.id), ["ch_2"]);
});

Case studies

Slow network

The customer on hotel wifi

A buyer on a slow connection saw the page hang after paying, so the redirect was delayed. The webhook arrived first and made a charge, then the redirect finished and made a second one. The customer was billed twice within forty seconds.

The reconciler grouped both charges under the same order, kept the one recorded on the order, and refunded the extra. The customer got their money back before they had to ask.

Traffic spike

The launch that doubled a handful of orders

During a product launch, higher load widened the window where both paths could run. About one order in fifty picked up a second charge.

Run daily, the script caught each duplicate within a day, well before any of them turned into a bank dispute. The team also added the missing idempotency key at the code level so new duplicates stopped forming.

What good looks like

With this running daily, a double charge becomes a same day refund instead of a chargeback. Pair it with the idempotency fix at the checkout level so the duplicates stop being created, and keep the reconciler on as a safety net for the ones that still slip through.

FAQ

Why did Stripe charge my customer twice for one WooCommerce order?

Two paths can complete the same payment, the browser redirect after checkout and the Stripe webhook. If both run before the order is locked, each can create a charge. You end up with two succeeded charges that carry the same order_id in their metadata.

How do I find duplicate Stripe charges?

List recent charges from Stripe, group them by the order_id in their metadata, and flag any order that has more than one succeeded charge for the same amount. Those groups are your duplicates.

Which charge should I refund?

Keep the charge that is recorded on the WooCommerce order as its transaction ID, and refund the extra one. If neither is recorded, keep the earliest and refund the rest.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin issue: duplicate charge from the redirect and webhook race. github.com/woocommerce/woocommerce-gateway-stripe/issues/831
  2. WooCommerce Stripe plugin issue: missing idempotency key on the PaymentIntents path led to double charges. github.com/woocommerce/woocommerce-gateway-stripe/issues/2339
  3. Stripe docs: idempotent requests and why they prevent duplicate charges. docs.stripe.com/api/idempotent_requests

On the solution:

  1. Stripe API: list charges with a created filter and metadata. docs.stripe.com/api/charges/list
  2. Stripe API: create a refund with a reason of duplicate. docs.stripe.com/api/refunds/create
  3. WooCommerce REST API: read an order 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 double charge?

If this saved a customer from a double bill and you from a chargeback, 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