Repair Customers, cards, and tokens

Orphaned customers and cards: Stripe customers with no WooCommerce user behind them

Somewhere in your Stripe dashboard there are customer records with saved cards that no WooCommerce account claims any more. Somewhere in your WooCommerce database there are user accounts pointing at a Stripe customer id that Stripe no longer recognizes. Neither side notices on its own. Here is why the link between a WooCommerce user and its Stripe customer breaks, and a small script that finds every case, reconnects the ones that can be reconnected, and safely cleans up the ones that cannot.

Python and Node.js Runs on a schedule Safe by default (dry run)
A group of people standing in a circle
Photo by Joel Frank on Unsplash
The short answer

WooCommerce stores the Stripe customer id in user meta _stripe_customer_id. A deleted user, a bad import, or a customer merge can leave that meta pointing at nothing, or a Stripe customer that no WooCommerce user claims. Run a small Python or Node.js script that walks Stripe customers, checks each one against WooCommerce, and either reconnects a link that just moved, or reports (and, once you allow it, deletes) a Stripe customer that has no WooCommerce owner, no active subscription, and no saved card worth keeping. Full code, tests, and a dry run guard are below.

The problem in plain words

When someone checks out with Stripe, WooCommerce creates or reuses a Stripe customer object and saves its id on the WordPress user, in meta key _stripe_customer_id. That one field is the whole relationship. WooCommerce trusts it to find the right customer, and Stripe trusts its own metadata to say which WooCommerce user it belongs to.

That single pointer is fragile. Delete the WordPress user and the Stripe customer is still there, holding a saved card, with nobody on the WooCommerce side who remembers it exists. Merge two accounts during a cleanup and one of them loses its link while the other keeps a Stripe customer that may not even be the right one. Import a store from a backup and the meta table can come back incomplete. Either the WooCommerce user forgets who its Stripe customer is, or the Stripe customer becomes an orphan nobody in WooCommerce claims.

WooCommerce user saves _stripe_customer_id Stripe customer holds saved card user deleted or meta lost Customer orphaned no WooCommerce owner Card sits unclaimed
The Stripe customer and its saved card survive, but the WooCommerce side of the link is gone, so nothing on the store connects them any more.

Why it happens

The link is a single value on a single row, so anything that touches user data can quietly break it. A few common causes:

None of this shows up as an error anywhere. Stripe has no idea WooCommerce lost track of a customer, and WooCommerce has no idea a saved card is still sitting in Stripe with nobody using it. The mismatch is only visible if you check both sides against each other on purpose.

The key insight

The _stripe_customer_id meta on the WooCommerce user and the metadata.woo_customer_id on the Stripe customer are two ends of the same rope. When both sides agree, the link is healthy. When one end is missing or they point at different people, you have either a broken link that needs reconnecting, or a customer nobody claims that is safe to clean up once you confirm nothing important is still attached to it.

The fix, as a flow

We do not touch checkout or account pages. We add a job that lists Stripe customers created in a lookback window, looks up the matching WooCommerce user for each one, and checks whether the two sides agree. If Stripe metadata names a real WooCommerce user that the current link is missing, we reconnect it. If nobody claims the customer and it has no active subscription and no saved card, we flag it, and only delete it once you turn deletion on.

Scheduled job every day or week List Stripe customers (lookback window) Find matching WooCommerce user Linked, orphan, or broken? metadata names a user Reconnect save the correct id no owner, has a card or sub Keep, just report no owner, nothing attached Delete customer only if allowed
The job only reconnects a link when Stripe already names the right WooCommerce user, and only ever deletes a Stripe customer that has no owner, no active subscription, and no saved card.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to customers. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="90"
export DELETE_ABANDONED="false"   # true also deletes truly orphaned Stripe customers
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_DAYS="90"
export DELETE_ABANDONED="false"   // true also deletes truly orphaned Stripe customers
export DRY_RUN="true"   // start safe, change to false to write
2

List Stripe customers from the lookback window

Ask Stripe for customers created within your lookback window, and page through all of them. There is no need to scan your entire Stripe history every run, since a link that broke years ago and was never noticed can wait one more pass while you clear the recent backlog first.

step2.py
import os, time, stripe

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

def list_stripe_customers(lookback_days):
    since = int(time.time()) - lookback_days * 86400
    for customer in stripe.Customer.list(limit=100, created={"gte": since}).auto_paging_iter():
        yield customer
step2.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function* listStripeCustomers(lookbackDays) {
  const since = Math.floor(Date.now() / 1000) - lookbackDays * 86400;
  for await (const customer of stripe.customers.list({ limit: 100, created: { gte: since } })) {
    yield customer;
  }
}
3

Find the matching WooCommerce user

The WooCommerce REST API does not filter customers by an arbitrary meta value directly, so we search by the Stripe customer id as a text term and confirm the match ourselves by reading each candidate's meta_data. This keeps the lookup honest instead of trusting a loose text search to be exact.

step3.py
import requests
from requests.auth import HTTPBasicAuth

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

def find_woo_user_by_stripe_id(stripe_customer_id):
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/customers",
        params={"search": stripe_customer_id, "per_page": 10},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    for user in r.json():
        for meta in user.get("meta_data") or []:
            if meta.get("key") == "_stripe_customer_id" and meta.get("value") == stripe_customer_id:
                return user
    return None
step3.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function findWooUserByStripeId(stripeCustomerId) {
  const users = await woo(`/customers?search=${encodeURIComponent(stripeCustomerId)}&per_page=10`);
  for (const user of users) {
    for (const meta of user.meta_data || []) {
      if (meta.key === "_stripe_customer_id" && meta.value === stripeCustomerId) return user;
    }
  }
  return null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the Stripe customer and the matching WooCommerce user, plus two cheap facts about the customer, an active subscription and a saved card, gathered separately. A pure function like this is easy to read and easy to test. If both sides agree, do nothing. If Stripe metadata names a real user the link is missing, reconnect. If nobody claims the customer but it still has a subscription or a card, only report it. Otherwise, it is safe to clean up.

decide.py
def decide(customer, woo_user):
    if customer is None:
        return ("broken-link", "WooCommerce points to a Stripe customer id Stripe does not have")
    if customer.get("deleted"):
        return ("broken-link", "the Stripe customer behind this id was deleted")

    linked_woo_id = (customer.get("metadata") or {}).get("woo_customer_id")

    if woo_user is not None:
        if linked_woo_id and str(linked_woo_id) != str(woo_user["id"]):
            return ("reconnect", "Stripe metadata points to a different WooCommerce user")
        return ("ok", "Stripe customer and WooCommerce user agree")

    if linked_woo_id:
        return ("reconnect", "Stripe metadata names a WooCommerce user id that no longer exists")

    has_subscription = bool(customer.get("has_active_subscription"))
    has_payment_method = bool(customer.get("has_payment_method"))
    if has_subscription or has_payment_method:
        return ("keep", "no WooCommerce user, but a subscription or saved card is still attached")

    return ("orphan", "no WooCommerce user, no subscription, no saved payment method")
decide.js
export function decide(customer, wooUser) {
  if (!customer) {
    return ["broken-link", "WooCommerce points to a Stripe customer id Stripe does not have"];
  }
  if (customer.deleted) {
    return ["broken-link", "the Stripe customer behind this id was deleted"];
  }

  const linkedWooId = (customer.metadata || {}).woo_customer_id;

  if (wooUser) {
    if (linkedWooId && String(linkedWooId) !== String(wooUser.id)) {
      return ["reconnect", "Stripe metadata points to a different WooCommerce user"];
    }
    return ["ok", "Stripe customer and WooCommerce user agree"];
  }

  if (linkedWooId) {
    return ["reconnect", "Stripe metadata names a WooCommerce user id that no longer exists"];
  }

  const hasSubscription = Boolean(customer.has_active_subscription);
  const hasPaymentMethod = Boolean(customer.has_payment_method);
  if (hasSubscription || hasPaymentMethod) {
    return ["keep", "no WooCommerce user, but a subscription or saved card is still attached"];
  }

  return ["orphan", "no WooCommerce user, no subscription, no saved payment method"];
}
5

Reconnect or clean up

When the action is reconnect, write the correct id onto the WooCommerce user's _stripe_customer_id meta through the REST API. When the action is orphan, only delete the Stripe customer once DELETE_ABANDONED is turned on, and never touch one that still has a subscription or a card, since decide already routed those to keep instead.

apply.py
def reconnect(woo_user_id, stripe_customer_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/customers/{woo_user_id}",
        json={"meta_data": [{"key": "_stripe_customer_id", "value": stripe_customer_id}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def delete_stripe_customer(stripe_customer_id):
    stripe.Customer.delete(stripe_customer_id)
apply.js
async function reconnect(wooUserId, stripeCustomerId) {
  await woo(`/customers/${wooUserId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: "_stripe_customer_id", value: stripeCustomerId }] }),
  });
}

async function deleteStripeCustomer(stripeCustomerId) {
  await stripe.customers.del(stripeCustomerId);
}
6

Wire it together with a dry run guard

The loop ties every piece together. Leave DRY_RUN on for the first few runs so the script only reports what it would reconnect or delete. Leave DELETE_ABANDONED off even longer, since deleting a Stripe customer is the one step here that cannot be undone. Run it on a schedule, daily or weekly is enough, since this problem builds up slowly rather than in a burst.

Run it safe

Always start with DRY_RUN=true and DELETE_ABANDONED=false. Reconnecting a link is easy to reverse, since it is one meta value. Deleting a Stripe customer is not. Read a few weeks of reports before you ever let the script delete anything.

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 never deletes a Stripe customer that still has an active subscription or a saved payment method attached.

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

find_orphaned_customers.py
"""Find Stripe customers with no matching WooCommerce user behind them, and
WooCommerce users whose saved Stripe customer no longer exists.

A WooCommerce user stores the Stripe customer id in user meta
`_stripe_customer_id`. A deleted WordPress user, a database import, or a
customer merge can leave that link pointing at nothing, or pointing at a
Stripe customer that actually belongs to someone else now. Meanwhile Stripe
can be holding a customer object, and a saved card, that no WooCommerce user
ever claims. This walks both sides, decides what is wrong with a pure
function, and either reports it (dry run) or repairs it: reconnect a link
that just moved, or delete a Stripe customer that is genuinely abandoned and
has no subscriptions or payment methods worth keeping. Safe by default. Run
on a schedule.
"""
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_orphaned_customers")

stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
    os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
    os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "90"))
DELETE_ABANDONED = os.environ.get("DELETE_ABANDONED", "false").lower() == "true"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def decide(customer, woo_user):
    """Pure decision function. No I/O, no Stripe or WooCommerce calls inside.

    customer: a dict shaped like a Stripe Customer, or None if Stripe has no
              such customer (deleted or never existed).
    woo_user: a dict shaped like a WooCommerce customer record, or None if no
              WooCommerce user claims this Stripe customer id.

    Returns a (action, reason) tuple. Actions:
      "ok"          nothing wrong, the link is good.
      "reconnect"   the Stripe customer exists and metadata.woo_customer_id
                    names a real, different WooCommerce user. Point the
                    record at that user instead of deleting anything.
      "orphan"      the Stripe customer exists but no WooCommerce user claims
                    it, and it has no subscriptions and no saved payment
                    methods. Safe to delete once DELETE_ABANDONED is on.
      "keep"        the Stripe customer exists, no WooCommerce user claims
                    it, but it still has a subscription or a saved card, so
                    it is left alone and only reported.
      "broken-link" a WooCommerce user has a saved Stripe customer id that
                    Stripe does not recognize any more. Needs a human to
                    reconnect it to the right customer or clear the field.
    """
    if customer is None:
        return ("broken-link", "WooCommerce points to a Stripe customer id Stripe does not have")

    if customer.get("deleted"):
        return ("broken-link", "the Stripe customer behind this id was deleted")

    linked_woo_id = (customer.get("metadata") or {}).get("woo_customer_id")

    if woo_user is not None:
        if linked_woo_id and str(linked_woo_id) != str(woo_user["id"]):
            return ("reconnect", "Stripe metadata points to a different WooCommerce user")
        return ("ok", "Stripe customer and WooCommerce user agree")

    # No WooCommerce user claims this Stripe customer.
    if linked_woo_id:
        return ("reconnect", "Stripe metadata names a WooCommerce user id that no longer exists")

    has_subscription = bool(customer.get("has_active_subscription"))
    has_payment_method = bool(customer.get("has_payment_method"))
    if has_subscription or has_payment_method:
        return ("keep", "no WooCommerce user, but a subscription or saved card is still attached")

    return ("orphan", "no WooCommerce user, no subscription, no saved payment method")


def list_stripe_customers(lookback_days):
    """Stripe customers created in the lookback window, newest first."""
    import time
    since = int(time.time()) - lookback_days * 86400
    for customer in stripe.Customer.list(limit=100, created={"gte": since}).auto_paging_iter():
        yield customer


def enrich(customer):
    """Attach the two cheap-to-check facts decide() needs: an active
    subscription, or at least one saved payment method. Both come straight
    from the Stripe API, kept separate from decide() so decide() stays pure.
    """
    customer["has_active_subscription"] = bool(
        stripe.Subscription.list(customer=customer["id"], status="active", limit=1).data
    )
    customer["has_payment_method"] = bool(
        stripe.PaymentMethod.list(customer=customer["id"], type="card", limit=1).data
    )
    return customer


def find_woo_user_by_stripe_id(stripe_customer_id):
    """Look up the WooCommerce customer whose meta _stripe_customer_id matches.
    The WooCommerce REST API does not filter customers by arbitrary meta, so
    we search by the value and confirm the meta match ourselves rather than
    trusting the search to be exact.
    """
    r = requests.get(
        f"{WOO_URL}/wp-json/wc/v3/customers",
        params={"search": stripe_customer_id, "per_page": 10},
        auth=AUTH, timeout=30,
    )
    r.raise_for_status()
    for user in r.json():
        for meta in user.get("meta_data") or []:
            if meta.get("key") == "_stripe_customer_id" and meta.get("value") == stripe_customer_id:
                return user
    return None


def reconnect(woo_user_id, stripe_customer_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/customers/{woo_user_id}",
        json={"meta_data": [{"key": "_stripe_customer_id", "value": stripe_customer_id}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def delete_stripe_customer(stripe_customer_id):
    stripe.Customer.delete(stripe_customer_id)


def run():
    reconnected = 0
    deleted = 0
    flagged = 0
    for customer in list_stripe_customers(LOOKBACK_DAYS):
        stripe_customer_id = customer["id"]
        woo_user = find_woo_user_by_stripe_id(stripe_customer_id)
        enriched = enrich(dict(customer))
        action, reason = decide(enriched, woo_user)

        if action == "ok":
            continue

        if action == "keep":
            log.info("Customer %s: %s. Leaving it alone.", stripe_customer_id, reason)
            flagged += 1
            continue

        if action == "broken-link":
            log.warning("WooCommerce user pointing at %s is broken: %s", stripe_customer_id, reason)
            flagged += 1
            continue

        if action == "reconnect":
            target_id = (enriched.get("metadata") or {}).get("woo_customer_id")
            log.info(
                "Customer %s: %s. %s",
                stripe_customer_id, reason, "would reconnect" if DRY_RUN else "reconnecting",
            )
            if not DRY_RUN and target_id:
                reconnect(target_id, stripe_customer_id)
            reconnected += 1
            continue

        if action == "orphan":
            log.info(
                "Customer %s: %s. %s",
                stripe_customer_id, reason,
                "would delete" if (DRY_RUN or not DELETE_ABANDONED) else "deleting",
            )
            if not DRY_RUN and DELETE_ABANDONED:
                delete_stripe_customer(stripe_customer_id)
                deleted += 1
            else:
                flagged += 1

    log.info(
        "Done. %d reconnected, %d deleted, %d flagged for review.",
        reconnected, deleted, flagged,
    )


if __name__ == "__main__":
    run()
find-orphaned-customers.js
/**
 * Find Stripe customers with no matching WooCommerce user behind them, and
 * WooCommerce users whose saved Stripe customer no longer exists.
 *
 * A WooCommerce user stores the Stripe customer id in user meta
 * `_stripe_customer_id`. A deleted WordPress user, a database import, or a
 * customer merge can leave that link pointing at nothing, or pointing at a
 * Stripe customer that actually belongs to someone else now. Meanwhile
 * Stripe can be holding a customer object, and a saved card, that no
 * WooCommerce user ever claims. This walks both sides, decides what is
 * wrong with a pure function, and either reports it (dry run) or repairs
 * it: reconnect a link that just moved, or delete a Stripe customer that is
 * genuinely abandoned and has no subscriptions or payment methods worth
 * keeping. Safe by default. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/woocommerce/orphaned-customers-and-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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 90);
const DELETE_ABANDONED = (process.env.DELETE_ABANDONED || "false").toLowerCase() === "true";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function decide(customer, wooUser) {
  if (!customer) {
    return ["broken-link", "WooCommerce points to a Stripe customer id Stripe does not have"];
  }

  if (customer.deleted) {
    return ["broken-link", "the Stripe customer behind this id was deleted"];
  }

  const linkedWooId = (customer.metadata || {}).woo_customer_id;

  if (wooUser) {
    if (linkedWooId && String(linkedWooId) !== String(wooUser.id)) {
      return ["reconnect", "Stripe metadata points to a different WooCommerce user"];
    }
    return ["ok", "Stripe customer and WooCommerce user agree"];
  }

  // No WooCommerce user claims this Stripe customer.
  if (linkedWooId) {
    return ["reconnect", "Stripe metadata names a WooCommerce user id that no longer exists"];
  }

  const hasSubscription = Boolean(customer.has_active_subscription);
  const hasPaymentMethod = Boolean(customer.has_payment_method);
  if (hasSubscription || hasPaymentMethod) {
    return ["keep", "no WooCommerce user, but a subscription or saved card is still attached"];
  }

  return ["orphan", "no WooCommerce user, no subscription, no saved payment method"];
}

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* listStripeCustomers(lookbackDays) {
  const since = Math.floor(Date.now() / 1000) - lookbackDays * 86400;
  for await (const customer of stripe.customers.list({ limit: 100, created: { gte: since } })) {
    yield customer;
  }
}

async function enrich(customer) {
  const [subs, cards] = await Promise.all([
    stripe.subscriptions.list({ customer: customer.id, status: "active", limit: 1 }),
    stripe.paymentMethods.list({ customer: customer.id, type: "card", limit: 1 }),
  ]);
  return {
    ...customer,
    has_active_subscription: subs.data.length > 0,
    has_payment_method: cards.data.length > 0,
  };
}

async function findWooUserByStripeId(stripeCustomerId) {
  const users = await woo(`/customers?search=${encodeURIComponent(stripeCustomerId)}&per_page=10`);
  for (const user of users) {
    for (const meta of user.meta_data || []) {
      if (meta.key === "_stripe_customer_id" && meta.value === stripeCustomerId) return user;
    }
  }
  return null;
}

async function reconnect(wooUserId, stripeCustomerId) {
  await woo(`/customers/${wooUserId}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: "_stripe_customer_id", value: stripeCustomerId }] }),
  });
}

async function deleteStripeCustomer(stripeCustomerId) {
  await stripe.customers.del(stripeCustomerId);
}

export async function run() {
  let reconnected = 0;
  let deleted = 0;
  let flagged = 0;

  for await (const customer of listStripeCustomers(LOOKBACK_DAYS)) {
    const stripeCustomerId = customer.id;
    const wooUser = await findWooUserByStripeId(stripeCustomerId);
    const enriched = await enrich(customer);
    const [action, reason] = decide(enriched, wooUser);

    if (action === "ok") continue;

    if (action === "keep") {
      console.log(`Customer ${stripeCustomerId}: ${reason}. Leaving it alone.`);
      flagged++;
      continue;
    }

    if (action === "broken-link") {
      console.warn(`WooCommerce user pointing at ${stripeCustomerId} is broken: ${reason}`);
      flagged++;
      continue;
    }

    if (action === "reconnect") {
      const targetId = (enriched.metadata || {}).woo_customer_id;
      console.log(`Customer ${stripeCustomerId}: ${reason}. ${DRY_RUN ? "would reconnect" : "reconnecting"}`);
      if (!DRY_RUN && targetId) await reconnect(targetId, stripeCustomerId);
      reconnected++;
      continue;
    }

    if (action === "orphan") {
      const willDelete = !DRY_RUN && DELETE_ABANDONED;
      console.log(`Customer ${stripeCustomerId}: ${reason}. ${willDelete ? "deleting" : "would delete"}`);
      if (willDelete) {
        await deleteStripeCustomer(stripeCustomerId);
        deleted++;
      } else {
        flagged++;
      }
    }
  }

  console.log(`Done. ${reconnected} reconnected, ${deleted} deleted, ${flagged} flagged for review.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether a real Stripe customer gets deleted. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_orphaned_customer_decide.py
from find_orphaned_customers import decide


def customer(**over):
    base = {"id": "cus_1", "deleted": False, "metadata": {}, "has_active_subscription": False, "has_payment_method": False}
    base.update(over)
    return base


def woo_user(**over):
    base = {"id": 42, "email": "buyer@example.com"}
    base.update(over)
    return base


def test_ok_when_customer_and_user_agree():
    assert decide(customer(), woo_user())[0] == "ok"


def test_broken_link_when_customer_missing():
    assert decide(None, None)[0] == "broken-link"


def test_broken_link_when_customer_deleted():
    assert decide(customer(deleted=True), None)[0] == "broken-link"


def test_reconnect_when_metadata_points_elsewhere():
    action, _ = decide(customer(metadata={"woo_customer_id": "99"}), woo_user(id=42))
    assert action == "reconnect"


def test_reconnect_when_metadata_names_missing_user():
    action, _ = decide(customer(metadata={"woo_customer_id": "99"}), None)
    assert action == "reconnect"


def test_orphan_when_nothing_claims_it_and_nothing_attached():
    assert decide(customer(), None)[0] == "orphan"


def test_keep_when_orphan_has_active_subscription():
    action, _ = decide(customer(has_active_subscription=True), None)
    assert action == "keep"


def test_keep_when_orphan_has_saved_payment_method():
    action, _ = decide(customer(has_payment_method=True), None)
    assert action == "keep"
find-orphaned-customers.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./find-orphaned-customers.js";

const customer = (over = {}) => ({
  id: "cus_1",
  deleted: false,
  metadata: {},
  has_active_subscription: false,
  has_payment_method: false,
  ...over,
});

const wooUser = (over = {}) => ({ id: 42, email: "buyer@example.com", ...over });

test("ok when customer and user agree", () => {
  assert.equal(decide(customer(), wooUser())[0], "ok");
});

test("broken-link when customer missing", () => {
  assert.equal(decide(null, null)[0], "broken-link");
});

test("broken-link when customer deleted", () => {
  assert.equal(decide(customer({ deleted: true }), null)[0], "broken-link");
});

test("reconnect when metadata points elsewhere", () => {
  const [action] = decide(customer({ metadata: { woo_customer_id: "99" } }), wooUser({ id: 42 }));
  assert.equal(action, "reconnect");
});

test("reconnect when metadata names missing user", () => {
  const [action] = decide(customer({ metadata: { woo_customer_id: "99" } }), null);
  assert.equal(action, "reconnect");
});

test("orphan when nothing claims it and nothing attached", () => {
  assert.equal(decide(customer(), null)[0], "orphan");
});

test("keep when orphan has active subscription", () => {
  const [action] = decide(customer({ has_active_subscription: true }), null);
  assert.equal(action, "keep");
});

test("keep when orphan has saved payment method", () => {
  const [action] = decide(customer({ has_payment_method: true }), null);
  assert.equal(action, "keep");
});

Case studies

Account cleanup

The spam sweep that orphaned real customers

A store ran a cleanup that deleted a batch of inactive WordPress accounts, including a handful that were not spam at all, just customers who had not ordered in a while. Their Stripe customers, some with saved cards, were left with no WooCommerce owner.

The script found eleven orphaned customers in the next scheduled run. None had an active subscription, and only two had a saved card still worth keeping, so those two were flagged and kept while the rest were reported for review before any deletion was allowed.

Database restore

The backup that dropped the meta table

After restoring from an older backup to recover from an unrelated issue, a developer noticed that some returning customers were being asked to re-enter their card at checkout. The _stripe_customer_id meta on their accounts had reverted to a value from before their most recent Stripe customer was created.

Running the script in dry run mode surfaced every case where Stripe metadata still named the correct WooCommerce user. Turning off dry run reconnected each one automatically, and returning customers saw their saved cards again on their next visit.

What good looks like

After this runs on a schedule, a broken link between a WooCommerce user and its Stripe customer gets fixed within a day or a week instead of surfacing months later as a support ticket about a missing saved card. Genuinely abandoned Stripe customers stop quietly accumulating, and nothing with an active subscription or a saved card is ever touched.

FAQ

What is an orphaned Stripe customer in WooCommerce?

It is a Stripe customer object, often with a saved card attached, that no WooCommerce user account points to any more. It usually happens after a WordPress user is deleted, a database import drops user meta, or two customer accounts are merged and only one keeps the link.

Is it safe to delete an orphaned Stripe customer?

Only after checking that it has no active subscription and no saved payment method still worth keeping. The script checks both before it ever deletes anything, and defaults to reporting instead of deleting until you turn that on.

How do I reconnect a WooCommerce user to the right Stripe customer?

Update the user meta _stripe_customer_id through the WooCommerce REST API so it points at the Stripe customer id that actually belongs to that account. The script does this automatically when Stripe metadata already names the correct WooCommerce user.

Related field notes

Citations

On the problem:

  1. WooCommerce Stripe plugin docs: how the gateway saves and reuses the Stripe customer id on a WordPress user. woocommerce.com/document/stripe
  2. Stripe docs: the Customer object and how metadata is used to store your own reference ids. docs.stripe.com/api/customers/object
  3. WordPress developer docs: deleting a user does not delete data your plugins stored elsewhere, such as a Stripe customer. developer.wordpress.org/reference/functions/wp_delete_user

On the solution:

  1. Stripe API: list customers with a created filter and auto pagination. docs.stripe.com/api/customers/list
  2. Stripe API: list a customer's subscriptions and payment methods before deleting the customer. docs.stripe.com/api/payment_methods/customer_list
  3. WooCommerce REST API: update a customer and its meta_data fields. 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 your customer list?

If this found a broken link or a batch of abandoned Stripe customers you did not know about, 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