Repair Sources to PaymentMethods and SCA

Attach the payment method

A customer has a saved card in WooCommerce, the checkout even shows it, but the next renewal or off session charge fails with a Stripe error about the PaymentMethod. Stripe created the PaymentMethod when the card was added, but it was never attached to the Customer object. Here is why that gap opens up and a small script that finds every affected customer and attaches the PaymentMethod before it costs you a missed renewal.

Python and Node.js Runs on a schedule Safe by default (dry run)
A cell phone sitting on top of a table next to a roll of paper
Photo by Towfiqu barbhuiya on Unsplash
The short answer

WooCommerce saved a Stripe PaymentMethod id on the customer, but the PaymentMethod was never attached to a Stripe Customer, so Stripe refuses the off session charge. Run a small Python or Node.js repair script on a schedule that reads each order or customer's saved PaymentMethod id from meta _stripe_intent_id or the order transaction_id, checks whether that PaymentMethod is attached to the right Stripe Customer, and attaches it when it is loose. Full code, tests, and a dry run guard are below.

The problem in plain words

When a shopper adds a card at checkout, Stripe creates a PaymentMethod object for that card right away. WooCommerce is happy the moment it gets a PaymentMethod id back, so it saves that id on the order and on the customer, and the checkout page starts showing "card ending in 4242" like everything is fine.

But a PaymentMethod on its own is just a token for a card. It only becomes chargeable off session once it is attached to a Stripe Customer object. If that attach call never runs, or it runs against the wrong Customer, or it silently fails, the PaymentMethod sits unattached. Stripe will happily let you attach it later, but it will not let you charge it later, and the first time anyone finds out is when a renewal or a re-order fails.

Shopper saves a card at checkout Stripe creates a PaymentMethod attach never runs PaymentMethod unattached, no Customer Renewal declined
The card looks saved in WooCommerce the whole time. The gap is invisible until Stripe tries to charge the PaymentMethod off session and finds no Customer attached.

Why it happens

Stripe's own docs are explicit that a PaymentMethod must be attached to a Customer before it can be reused for an off session payment. A few common reasons that attach step gets skipped or fails silently:

This is a known trap in the WooCommerce Stripe integration, and Stripe's support docs cover the exact "must be attached to a customer" requirement for off session usage. See the citations at the end for the exact references.

The key insight

A PaymentMethod id saved in WordPress means nothing to Stripe until that PaymentMethod is attached to a Customer. Do not trust the id in WooCommerce. Always ask Stripe directly whether the PaymentMethod is attached, and to which Customer, before you assume it is chargeable.

The fix, as a flow

We do not touch checkout. We add a job that runs on a schedule, reads the saved PaymentMethod id off recent orders or customers, and asks Stripe for the real state of that PaymentMethod. If it is unattached, or somehow attached to a different Customer than the one WooCommerce expects, we attach it to the correct Stripe Customer and record what we did.

Scheduled job once a day Read saved PaymentMethod id Retrieve from Stripe Attached to right customer? yes, skip no Attach and note customer.id
The repair reads the real state from Stripe and only attaches a PaymentMethod that is loose or attached to the wrong Customer. A PaymentMethod already attached correctly is left alone.

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 orders and 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="30"
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="30"
export DRY_RUN="true"   // start safe, change to false to write
2

Find the saved PaymentMethod id on each order

WooCommerce, or its Stripe gateway, saves the id on order meta _stripe_intent_id most of the time, since a PaymentIntent's payment_method field carries the token forward. Older orders sometimes only have a transaction_id that starts with pm_. Check both, and skip anything that is not a PaymentMethod id.

step2.py
def payment_method_id_of(order):
    """The saved Stripe PaymentIntent id lives on meta _stripe_intent_id.
    We use it to look up the PaymentIntent, then read payment_method off it.
    Some older orders only have a pm_ id directly on transaction_id.
    """
    tid = order.get("transaction_id") or ""
    if tid.startswith("pm_"):
        return tid
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]  # a pi_... id, resolved to payment_method later
    return None
step2.js
export function paymentMethodIdOf(order) {
  // The saved Stripe PaymentIntent id lives on meta _stripe_intent_id.
  // We use it to look up the PaymentIntent, then read payment_method off it.
  // Some older orders only have a pm_ id directly on transaction_id.
  const tid = order.transaction_id || "";
  if (tid.startsWith("pm_")) return tid;
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value; // a pi_... id
  }
  return null;
}
3

Load the order's customer and resolve the real PaymentMethod

Read the order from the WooCommerce REST API to get the customer_id, then read that WordPress customer's Stripe Customer id from meta _stripe_customer_id. On the Stripe side, if what we found in step 2 was a PaymentIntent id, retrieve it and take its payment_method. Then retrieve the PaymentMethod itself so we can see its current customer field.

step3.py
import os, requests, stripe
from requests.auth import HTTPBasicAuth

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"])

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

def stripe_customer_id_of(wc_customer_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{wc_customer_id}", auth=AUTH, timeout=30)
    r.raise_for_status()
    for meta in r.json().get("meta_data") or []:
        if meta.get("key") == "_stripe_customer_id":
            return meta.get("value")
    return None

def resolve_payment_method(raw_id):
    if raw_id.startswith("pi_"):
        intent = stripe.PaymentIntent.retrieve(raw_id)
        pm_id = intent.payment_method
        return stripe.PaymentMethod.retrieve(pm_id) if pm_id else None
    return stripe.PaymentMethod.retrieve(raw_id)
step3.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

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

async function stripeCustomerIdOf(wcCustomerId) {
  const customer = await woo(`/customers/${wcCustomerId}`);
  for (const meta of customer?.meta_data || []) {
    if (meta.key === "_stripe_customer_id") return meta.value;
  }
  return null;
}

async function resolvePaymentMethod(rawId) {
  if (rawId.startsWith("pi_")) {
    const intent = await stripe.paymentIntents.retrieve(rawId);
    return intent.payment_method ? stripe.paymentMethods.retrieve(intent.payment_method) : null;
  }
  return stripe.paymentMethods.retrieve(rawId);
}
4

Decide, with one pure function

Keep the decision in its own function that takes the expected Stripe Customer id and the PaymentMethod object, and returns an action. This function does no I/O, so it is easy to test. The rule: if we have no PaymentMethod to check, skip it. If it is already attached to the expected customer, it is fine. If it is attached to a different customer, that is a conflict we should not touch automatically. Otherwise, attach it.

decide.py
def decide(stripe_customer_id, payment_method):
    if payment_method is None:
        return ("skip", "no PaymentMethod found to check")
    if not stripe_customer_id:
        return ("skip", "customer has no Stripe Customer id on file")
    current = payment_method.get("customer")
    if current == stripe_customer_id:
        return ("ok", "already attached to the right customer")
    if current:
        return ("conflict", f"attached to a different customer ({current})")
    return ("attach", "unattached, safe to attach")
decide.js
export function decide(stripeCustomerId, paymentMethod) {
  if (!paymentMethod) return ["skip", "no PaymentMethod found to check"];
  if (!stripeCustomerId) return ["skip", "customer has no Stripe Customer id on file"];
  const current = paymentMethod.customer || null;
  if (current === stripeCustomerId) return ["ok", "already attached to the right customer"];
  if (current) return ["conflict", `attached to a different customer (${current})`];
  return ["attach", "unattached, safe to attach"];
}
5

Attach it and leave a note

When the action is attach, call Stripe's attach endpoint with the PaymentMethod id and the Stripe Customer id. Then write an order note through the WooCommerce REST API so the shop manager can see the PaymentMethod was repaired and why. A conflict is logged, never auto attached, since moving a card to a different customer needs a human to confirm it is not fraud.

apply.py
def attach_payment_method(payment_method_id, stripe_customer_id):
    stripe.PaymentMethod.attach(payment_method_id, customer=stripe_customer_id)

def add_note(order_id, note):
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": note}, auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function attachPaymentMethod(paymentMethodId, stripeCustomerId) {
  await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
}

async function addNote(orderId, note) {
  await woo(`/orders/${orderId}/notes`, {
    method: "POST",
    body: JSON.stringify({ note }),
  });
}
6

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 do. Read the output, trust it, then switch it off to let it write. Run it once a day, a few hours ahead of your subscription billing job.

Run it safe

Always start with DRY_RUN=true. Attaching a PaymentMethod does not move money, but it does change who can charge a saved card, so you want to see the plan before it acts. A conflict is never auto resolved, it is only reported.

The full code

Here is the complete repair script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and never overwrites a PaymentMethod that is already attached to a different customer.

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

attach_payment_method.py
"""Attach a WooCommerce customer's saved Stripe PaymentMethod to their Stripe
Customer when it exists but was never attached. Run on a schedule, ahead of
billing. Safe to run again and again.
"""
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("attach_payment_method")

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


def payment_method_id_of(order):
    """The saved Stripe PaymentIntent id lives on meta _stripe_intent_id.
    We use it to look up the PaymentIntent, then read payment_method off it.
    Some older orders only have a pm_ id directly on transaction_id.
    """
    tid = order.get("transaction_id") or ""
    if tid.startswith("pm_"):
        return tid
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    return None


def stripe_customer_id_of(wc_customer_id):
    if not wc_customer_id:
        return None
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{wc_customer_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    for meta in r.json().get("meta_data") or []:
        if meta.get("key") == "_stripe_customer_id":
            return meta.get("value")
    return None


def resolve_payment_method(raw_id):
    if raw_id is None:
        return None
    try:
        if raw_id.startswith("pi_"):
            intent = stripe.PaymentIntent.retrieve(raw_id)
            pm_id = intent.get("payment_method")
            return stripe.PaymentMethod.retrieve(pm_id) if pm_id else None
        return stripe.PaymentMethod.retrieve(raw_id)
    except stripe.error.InvalidRequestError:
        return None


def decide(stripe_customer_id, payment_method):
    if payment_method is None:
        return ("skip", "no PaymentMethod found to check")
    if not stripe_customer_id:
        return ("skip", "customer has no Stripe Customer id on file")
    current = payment_method.get("customer")
    if current == stripe_customer_id:
        return ("ok", "already attached to the right customer")
    if current:
        return ("conflict", f"attached to a different customer ({current})")
    return ("attach", "unattached, safe to attach")


def attach_payment_method(payment_method_id, stripe_customer_id):
    stripe.PaymentMethod.attach(payment_method_id, customer=stripe_customer_id)


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


def recent_orders():
    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={"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 run():
    fixed = 0
    for order in recent_orders():
        raw_id = payment_method_id_of(order)
        if raw_id is None:
            continue
        payment_method = resolve_payment_method(raw_id)
        stripe_customer_id = stripe_customer_id_of(order.get("customer_id"))
        action, reason = decide(stripe_customer_id, payment_method)
        if action == "conflict":
            log.warning("Order %s: %s. Needs a human to review.", order["id"], reason)
            continue
        if action in ("skip", "ok"):
            continue
        pm_id = payment_method["id"]
        log.info("Order %s: %s. %s", order["id"], reason, "would attach" if DRY_RUN else "attaching")
        if not DRY_RUN:
            attach_payment_method(pm_id, stripe_customer_id)
            add_note(order["id"], f"Attached Stripe PaymentMethod {pm_id} to Stripe "
                                   f"Customer {stripe_customer_id}. It existed but was not "
                                   f"attached, which would have blocked the next off session charge.")
        fixed += 1
    log.info("Done. %d PaymentMethod(s) %s.", fixed, "to attach" if DRY_RUN else "attached")


if __name__ == "__main__":
    run()
attach-payment-method.js
/**
 * Attach a WooCommerce customer's saved Stripe PaymentMethod to their Stripe
 * Customer when it exists but was never attached. Run on a schedule, ahead
 * of billing. Safe to run again and again.
 */
import Stripe from "stripe";

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

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

export function paymentMethodIdOf(order) {
  const tid = order.transaction_id || "";
  if (tid.startsWith("pm_")) return tid;
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  return null;
}

async function stripeCustomerIdOf(wcCustomerId) {
  if (!wcCustomerId) return null;
  const customer = await woo(`/customers/${wcCustomerId}`);
  for (const meta of customer?.meta_data || []) {
    if (meta.key === "_stripe_customer_id") return meta.value;
  }
  return null;
}

async function resolvePaymentMethod(rawId) {
  if (!rawId) return null;
  try {
    if (rawId.startsWith("pi_")) {
      const intent = await stripe.paymentIntents.retrieve(rawId);
      return intent.payment_method ? stripe.paymentMethods.retrieve(intent.payment_method) : null;
    }
    return await stripe.paymentMethods.retrieve(rawId);
  } catch {
    return null;
  }
}

export function decide(stripeCustomerId, paymentMethod) {
  if (!paymentMethod) return ["skip", "no PaymentMethod found to check"];
  if (!stripeCustomerId) return ["skip", "customer has no Stripe Customer id on file"];
  const current = paymentMethod.customer || null;
  if (current === stripeCustomerId) return ["ok", "already attached to the right customer"];
  if (current) return ["conflict", `attached to a different customer (${current})`];
  return ["attach", "unattached, safe to attach"];
}

async function attachPaymentMethod(paymentMethodId, stripeCustomerId) {
  await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
}

async function addNote(orderId, note) {
  await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
}

async function* recentOrders() {
  const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(`/orders?after=${after}&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}

export async function run() {
  let fixed = 0;
  for await (const order of recentOrders()) {
    const rawId = paymentMethodIdOf(order);
    if (!rawId) continue;
    const paymentMethod = await resolvePaymentMethod(rawId);
    const stripeCustomerId = await stripeCustomerIdOf(order.customer_id);
    const [action, reason] = decide(stripeCustomerId, paymentMethod);
    if (action === "conflict") { console.warn(`Order ${order.id}: ${reason}. Needs a human to review.`); continue; }
    if (action === "skip" || action === "ok") continue;
    const pmId = paymentMethod.id;
    console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would attach" : "attaching"}`);
    if (!DRY_RUN) {
      await attachPaymentMethod(pmId, stripeCustomerId);
      await addNote(order.id, `Attached Stripe PaymentMethod ${pmId} to Stripe Customer ` +
                               `${stripeCustomerId}. It existed but was not attached, which ` +
                               `would have blocked the next off session charge.`);
    }
    fixed++;
  }
  console.log(`Done. ${fixed} PaymentMethod(s) ${DRY_RUN ? "to attach" : "attached"}.`);
}

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 saved card gets linked to a customer. 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_attach_decide.py
from attach_payment_method import decide, payment_method_id_of


def pm(**over):
    base = {"id": "pm_1", "customer": None}
    base.update(over)
    return base


def test_attach_when_unattached():
    assert decide("cus_1", pm())[0] == "attach"


def test_ok_when_already_attached_to_right_customer():
    assert decide("cus_1", pm(customer="cus_1"))[0] == "ok"


def test_conflict_when_attached_to_other_customer():
    assert decide("cus_1", pm(customer="cus_2"))[0] == "conflict"


def test_skip_when_no_payment_method():
    assert decide("cus_1", None)[0] == "skip"


def test_skip_when_no_stripe_customer_id():
    assert decide(None, pm())[0] == "skip"


def test_payment_method_id_prefers_transaction_id_pm():
    order = {"transaction_id": "pm_555", "meta_data": []}
    assert payment_method_id_of(order) == "pm_555"


def test_payment_method_id_falls_back_to_intent_meta():
    order = {"transaction_id": "", "meta_data": [{"key": "_stripe_intent_id", "value": "pi_999"}]}
    assert payment_method_id_of(order) == "pi_999"
decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, paymentMethodIdOf } from "./attach-payment-method.js";

const pm = (over = {}) => ({ id: "pm_1", customer: null, ...over });

test("attach when unattached", () => {
  assert.equal(decide("cus_1", pm())[0], "attach");
});

test("ok when already attached to right customer", () => {
  assert.equal(decide("cus_1", pm({ customer: "cus_1" }))[0], "ok");
});

test("conflict when attached to other customer", () => {
  assert.equal(decide("cus_1", pm({ customer: "cus_2" }))[0], "conflict");
});

test("skip when no payment method", () => {
  assert.equal(decide("cus_1", null)[0], "skip");
});

test("skip when no stripe customer id", () => {
  assert.equal(decide(null, pm())[0], "skip");
});

test("paymentMethodIdOf prefers transaction_id pm_", () => {
  assert.equal(paymentMethodIdOf({ transaction_id: "pm_555", meta_data: [] }), "pm_555");
});

test("paymentMethodIdOf falls back to intent meta", () => {
  assert.equal(
    paymentMethodIdOf({ transaction_id: "", meta_data: [{ key: "_stripe_intent_id", value: "pi_999" }] }),
    "pi_999"
  );
});

Case studies

Custom checkout

The Payment Element that forgot to attach

A store built a custom checkout page around the Stripe Payment Element and set setup_future_usage: off_session, assuming that alone would make the card reusable. It creates and confirms the PaymentMethod but Stripe does not attach it to a Customer on its own unless the integration calls attach explicitly.

Every card saved through that page for three weeks was unattached. The repair script found all of them in one run, attached each to its correct Stripe Customer, and the next subscription renewal cycle went through without a single decline from this cause.

Customer migration

The migration that moved ids but not attachments

During a move from the legacy Sources API to PaymentMethods, a script backfilled _stripe_intent_id on old orders so WooCommerce would show the new field format. It never called Stripe to actually attach those PaymentMethods to the matching Customer objects.

Running the repair script in dry run surfaced twelve unattached PaymentMethods, all pre migration orders. The team reviewed the list, ran it for real, and closed the gap before the next billing date.

What good looks like

After this runs on a schedule ahead of billing, a PaymentMethod that exists but is not attached no longer turns into a missed renewal. The worst case becomes a quiet attach the day before, instead of a failed charge and a support ticket the day of. Keep a conflict alert in place too, since that case always needs a human.

FAQ

Why does WooCommerce say a card is saved but the renewal still fails?

WooCommerce saved a Stripe PaymentMethod id on the customer, but the PaymentMethod itself was never attached to a Stripe Customer object. Stripe rejects an off session charge against a PaymentMethod with no customer attached, so the renewal fails even though the card looks saved in your store.

Is it safe to attach a PaymentMethod with a script?

Yes, when the script first confirms the PaymentMethod is not already attached to a different customer and that the WooCommerce customer has a matching Stripe Customer id. Attaching a PaymentMethod does not move money, it only links records, and the repair skips anything that looks wrong. Start in dry run mode to review the list before it writes.

How often should the repair script run?

Once a day is enough for most stores. Run it a few hours before your renewal or subscription billing job so any unattached PaymentMethod is fixed before Stripe tries to charge it.

Related field notes

Citations

On the problem:

  1. Stripe docs: a PaymentMethod must be attached to a Customer to be used for future, off session payments. docs.stripe.com/payments/payment-methods
  2. Stripe docs: saving payment details for future use and the attach requirement. docs.stripe.com/payments/save-and-reuse
  3. WooCommerce docs: how the Stripe gateway stores customer and payment method data. woocommerce.com/document/stripe

On the solution:

  1. Stripe API: attach a PaymentMethod to a Customer. docs.stripe.com/api/payment_methods/attach
  2. Stripe API: retrieve a PaymentMethod and read its customer field. docs.stripe.com/api/payment_methods/retrieve
  3. WooCommerce REST API: read and update customers and orders. 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 fix your unattached cards?

If this saved you a pile of failed renewals or 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