Repair Customers, cards, and tokens

Duplicate saved cards on one WooCommerce customer's Stripe profile

A customer opens their account page to update a payment method and finds the same Visa ending in 4242 listed three times. Nothing is broken from Stripe's point of view. Each one is a separate, valid PaymentMethod. But the customer is confused, the renewal picker is cluttered, and support has to guess which copy the next subscription payment will actually use. Here is why the same card keeps getting saved more than once and a small script that finds every duplicate and keeps just one.

Python and Node.js Runs on a schedule Safe by default (dry run)
An open bifold wallet
Photo by Emil Kalibradov on Unsplash
The short answer

Stripe creates a brand new PaymentMethod every time a card is saved, even if the exact same card is already on file, so a retried checkout or a re-added card during a plan change leaves duplicates behind. Run a small Python or Node.js job on a schedule that lists every saved card on each Stripe customer, groups them by card fingerprint, keeps the one an active subscription is actually using to renew (or the newest one if none is in use), and detaches the rest. Full code, tests, and a dry run guard are below.

The problem in plain words

When a shopper saves a card, whether at checkout, in the WooCommerce account page, or in a payment update flow, the store asks Stripe to create a PaymentMethod and attach it to that customer. Stripe does exactly what it is asked. It does not first check whether that same card, same number, same expiry, is already sitting on the customer from an earlier visit.

So the card goes on twice, three times, sometimes more. Each copy has a different PaymentMethod id, but underneath, the actual card is identical. WooCommerce shows all of them in "my payment methods." A subscription renewal picker lists all of them. Nobody merges them, because from Stripe's side there is nothing to merge, just several separate objects that happen to describe the same card.

Checkout saves Visa ••4242 Plan change saves Visa ••4242 again Account page saves Visa ••4242 again No dedupe check Stripe just creates a new PaymentMethod pm_1 ••4242 pm_2 ••4242 pm_3 ••4242 one customer
Every save creates a separate PaymentMethod. Nothing tells Stripe that all three describe the same physical card.

Why it happens

Stripe's own PaymentMethod docs are direct about this. Creating a PaymentMethod always makes a new object, and attaching it to a customer never checks for an existing match. The duplication is a natural side effect of a few common WooCommerce flows:

None of this fails loudly. Every save succeeds, every charge on any of the copies would work fine, so nothing alerts anyone that duplicates are piling up. It just quietly clutters the customer's card list until someone opens the account page and asks why the same card is there three times.

The key insight

A duplicate is not defined by the PaymentMethod id, since that is different every time. It is defined by Stripe's card fingerprint, a value Stripe computes from the card number and expiry that stays identical across every PaymentMethod created for that same card. Group by fingerprint, not by id, and the duplicates are obvious. The one exception that matters is any card an active subscription is still renewing with, which must never be the one removed, no matter how old it is.

The fix, as a flow

We add a job that runs on a schedule, walks every WooCommerce customer that has a Stripe customer id saved, and lists their saved cards from Stripe. It groups those cards by fingerprint. Any group with more than one card is a set of duplicates. It checks which of those PaymentMethod ids, if any, an active subscription is currently using to renew, keeps that one (or the newest card if none are in use), and detaches the rest.

Scheduled job once a day List saved cards per Stripe customer Group by fingerprint same card, different ids More than one copy? yes no, leave alone Keep the one in use or the newest detach the rest
The job only acts inside a confirmed group of duplicates. Anything a live subscription still relies on is always kept.

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 access to customers and subscriptions, plus permission to detach a Stripe PaymentMethod. 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 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 DRY_RUN="true"   // start safe, change to false to write
2

Walk WooCommerce customers with a Stripe customer id

Page through WooCommerce customers and keep the ones that have a saved _stripe_customer_id in their meta data. That id is what lets us ask Stripe for every card attached to that person.

step2.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 woo_customers_with_stripe_id():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/customers",
            params={"per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for customer in batch:
            stripe_id = next(
                (m["value"] for m in customer.get("meta_data") or []
                 if m.get("key") == "_stripe_customer_id" and m.get("value")),
                None,
            )
            if stripe_id:
                yield customer["id"], stripe_id
        page += 1
step2.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* wooCustomersWithStripeId() {
  let page = 1;
  while (true) {
    const batch = await woo(`/customers?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const customer of batch) {
      const meta = (customer.meta_data || []).find(
        (m) => m.key === "_stripe_customer_id" && m.value
      );
      if (meta) yield [customer.id, meta.value];
    }
    page++;
  }
}
3

List the customer's saved cards and group them by fingerprint

Ask Stripe for every card type PaymentMethod attached to the customer, then group them by card.fingerprint. Any group with only one entry is not a duplicate and gets skipped. Any group with two or more is a set of the same physical card saved multiple times.

step3.py
import stripe

def saved_cards(customer_id):
    return stripe.PaymentMethod.list(customer=customer_id, type="card").auto_paging_iter()

def group_by_fingerprint(payment_methods):
    groups = {}
    for pm in payment_methods:
        card = pm.get("card") or {}
        fingerprint = card.get("fingerprint")
        if not fingerprint:
            continue
        groups.setdefault(fingerprint, []).append(pm)
    return groups
step3.js
async function savedCards(customerId) {
  const cards = [];
  for await (const pm of stripe.paymentMethods.list({ customer: customerId, type: "card" })) {
    cards.push(pm);
  }
  return cards;
}

export function groupByFingerprint(paymentMethods) {
  const groups = new Map();
  for (const pm of paymentMethods) {
    const fingerprint = pm.card && pm.card.fingerprint;
    if (!fingerprint) continue;
    if (!groups.has(fingerprint)) groups.set(fingerprint, []);
    groups.get(fingerprint).push(pm);
  }
  return groups;
}
4

Find which duplicate, if any, is still in use

Before deciding anything, read every active or on-hold WooCommerce Subscription for that customer and collect the Stripe PaymentMethod ids saved on them in meta _stripe_source_id. A card in this set is renewing something right now and must never be detached, no matter how old it is.

step4.py
def tokens_in_use(customer_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions",
        params={"customer": customer_id, "status": "active,on-hold", "per_page": 100},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    used = set()
    for sub in r.json():
        for meta in sub.get("meta_data") or []:
            if meta.get("key") == "_stripe_source_id" and meta.get("value"):
                used.add(meta["value"])
    return used
step4.js
export async function tokensInUse(customerId) {
  const subs = await woo(`/subscriptions?customer=${customerId}&status=active,on-hold&per_page=100`);
  const used = new Set();
  for (const sub of subs) {
    for (const meta of sub.meta_data || []) {
      if (meta.key === "_stripe_source_id" && meta.value) used.add(meta.value);
    }
  }
  return used;
}
5

Decide, with one pure function

Keep the decision in its own function that takes one fingerprint group and the set of PaymentMethod ids in use, and returns what to do with each id. A single card is left alone. Among duplicates, any card already wired to an active subscription is always kept. If more than one is in use, a rare split subscription setup, keep all of those. If none are in use, keep the newest card, since that is the one the customer most likely intended to keep, and detach the rest.

decide.py
def decide(group, used_token_ids):
    if len(group) < 2:
        return {pm["id"]: "keep" for pm in group}

    in_use = [pm for pm in group if pm["id"] in used_token_ids]
    if in_use:
        keep_ids = {pm["id"] for pm in in_use}
    else:
        newest = max(group, key=lambda pm: pm.get("created", 0))
        keep_ids = {newest["id"]}

    return {pm["id"]: ("keep" if pm["id"] in keep_ids else "detach") for pm in group}
decide.js
export function decide(group, usedTokenIds) {
  if (group.length < 2) {
    return new Map(group.map((pm) => [pm.id, "keep"]));
  }

  const inUse = group.filter((pm) => usedTokenIds.has(pm.id));
  let keepIds;
  if (inUse.length > 0) {
    keepIds = new Set(inUse.map((pm) => pm.id));
  } else {
    const newest = group.reduce((a, b) => ((b.created || 0) > (a.created || 0) ? b : a));
    keepIds = new Set([newest.id]);
  }

  return new Map(group.map((pm) => [pm.id, keepIds.has(pm.id) ? "keep" : "detach"]));
}
6

Wire it together with a dry run guard

The loop ties every piece together, one customer at a time. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which PaymentMethods it would detach. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron once a day, since duplicate cards accumulate slowly.

Run it safe

Always start with DRY_RUN=true. Detaching a PaymentMethod removes it from the customer for good, so you want to see the exact list before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it never touches a card that is the only copy or a card an active subscription still relies on.

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

dedupe_saved_cards.py
"""Find WooCommerce customers with the same card saved more than once on Stripe.

A retried checkout, a re-added card during a plan upgrade, or a customer portal
session can all attach a fresh Stripe PaymentMethod for a card that the customer
already has on file. Stripe never merges these for you, so the same card sits on
the customer two, three, sometimes five times. This walks each customer's saved
cards, groups them by card fingerprint, keeps the one WooCommerce actually uses
for renewals (or the newest one if none is in use), and detaches the rest.
Read only by default. Run on a schedule.

Guide: https://www.allanninal.dev/woocommerce/duplicate-saved-cards/
"""
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("dedupe_saved_cards")

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


def tokens_in_use(customer_id):
    """Every Stripe PaymentMethod id this WooCommerce customer's active
    subscriptions rely on for renewals. These are never candidates for removal.
    """
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions",
        params={"customer": customer_id, "status": "active,on-hold", "per_page": 100},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    used = set()
    for sub in r.json():
        for meta in sub.get("meta_data") or []:
            if meta.get("key") == "_stripe_source_id" and meta.get("value"):
                used.add(meta["value"])
    return used


def group_by_fingerprint(payment_methods):
    """Group a customer's saved cards by Stripe's card fingerprint. Two
    PaymentMethod objects that share a fingerprint are the same physical card,
    regardless of how many times it was re-added.
    """
    groups = {}
    for pm in payment_methods:
        card = pm.get("card") or {}
        fingerprint = card.get("fingerprint")
        if not fingerprint:
            continue
        groups.setdefault(fingerprint, []).append(pm)
    return groups


def decide(group, used_token_ids):
    """Given every saved card that shares one fingerprint, decide what to do
    with each PaymentMethod id. Returns a dict of payment_method_id -> action,
    where action is "keep" or "detach". Pure: no I/O, no Stripe or Woo calls.

    Rule: a single card is left alone. Among duplicates, any card already
    wired to an active subscription is always kept, never detached, even if
    it is not the newest. If more than one duplicate is in use (a rare split
    subscription setup), keep all of those and only detach the unused ones.
    If none are in use, keep the most recently created card and detach the
    rest, since the newest one is the one the customer most likely intended
    to keep.
    """
    if len(group) < 2:
        return {pm["id"]: "keep" for pm in group}

    in_use = [pm for pm in group if pm["id"] in used_token_ids]
    if in_use:
        keep_ids = {pm["id"] for pm in in_use}
    else:
        newest = max(group, key=lambda pm: pm.get("created", 0))
        keep_ids = {newest["id"]}

    return {pm["id"]: ("keep" if pm["id"] in keep_ids else "detach") for pm in group}


def saved_cards(customer_id):
    return stripe.PaymentMethod.list(customer=customer_id, type="card").auto_paging_iter()


def detach(payment_method_id):
    stripe.PaymentMethod.detach(payment_method_id)


def woo_customers_with_stripe_id():
    """Every WooCommerce customer that has a Stripe customer id saved, paging
    through the REST API.
    """
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/customers",
            params={"per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for customer in batch:
            stripe_id = next(
                (m["value"] for m in customer.get("meta_data") or []
                 if m.get("key") == "_stripe_customer_id" and m.get("value")),
                None,
            )
            if stripe_id:
                yield customer["id"], stripe_id
        page += 1


def run():
    detached = 0
    for woo_customer_id, stripe_customer_id in woo_customers_with_stripe_id():
        methods = list(saved_cards(stripe_customer_id))
        groups = group_by_fingerprint(methods)
        used = tokens_in_use(woo_customer_id)
        for fingerprint, group in groups.items():
            if len(group) < 2:
                continue
            actions = decide(group, used)
            for pm_id, action in actions.items():
                if action != "detach":
                    continue
                log.info(
                    "Customer %s: duplicate card %s (fingerprint %s...). %s",
                    woo_customer_id, pm_id, fingerprint[:8],
                    "would detach" if DRY_RUN else "detaching",
                )
                if not DRY_RUN:
                    detach(pm_id)
                detached += 1
    log.info("Done. %d duplicate card(s) %s.", detached, "to detach" if DRY_RUN else "detached")


if __name__ == "__main__":
    run()
dedupe-saved-cards.js
/**
 * Find WooCommerce customers with the same card saved more than once on Stripe.
 *
 * A retried checkout, a re-added card during a plan upgrade, or a customer portal
 * session can all attach a fresh Stripe PaymentMethod for a card the customer
 * already has on file. Stripe never merges these for you, so the same card sits
 * on the customer two, three, sometimes five times. This walks each customer's
 * saved cards, groups them by card fingerprint, keeps the one WooCommerce
 * actually uses for renewals (or the newest one if none is in use), and detaches
 * the rest. Read only by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/duplicate-saved-cards/
 */
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 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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

/**
 * Every Stripe PaymentMethod id this WooCommerce customer's active
 * subscriptions rely on for renewals. These are never candidates for removal.
 */
export async function tokensInUse(customerId) {
  const subs = await woo(`/subscriptions?customer=${customerId}&status=active,on-hold&per_page=100`);
  const used = new Set();
  for (const sub of subs) {
    for (const meta of sub.meta_data || []) {
      if (meta.key === "_stripe_source_id" && meta.value) used.add(meta.value);
    }
  }
  return used;
}

/**
 * Group a customer's saved cards by Stripe's card fingerprint. Two
 * PaymentMethod objects that share a fingerprint are the same physical card,
 * regardless of how many times it was re-added.
 */
export function groupByFingerprint(paymentMethods) {
  const groups = new Map();
  for (const pm of paymentMethods) {
    const fingerprint = pm.card && pm.card.fingerprint;
    if (!fingerprint) continue;
    if (!groups.has(fingerprint)) groups.set(fingerprint, []);
    groups.get(fingerprint).push(pm);
  }
  return groups;
}

/**
 * Given every saved card that shares one fingerprint, decide what to do with
 * each PaymentMethod id. Returns a Map of payment_method_id -> action, where
 * action is "keep" or "detach". Pure: no I/O, no Stripe or Woo calls.
 *
 * Rule: a single card is left alone. Among duplicates, any card already wired
 * to an active subscription is always kept, never detached, even if it is not
 * the newest. If more than one duplicate is in use (a rare split subscription
 * setup), keep all of those and only detach the unused ones. If none are in
 * use, keep the most recently created card and detach the rest, since the
 * newest one is the one the customer most likely intended to keep.
 */
export function decide(group, usedTokenIds) {
  if (group.length < 2) {
    return new Map(group.map((pm) => [pm.id, "keep"]));
  }

  const inUse = group.filter((pm) => usedTokenIds.has(pm.id));
  let keepIds;
  if (inUse.length > 0) {
    keepIds = new Set(inUse.map((pm) => pm.id));
  } else {
    const newest = group.reduce((a, b) => ((b.created || 0) > (a.created || 0) ? b : a));
    keepIds = new Set([newest.id]);
  }

  return new Map(group.map((pm) => [pm.id, keepIds.has(pm.id) ? "keep" : "detach"]));
}

async function savedCards(customerId) {
  const cards = [];
  for await (const pm of stripe.paymentMethods.list({ customer: customerId, type: "card" })) {
    cards.push(pm);
  }
  return cards;
}

async function detach(paymentMethodId) {
  await stripe.paymentMethods.detach(paymentMethodId);
}

/**
 * Every WooCommerce customer that has a Stripe customer id saved, paging
 * through the REST API.
 */
async function* wooCustomersWithStripeId() {
  let page = 1;
  while (true) {
    const batch = await woo(`/customers?per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const customer of batch) {
      const meta = (customer.meta_data || []).find(
        (m) => m.key === "_stripe_customer_id" && m.value
      );
      if (meta) yield [customer.id, meta.value];
    }
    page++;
  }
}

export async function run() {
  let detached = 0;
  for await (const [wooCustomerId, stripeCustomerId] of wooCustomersWithStripeId()) {
    const methods = await savedCards(stripeCustomerId);
    const groups = groupByFingerprint(methods);
    const used = await tokensInUse(wooCustomerId);
    for (const [fingerprint, group] of groups) {
      if (group.length < 2) continue;
      const actions = decide(group, used);
      for (const [pmId, action] of actions) {
        if (action !== "detach") continue;
        console.log(
          `Customer ${wooCustomerId}: duplicate card ${pmId} (fingerprint ${fingerprint.slice(0, 8)}...). ` +
          `${DRY_RUN ? "would detach" : "detaching"}`
        );
        if (!DRY_RUN) await detach(pmId);
        detached++;
      }
    }
  }
  console.log(`Done. ${detached} duplicate card(s) ${DRY_RUN ? "to detach" : "detached"}.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides which of a customer's real saved cards get removed. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action for each PaymentMethod id.

test_duplicate_decide.py
from dedupe_saved_cards import decide, group_by_fingerprint


def pm(id_, fingerprint="fp_abc", created=1000):
    return {"id": id_, "created": created, "card": {"fingerprint": fingerprint}}


def test_single_card_is_kept():
    group = [pm("pm_1")]
    assert decide(group, set()) == {"pm_1": "keep"}


def test_duplicates_keep_newest_when_none_in_use():
    group = [pm("pm_1", created=1000), pm("pm_2", created=2000), pm("pm_3", created=1500)]
    result = decide(group, set())
    assert result == {"pm_1": "detach", "pm_2": "keep", "pm_3": "detach"}


def test_duplicates_keep_the_one_used_by_a_subscription():
    group = [pm("pm_1", created=1000), pm("pm_2", created=2000)]
    # pm_1 is older but a live subscription still points at it, so it wins.
    result = decide(group, {"pm_1"})
    assert result == {"pm_1": "keep", "pm_2": "detach"}


def test_multiple_in_use_are_all_kept():
    group = [pm("pm_1"), pm("pm_2"), pm("pm_3")]
    result = decide(group, {"pm_1", "pm_2"})
    assert result == {"pm_1": "keep", "pm_2": "keep", "pm_3": "detach"}


def test_group_by_fingerprint_splits_different_cards():
    methods = [pm("pm_1", fingerprint="fp_a"), pm("pm_2", fingerprint="fp_b"), pm("pm_3", fingerprint="fp_a")]
    groups = group_by_fingerprint(methods)
    assert set(groups.keys()) == {"fp_a", "fp_b"}
    assert {m["id"] for m in groups["fp_a"]} == {"pm_1", "pm_3"}
    assert {m["id"] for m in groups["fp_b"]} == {"pm_2"}


def test_group_by_fingerprint_skips_methods_without_one():
    methods = [{"id": "pm_1", "card": {}}, pm("pm_2")]
    groups = group_by_fingerprint(methods)
    assert list(groups.keys()) == ["fp_abc"]
dedupe-saved-cards.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, groupByFingerprint } from "./dedupe-saved-cards.js";

const pm = (id, fingerprint = "fp_abc", created = 1000) => ({ id, created, card: { fingerprint } });

test("single card is kept", () => {
  const result = decide([pm("pm_1")], new Set());
  assert.deepEqual([...result], [["pm_1", "keep"]]);
});

test("duplicates keep newest when none in use", () => {
  const group = [pm("pm_1", "fp_abc", 1000), pm("pm_2", "fp_abc", 2000), pm("pm_3", "fp_abc", 1500)];
  const result = decide(group, new Set());
  assert.equal(result.get("pm_1"), "detach");
  assert.equal(result.get("pm_2"), "keep");
  assert.equal(result.get("pm_3"), "detach");
});

test("duplicates keep the one used by a subscription", () => {
  const group = [pm("pm_1", "fp_abc", 1000), pm("pm_2", "fp_abc", 2000)];
  const result = decide(group, new Set(["pm_1"]));
  assert.equal(result.get("pm_1"), "keep");
  assert.equal(result.get("pm_2"), "detach");
});

test("multiple in use are all kept", () => {
  const group = [pm("pm_1"), pm("pm_2"), pm("pm_3")];
  const result = decide(group, new Set(["pm_1", "pm_2"]));
  assert.equal(result.get("pm_1"), "keep");
  assert.equal(result.get("pm_2"), "keep");
  assert.equal(result.get("pm_3"), "detach");
});

test("groupByFingerprint splits different cards", () => {
  const methods = [pm("pm_1", "fp_a"), pm("pm_2", "fp_b"), pm("pm_3", "fp_a")];
  const groups = groupByFingerprint(methods);
  assert.deepEqual([...groups.keys()].sort(), ["fp_a", "fp_b"]);
  assert.deepEqual(groups.get("fp_a").map((m) => m.id).sort(), ["pm_1", "pm_3"]);
  assert.deepEqual(groups.get("fp_b").map((m) => m.id), ["pm_2"]);
});

test("groupByFingerprint skips methods without one", () => {
  const methods = [{ id: "pm_1", card: {} }, pm("pm_2")];
  const groups = groupByFingerprint(methods);
  assert.deepEqual([...groups.keys()], ["fp_abc"]);
});

Case studies

Plan upgrade flow

The upgrade flow that kept asking for a card

A store's plan upgrade page always showed a payment field, even for customers who already had a card on file, because the flow was built for new customers and never checked for an existing PaymentMethod. Regular upgraders ended up with the same card saved three or four times over a year.

Running the job in dry run surfaced a clear list of duplicate fingerprints per customer. Once switched to write mode, it quietly detached the unused copies overnight, and the team fixed the upgrade page to reuse an existing card afterward.

Support confusion

The refund that almost went to the wrong copy

A support agent tried to help a customer update their card and, seeing three identical entries, was not sure which one the active subscription actually used. They nearly detached the one still tied to a live renewal.

After the dedupe job ran, each customer had exactly one saved card per fingerprint, and it was always the one wired to the subscription in use, since the script never removes a card an active subscription depends on.

What good looks like

After this runs on a schedule, a customer's payment methods page shows each real card exactly once, support never has to guess which duplicate is the one that matters, and no active subscription is put at risk, because the script never detaches a card that is still renewing something. Keep it running, since new duplicates will keep forming as long as checkout and account flows can re-save an existing card.

FAQ

Why does the same card show up more than once on a customer's saved payment methods?

Every time a card is entered at checkout, in the customer's account page, or in a payment update flow, Stripe can create a brand new PaymentMethod object for it. Stripe never checks whether that exact card is already saved on the customer, so a retried checkout or a re-added card during a plan change quietly adds a duplicate instead of reusing the one that is already there.

How do you tell that two saved cards are actually the same physical card?

Compare the card fingerprint that Stripe attaches to every PaymentMethod, not the card id. The fingerprint is the same for a given card number and expiry no matter how many times it gets saved, while the PaymentMethod id is different every time, so grouping by fingerprint is the reliable way to spot duplicates.

Is it safe to detach a customer's saved card automatically?

Yes, as long as the script never detaches a card that an active subscription is still using to renew, and it only acts within a group of confirmed duplicates of the same fingerprint. Start in dry run mode to review exactly which PaymentMethods would be detached before it writes anything.

Related field notes

Citations

On the problem:

  1. Stripe docs: the PaymentMethod object, including how card.fingerprint identifies the same physical card across separate objects. docs.stripe.com/api/payment_methods/object
  2. Stripe docs: saving a card for future payments, including how each save creates a new PaymentMethod. docs.stripe.com/payments/save-and-reuse
  3. WooCommerce Subscriptions docs: how a subscription stores the payment token it renews with. woocommerce.com/document/subscriptions

On the solution:

  1. Stripe API: list a customer's saved PaymentMethods with auto pagination. docs.stripe.com/api/payment_methods/list
  2. Stripe API: detach a PaymentMethod from a customer. docs.stripe.com/api/payment_methods/detach
  3. WooCommerce REST API: list customers and list subscriptions. 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 clean up a customer's cards for you?

If this cleared out a pile of duplicate PaymentMethods or saved a support ticket, 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