Repair Charges and money
Idempotency gap on PaymentIntents
A retry went out without an idempotency key, and Stripe had no way to know it was the same payment as the first one. It made a second charge. The customer paid twice, the order shows one payment, and nobody notices until the customer emails you about a strange amount on their card statement. Here is why the gap opens and a small script that finds every likely duplicate so you can review and refund it.
Stripe only collapses a repeated request into one charge when the retry carries the exact same Idempotency-Key as the first attempt. If a retry goes out with no key, or a different key, Stripe treats it as a brand new payment and can create a second PaymentIntent that also succeeds. Run a small Python or Node.js script on a schedule that reads the PaymentIntent id saved on each paid order, looks up the same Stripe customer, and lists any other succeeded PaymentIntent with a matching amount inside a short time window. Full code, tests, and a dry run guard are below.
The problem in plain words
Idempotency is the property that lets you send the same request twice and get the same result once. Stripe supports this with an Idempotency-Key header. If your checkout code sends that key with the payment request, and the request has to be retried, Stripe recognizes the key and hands back the original PaymentIntent instead of creating a new one.
The gap opens when a retry happens without that key attached, or with a new key generated for every attempt instead of one key per checkout attempt. A slow response makes the browser or the server think the first request failed, so it tries again. Stripe sees two unrelated requests, not one request repeated, and charges the card both times. WooCommerce only stores one PaymentIntent id on the order, usually in order meta _stripe_intent_id or as the order's transaction_id, so the second successful charge sits in Stripe with no order pointing back at it.
Why it happens
Stripe's own docs are direct about this: idempotency keys only protect you when the same key is reused for retries of the same operation. A few common ways the key gets dropped or changes between attempts:
- The checkout code generates a fresh key on every function call instead of once per checkout attempt, so a retry inside the same attempt still looks new to Stripe.
- A custom checkout flow calls the Payment Intents API directly and the idempotency header was simply never added to the request.
- The browser times out waiting for a response and the customer, or a background retry, resubmits the form, generating an entirely new request from scratch.
- A load balancer or proxy retries a request it thinks failed, without knowing an idempotency key exists or how to preserve it.
Stripe's guidance on this exact situation is that idempotency keys should be generated once per unique operation and reused for every retry of that operation, not regenerated per attempt. When that rule is not followed, the systems disagree quietly: Stripe has two successful charges, WooCommerce has one order and one saved payment id, and the second charge just sits there until someone goes looking.
You cannot go back and add an idempotency key to a request that already happened. The fix here is not prevention after the fact, it is detection. Stripe is still the source of truth for what was actually charged, so a script that asks Stripe "did this customer pay twice for the same amount around the same time" finds what the order alone cannot show you.
The fix, as a flow
We do not touch checkout. We add a job that runs on a schedule, walks recent paid orders, and for each one reads the PaymentIntent that WooCommerce saved. It then asks Stripe for every other succeeded PaymentIntent belonging to the same customer. If any of those match the order's amount and were created inside a short window of the saved one, it is a likely duplicate. The script reports it as an order note. It never issues a refund by itself, because a refund is real money and deserves a human to confirm the match first.
Build it step by step
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 access to orders, and write access if you want the script to leave notes. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.
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="7"
export MATCH_WINDOW_MINUTES="30"
export DRY_RUN="true" # start safe, change to false to write
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="7"
export MATCH_WINDOW_MINUTES="30"
export DRY_RUN="true" // start safe, change to false to write
Read the PaymentIntent id saved on the order
The WooCommerce Stripe plugin saves the PaymentIntent id in order meta as _stripe_intent_id. Older orders or other integrations sometimes save it as the order's transaction_id instead, prefixed pi_. Check both, and treat a transaction_id that starts with ch_ as a charge id, not a PaymentIntent id.
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
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;
}
Ask Stripe for the customer's other successful payments
Once you have the saved PaymentIntent, read its customer field and list every other succeeded PaymentIntent for that same customer inside your lookback window. This is the set of candidates that might be an accidental duplicate of the order's real payment.
import time, stripe
def other_succeeded_for_customer(customer_id, exclude_intent_id, lookback_days):
if not customer_id:
return []
since = int(time.time()) - lookback_days * 86400
results = []
for intent in stripe.PaymentIntent.list(
customer=customer_id, limit=100, created={"gte": since}
).auto_paging_iter():
if intent.id == exclude_intent_id:
continue
if intent.status == "succeeded":
results.append(intent)
return results
async function otherSucceededForCustomer(customerId, excludeIntentId, lookbackDays) {
if (!customerId) return [];
const since = Math.floor(Date.now() / 1000) - lookbackDays * 86400;
const results = [];
for await (const intent of stripe.paymentIntents.list({ customer: customerId, limit: 100, created: { gte: since } })) {
if (intent.id === excludeIntentId) continue;
if (intent.status === "succeeded") results.push(intent);
}
return results;
}
Decide, with one pure function
Keep the matching rule in its own function that takes the order's primary PaymentIntent, the list of other candidate intents, the order amount in cents, and a time window, then returns the ones that look like duplicates. A pure function like this is easy to read and easy to test, which we do later. A candidate only counts when it succeeded, is not the primary intent itself, matches the order amount, and was created close enough in time to the primary intent.
def order_amount_minor(order):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# have their own guide, since 50.00 is wrong for those.
return round(float(order["total"]) * 100)
def find_candidate_duplicates(primary_intent, other_intents, order_amount_minor_value, window_seconds):
"""Pure decision function. No I/O. Returns a list of (intent, reason) pairs."""
duplicates = []
if primary_intent is None or primary_intent.get("status") != "succeeded":
return duplicates
primary_created = primary_intent.get("created", 0)
for candidate in other_intents:
if candidate.get("id") == primary_intent.get("id"):
continue
if candidate.get("status") != "succeeded":
continue
if abs(candidate.get("amount_received", 0) - order_amount_minor_value) > 1:
continue
if abs(candidate.get("created", 0) - primary_created) > window_seconds:
continue
duplicates.append((candidate, "same customer, same amount, created within the match window"))
return duplicates
export function orderAmountMinor(order) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// have their own guide, since 50.00 is wrong for those.
return Math.round(parseFloat(order.total) * 100);
}
export function findCandidateDuplicates(primaryIntent, otherIntents, orderAmountMinorValue, windowSeconds) {
const duplicates = [];
if (!primaryIntent || primaryIntent.status !== "succeeded") return duplicates;
const primaryCreated = primaryIntent.created || 0;
for (const candidate of otherIntents) {
if (candidate.id === primaryIntent.id) continue;
if (candidate.status !== "succeeded") continue;
if (Math.abs((candidate.amount_received || 0) - orderAmountMinorValue) > 1) continue;
if (Math.abs((candidate.created || 0) - primaryCreated) > windowSeconds) continue;
duplicates.push([candidate, "same customer, same amount, created within the match window"]);
}
return duplicates;
}
Flag the order, do not refund automatically
When a duplicate is found, add an order note listing the extra PaymentIntent ids and why they were flagged. That gives the shop manager or support agent everything they need to open Stripe, confirm the second charge is really a duplicate, and issue a refund from there. The script never calls the refund API itself, because a false match here means refunding money a customer meant to spend on a second, real purchase.
def flag(order, duplicate_intent_ids):
note = (
"Possible duplicate charge detected. This order's saved PaymentIntent "
"succeeded, but Stripe also shows " + ", ".join(duplicate_intent_ids) +
" as succeeded for the same customer, amount, and time window. "
"This can happen when a retry goes out without an Idempotency-Key. "
"Please review in Stripe before refunding."
)
requests.post(
f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
json={"note": note},
auth=AUTH, timeout=30,
).raise_for_status()
async function flag(order, duplicateIntentIds) {
const note =
"Possible duplicate charge detected. This order's saved PaymentIntent " +
"succeeded, but Stripe also shows " + duplicateIntentIds.join(", ") +
" as succeeded for the same customer, amount, and time window. " +
"This can happen when a retry goes out without an Idempotency-Key. " +
"Please review in Stripe before refunding.";
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would flag. Read the output, confirm a couple of matches in the Stripe dashboard yourself, then switch it off to let it write notes. This job is cheap to run once a day, since duplicates from a stuck retry are rare and do not need minute by minute checking.
Always start with DRY_RUN=true. This script never issues a refund on its own, but it does write order notes once live, so you still want to see its plan before it acts. Tune MATCH_WINDOW_MINUTES to your own checkout, a shorter window means fewer false positives, a longer one catches slower retries.
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 flagging the same order twice just adds another note, it never issues a refund.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Find WooCommerce orders that were charged twice because a retry went out
without a Stripe Idempotency-Key.
A flaky network, a page refresh, or a double-click on "Place order" can send
the same checkout request twice. When neither request carries the same
Idempotency-Key, Stripe treats them as two different payments and can create
two separate PaymentIntents, both of which succeed. WooCommerce only stores
one PaymentIntent id on the order, so the extra charge is invisible unless you
go looking for it in Stripe.
This script reads the PaymentIntent id saved on each recent paid order (meta
_stripe_intent_id, falling back to transaction_id), looks up that intent's
Stripe Customer, and lists every other succeeded PaymentIntent created for
that same customer within a short window with the same amount. Anything it
finds beyond the one saved on the order is a likely duplicate charge.
Read only by default. Refunding is a separate, explicit step you take after
reviewing the report, never automatic.
"""
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("find_duplicate_intents")
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", "7"))
MATCH_WINDOW_MINUTES = int(os.environ.get("MATCH_WINDOW_MINUTES", "30"))
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 order_amount_minor(order):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# have their own guide, since 50.00 is wrong for those.
return round(float(order["total"]) * 100)
def find_candidate_duplicates(primary_intent, other_intents, order_amount_minor_value, window_seconds):
"""Pure decision function. No I/O.
primary_intent: the PaymentIntent whose id is saved on the order.
other_intents: every other succeeded PaymentIntent for the same customer,
as plain dicts with at least id, status, amount_received, created.
order_amount_minor_value: the order total in minor units (cents).
window_seconds: how close in time a second charge has to be to count.
Returns a list of (intent_dict, reason) tuples, one per likely duplicate.
An intent only counts as a duplicate when it succeeded, is not the
primary intent, matches the order amount, and was created within the
time window of the primary intent.
"""
duplicates = []
if primary_intent is None or primary_intent.get("status") != "succeeded":
return duplicates
primary_created = primary_intent.get("created", 0)
for candidate in other_intents:
if candidate.get("id") == primary_intent.get("id"):
continue
if candidate.get("status") != "succeeded":
continue
if abs(candidate.get("amount_received", 0) - order_amount_minor_value) > 1:
continue
if abs(candidate.get("created", 0) - primary_created) > window_seconds:
continue
duplicates.append((candidate, "same customer, same amount, created within the match window"))
return duplicates
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 other_succeeded_for_customer(customer_id, exclude_intent_id, lookback_days):
if not customer_id:
return []
since = int(__import__("time").time()) - lookback_days * 86400
results = []
for intent in stripe.PaymentIntent.list(
customer=customer_id, limit=100, created={"gte": since}
).auto_paging_iter():
if intent.id == exclude_intent_id:
continue
if intent.status == "succeeded":
results.append(intent)
return results
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 flag(order, duplicate_intent_ids):
note = (
"Possible duplicate charge detected. This order's saved PaymentIntent "
"succeeded, but Stripe also shows " + ", ".join(duplicate_intent_ids) +
" as succeeded for the same customer, amount, and time window. "
"This can happen when a retry goes out without an Idempotency-Key. "
"Please review in Stripe before refunding."
)
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():
flagged = 0
for order in paid_orders(LOOKBACK_DAYS):
if order["status"] not in PAID_STATUSES:
continue
primary = get_intent(intent_id_of(order))
if primary is None:
continue
customer_id = primary.get("customer")
others = other_succeeded_for_customer(customer_id, primary["id"], LOOKBACK_DAYS)
others_as_dicts = [dict(o) for o in others]
duplicates = find_candidate_duplicates(
dict(primary), others_as_dicts, order_amount_minor(order), MATCH_WINDOW_MINUTES * 60
)
if not duplicates:
continue
duplicate_ids = [d["id"] for d, _reason in duplicates]
log.warning(
"Order %s: %d likely duplicate charge(s) found: %s. %s",
order["id"], len(duplicate_ids), ", ".join(duplicate_ids),
"would flag" if DRY_RUN else "flagging",
)
if not DRY_RUN:
flag(order, duplicate_ids)
flagged += 1
log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Find WooCommerce orders that were charged twice because a retry went out
* without a Stripe Idempotency-Key.
*
* A flaky network, a page refresh, or a double-click on "Place order" can
* send the same checkout request twice. When neither request carries the
* same Idempotency-Key, Stripe treats them as two different payments and can
* create two separate PaymentIntents, both of which succeed. WooCommerce
* only stores one PaymentIntent id on the order, so the extra charge is
* invisible unless you go looking for it in Stripe.
*
* This script reads the PaymentIntent id saved on each recent paid order
* (meta _stripe_intent_id, falling back to transaction_id), looks up that
* intent's Stripe Customer, and lists every other succeeded PaymentIntent
* created for that same customer within a short window with the same
* amount. Anything it finds beyond the one saved on the order is a likely
* duplicate charge.
*
* Read only by default. Refunding is a separate, explicit step you take
* after reviewing the report, never automatic.
*/
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 || 7);
const MATCH_WINDOW_MINUTES = Number(process.env.MATCH_WINDOW_MINUTES || 30);
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;
}
export function orderAmountMinor(order) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// have their own guide, since 50.00 is wrong for those.
return Math.round(parseFloat(order.total) * 100);
}
/**
* Pure decision function. No I/O.
*
* primaryIntent: the PaymentIntent whose id is saved on the order.
* otherIntents: every other succeeded PaymentIntent for the same customer,
* as plain objects with at least id, status, amount_received, created.
* orderAmountMinorValue: the order total in minor units (cents).
* windowSeconds: how close in time a second charge has to be to count.
*
* Returns an array of [intent, reason] pairs, one per likely duplicate. An
* intent only counts as a duplicate when it succeeded, is not the primary
* intent, matches the order amount, and was created within the time window
* of the primary intent.
*/
export function findCandidateDuplicates(primaryIntent, otherIntents, orderAmountMinorValue, windowSeconds) {
const duplicates = [];
if (!primaryIntent || primaryIntent.status !== "succeeded") return duplicates;
const primaryCreated = primaryIntent.created || 0;
for (const candidate of otherIntents) {
if (candidate.id === primaryIntent.id) continue;
if (candidate.status !== "succeeded") continue;
if (Math.abs((candidate.amount_received || 0) - orderAmountMinorValue) > 1) continue;
if (Math.abs((candidate.created || 0) - primaryCreated) > windowSeconds) continue;
duplicates.push([candidate, "same customer, same amount, created within the match window"]);
}
return duplicates;
}
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 otherSucceededForCustomer(customerId, excludeIntentId, lookbackDays) {
if (!customerId) return [];
const since = Math.floor(Date.now() / 1000) - lookbackDays * 86400;
const results = [];
for await (const intent of stripe.paymentIntents.list({ customer: customerId, limit: 100, created: { gte: since } })) {
if (intent.id === excludeIntentId) continue;
if (intent.status === "succeeded") results.push(intent);
}
return results;
}
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 flag(order, duplicateIntentIds) {
const note =
"Possible duplicate charge detected. This order's saved PaymentIntent " +
"succeeded, but Stripe also shows " + duplicateIntentIds.join(", ") +
" as succeeded for the same customer, amount, and time window. " +
"This can happen when a retry goes out without an Idempotency-Key. " +
"Please review in Stripe before refunding.";
await woo(`/orders/${order.id}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
}
export async function run() {
let flagged = 0;
for await (const order of paidOrders(LOOKBACK_DAYS)) {
if (!PAID_STATUSES.has(order.status)) continue;
const primary = await getIntent(intentIdOf(order));
if (!primary) continue;
const others = await otherSucceededForCustomer(primary.customer, primary.id, LOOKBACK_DAYS);
const duplicates = findCandidateDuplicates(primary, others, orderAmountMinor(order), MATCH_WINDOW_MINUTES * 60);
if (!duplicates.length) continue;
const duplicateIds = duplicates.map(([intent]) => intent.id);
console.warn(
`Order ${order.id}: ${duplicateIds.length} likely duplicate charge(s) found: ${duplicateIds.join(", ")}. ` +
`${DRY_RUN ? "would flag" : "flagging"}`
);
if (!DRY_RUN) await flag(order, duplicateIds);
flagged++;
}
console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The matching rule is the part most worth testing, because it decides which charges get called out as possible duplicates. Because we kept find_candidate_duplicates pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks which ones come back.
from find_duplicate_intents import find_candidate_duplicates, intent_id_of, order_amount_minor
def intent(**over):
base = {"id": "pi_primary", "status": "succeeded", "amount_received": 5000, "created": 1_700_000_000}
base.update(over)
return base
def test_finds_duplicate_same_amount_same_window():
primary = intent()
other = intent(id="pi_retry", created=1_700_000_120)
result = find_candidate_duplicates(primary, [other], 5000, window_seconds=1800)
assert len(result) == 1
assert result[0][0]["id"] == "pi_retry"
def test_ignores_itself():
primary = intent()
result = find_candidate_duplicates(primary, [intent()], 5000, window_seconds=1800)
assert result == []
def test_ignores_non_succeeded_candidates():
primary = intent()
other = intent(id="pi_failed", status="requires_payment_method", created=1_700_000_60)
result = find_candidate_duplicates(primary, [other], 5000, window_seconds=1800)
assert result == []
def test_ignores_different_amount():
primary = intent()
other = intent(id="pi_other_amount", amount_received=1500, created=1_700_000_60)
result = find_candidate_duplicates(primary, [other], 5000, window_seconds=1800)
assert result == []
def test_ignores_outside_time_window():
primary = intent()
other = intent(id="pi_far_away", created=1_700_000_000 + 7200)
result = find_candidate_duplicates(primary, [other], 5000, window_seconds=1800)
assert result == []
def test_no_duplicates_when_primary_not_succeeded():
primary = intent(status="requires_payment_method")
other = intent(id="pi_retry", created=1_700_000_120)
result = find_candidate_duplicates(primary, [other], 5000, window_seconds=1800)
assert result == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findCandidateDuplicates, intentIdOf, orderAmountMinor } from "./find-duplicate-intents.js";
const intent = (over = {}) => ({ id: "pi_primary", status: "succeeded", amount_received: 5000, created: 1_700_000_000, ...over });
test("finds duplicate with same amount in same window", () => {
const primary = intent();
const other = intent({ id: "pi_retry", created: 1_700_000_120 });
const result = findCandidateDuplicates(primary, [other], 5000, 1800);
assert.equal(result.length, 1);
assert.equal(result[0][0].id, "pi_retry");
});
test("ignores itself", () => {
const primary = intent();
const result = findCandidateDuplicates(primary, [intent()], 5000, 1800);
assert.deepEqual(result, []);
});
test("ignores non succeeded candidates", () => {
const primary = intent();
const other = intent({ id: "pi_failed", status: "requires_payment_method", created: 1_700_000_060 });
const result = findCandidateDuplicates(primary, [other], 5000, 1800);
assert.deepEqual(result, []);
});
test("ignores different amount", () => {
const primary = intent();
const other = intent({ id: "pi_other_amount", amount_received: 1500, created: 1_700_000_060 });
const result = findCandidateDuplicates(primary, [other], 5000, 1800);
assert.deepEqual(result, []);
});
Case studies
The store on a shared host that timed out under load
A store on a busy shared host had a checkout page that occasionally took over thirty seconds to hear back from Stripe during a sale. The customer's browser gave up and auto-retried the form post, and the checkout code generated a new key for that retry instead of reusing the first one. Twelve customers were charged twice over one weekend before anyone noticed, because each order in WooCommerce still looked completely normal.
Running the script with a seven day lookback found all twelve in one pass. Each note listed the exact duplicate PaymentIntent id, so support could refund every one from the Stripe dashboard in an afternoon instead of waiting for customers to notice their statements.
The headless storefront that skipped the idempotency header entirely
A custom React storefront called the Payment Intents API directly from its own server, bypassing the plugin's built in handling, and the idempotency header had simply been left out during development. It worked fine in testing because nobody double-clicked the button on a fast connection. In production, a subset of mobile customers on slow connections tapped Pay twice, and each tap became its own successful charge.
The fix in the API code came later. In the meantime, the reconciler ran daily and caught the pattern early, at a handful of orders a week, which kept the support backlog from growing while the checkout code was corrected.
Once the idempotency header is generated correctly for every checkout attempt, this problem stops happening for new orders. Keep the script running daily anyway, since third party plugins, custom checkout code, and future integrations can all reintroduce the same gap without warning. A daily report of zero duplicates costs nothing and tells you the fix is holding.
FAQ
Why did my customer get charged twice on a single WooCommerce order?
A retry, from a flaky network, a page refresh, or a double-click on Place order, sent the same checkout request to Stripe twice without the same Idempotency-Key attached. Stripe had no way to tell the two requests were meant to be the same payment, so it created two separate PaymentIntents, and both succeeded.
What is a Stripe Idempotency-Key and why does it prevent this?
An Idempotency-Key is a unique value you attach to a request. If Stripe sees the same key again within its retention window, it returns the original result instead of creating a new charge. Without that key, every retry looks like a brand new payment, which is exactly how a duplicate charge gets made.
Is it safe to refund a duplicate charge automatically?
Automatic refunds are risky, because a false match means refunding a real, separate purchase. The safer pattern is to detect likely duplicates by customer, amount, and a short time window, write the finding to the order as a note, and let a human confirm in Stripe before the refund goes out.
Related field notes
Citations
On the problem:
- Stripe docs: idempotent requests, how keys are matched, and what happens when a key is missing or reused incorrectly. docs.stripe.com/api/idempotent_requests
- Stripe docs: designing an idempotency key strategy for payment retries. docs.stripe.com/error-handling
- WooCommerce Stripe plugin docs: how the PaymentIntent id is saved to the order and where to find it. woocommerce.com/document/stripe
On the solution:
- Stripe API: list PaymentIntents filtered by customer and creation date, with auto pagination. docs.stripe.com/api/payment_intents/list
- Stripe docs: issuing a refund for a PaymentIntent once a duplicate is confirmed. docs.stripe.com/refunds
- WooCommerce REST API: reading orders and adding 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.
Did this catch a duplicate charge for you?
If this saved a customer from an awkward refund conversation, 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