Repair Alternative payment methods

Stripe Link becomes manual

A customer breezes through checkout with Stripe Link, one click, no typing a card number, and the first payment goes through fine. Weeks later the renewal never charges. WooCommerce Subscriptions has quietly switched the subscription to manual renewal, so it emails an invoice and waits for the customer to pay it by hand, and most never do. Here is why Link checkouts can skip the step that saves a reusable payment method, and a small script that finds the subscriptions this happened to and switches the ones it safely can back to automatic.

Python and Node.js Runs on a schedule Safe by default (dry run)
Credit cards and a phone
Photo by CardMapr.nl on Unsplash
The short answer

Stripe Link can complete a payment without leaving a reusable payment method attached to the Stripe customer, so WooCommerce Subscriptions has nothing to charge next cycle and falls back to manual renewal. Run a small Python or Node.js job on a schedule that reads each manual subscription's last PaymentIntent (from order meta _stripe_intent_id or transaction_id), checks whether Stripe now has a reusable payment method on that customer, and switches the subscription back to automatic only when one genuinely exists. Full code, tests, and a dry run guard are below.

The problem in plain words

Stripe Link lets a returning shopper pay with one click using a phone number and a code, no card form, no re-typing anything. It is great for conversion. WooCommerce Subscriptions likes it too, until it needs to charge that customer again in thirty days.

To bill a subscription automatically, WooCommerce Subscriptions needs a payment method token saved against the Stripe customer, something it can hand back to Stripe next month and say "charge this one again." A normal card checkout attaches that token as part of the flow. Some Link checkouts finish the PaymentIntent successfully but do not attach an off-session reusable payment method the way WooCommerce expects, because Link can settle the charge through a wallet-style path rather than a saved card object. WooCommerce sees a subscription with a successful first payment and no usable token, and does the safe thing: it flips the subscription to manual renewal so the store is never left holding an uncharged order.

Nobody configured this. No setting was changed. It is a side effect of how the payment happened, and unless someone is watching the subscriptions list, it looks identical to any other subscription until the missed renewal email starts to arrive.

Customer pays with Stripe Link First charge ok PaymentIntent succeeded no reusable token Sub set manual renewal by Woo Invoice sent not charged
The first payment goes through, but because Link did not leave a reusable payment method behind, WooCommerce Subscriptions cannot bill the customer again automatically.

Why it happens

Stripe's own documentation notes that Link's saved payment details are tied to the Link identity, not always exposed to a merchant as a plain reusable payment method the way a saved card is. WooCommerce Subscriptions, through the WooCommerce Stripe gateway, looks for a specific kind of saved token on the customer before it will attempt an off-session renewal. A few things can go wrong at that boundary:

This is a known friction point between one-click wallet checkouts and subscription billing, and it is reported in the WooCommerce Stripe gateway's issue tracker as subscriptions silently losing automatic renewal after specific payment method types. See the citations at the end for the exact references.

The key insight

"Manual renewal" is not always a customer choice, and it is not always wrong either. The only safe move is to check Stripe directly. If the Stripe customer now has a genuine reusable payment method attached, and it was not there when the subscription was created, the subscription can be switched back. If Stripe has nothing reusable, leave it alone and let the customer pay the invoice or add a card.

The fix, as a flow

We do not touch checkout or the Link flow itself. We add a job that runs once a day, looks at every subscription currently on manual renewal, and asks Stripe whether the customer behind that subscription has a real reusable payment method on file today. If they do, and WooCommerce still thinks the subscription needs manual renewal, we set the subscription's payment method back to the gateway and save the token, the same way a normal card renewal would have.

Scheduled job once a day List subscriptions on manual renewal Read PaymentIntent get the Stripe customer Reusable method on file now? yes no, skip Set automatic save token + note
The repair job only acts when Stripe genuinely has a reusable payment method for that customer today. Subscriptions with nothing reusable on file are left on manual renewal, on purpose.

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 subscriptions. 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

List subscriptions on manual renewal

The WooCommerce Subscriptions REST API extends the same /wp-json/wc/v3/ namespace with a subscriptions endpoint. We page through everything with requires_manual_renewal set, since that flag is exactly what marks a subscription as needing the invoice-and-wait path instead of an automatic charge.

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 manual_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            if sub.get("requires_manual_renewal"):
                yield sub
        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.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* manualSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const sub of batch) {
      if (sub.requires_manual_renewal) yield sub;
    }
    page++;
  }
}
3

Read the PaymentIntent and find the Stripe customer

Each subscription has a parent order with the original checkout on it. We read the saved PaymentIntent id from order meta _stripe_intent_id, falling back to transaction_id when it looks like a PaymentIntent id, then ask Stripe for that intent to get its customer.

step3.py
import stripe

def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None

def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None
step3.js
export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

async function getIntent(stripe, intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the subscription and the Stripe customer's default payment method (or null) and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule: only repair a subscription that is on manual renewal, whose parent order actually paid through the Stripe gateway, and where Stripe now shows a genuine reusable card or bank type payment method. Everything else is left alone.

decide.py
REUSABLE_TYPES = {"card", "us_bank_account", "sepa_debit"}

def is_reusable(payment_method):
    """A payment method Stripe will let us charge again off session."""
    if not payment_method:
        return False
    return payment_method.get("type") in REUSABLE_TYPES

def decide(subscription, payment_method):
    if not subscription.get("requires_manual_renewal"):
        return ("skip", "subscription already automatic")
    if subscription.get("payment_method") not in ("stripe", ""):
        return ("skip", "not billed through the Stripe gateway")
    if not is_reusable(payment_method):
        return ("keep_manual", "no reusable payment method on the Stripe customer")
    return ("repair", "reusable payment method found, safe to re-enable automatic renewal")
decide.js
const REUSABLE_TYPES = new Set(["card", "us_bank_account", "sepa_debit"]);

export function isReusable(paymentMethod) {
  if (!paymentMethod) return false;
  return REUSABLE_TYPES.has(paymentMethod.type);
}

export function decide(subscription, paymentMethod) {
  if (!subscription.requires_manual_renewal) return ["skip", "subscription already automatic"];
  if (!["stripe", ""].includes(subscription.payment_method)) {
    return ["skip", "not billed through the Stripe gateway"];
  }
  if (!isReusable(paymentMethod)) {
    return ["keep_manual", "no reusable payment method on the Stripe customer"];
  }
  return ["repair", "reusable payment method found, safe to re-enable automatic renewal"];
}
5

Switch the subscription back to automatic

When the action is repair, update the subscription so requires_manual_renewal is false and the payment method stays stripe, then add an order note on the parent order so the shop manager can see exactly why it changed and which Stripe payment method made it safe.

apply.py
def re_enable_automatic(subscription_id, parent_order_id, payment_method):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"requires_manual_renewal": False, "payment_method": "stripe"},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{parent_order_id}/notes",
        json={"note": f"Automatic renewal restored. Stripe customer now has a reusable "
                      f"{payment_method['type']} payment method on file, so the Link "
                      f"checkout fallback to manual renewal was cleared by the repair job."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function reEnableAutomatic(subscriptionId, parentOrderId, paymentMethod) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({ requires_manual_renewal: false, payment_method: "stripe" }),
  });
  await woo(`/orders/${parentOrderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Automatic renewal restored. Stripe customer now has a reusable ` +
            `${paymentMethod.type} payment method on file, so the Link checkout ` +
            `fallback to manual renewal was cleared by the repair job.`,
    }),
  });
}
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 with cron, there is no need to check more often than that.

Run it safe

Always start with DRY_RUN=true. A repair job writes to real subscriptions, so you want to see its plan before it acts. Once the report looks right for a few days, turn it off.

The full code

Here is the complete repair 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 only ever touches a subscription that is on manual renewal with a genuinely reusable payment method behind it.

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

restore_automatic_renewal.py
"""Restore automatic renewal on WooCommerce Subscriptions that a Stripe Link
checkout left on manual renewal, but only when Stripe now shows a genuine
reusable payment method for that customer. Run on a schedule. 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("restore_automatic_renewal")

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"

REUSABLE_TYPES = {"card", "us_bank_account", "sepa_debit"}


def manual_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            if sub.get("requires_manual_renewal"):
                yield sub
        page += 1


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 intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


def default_reusable_payment_method(customer_id):
    """The Stripe customer's default payment method, if it is a reusable type."""
    if not customer_id:
        return None
    try:
        customer = stripe.Customer.retrieve(customer_id)
    except stripe.error.InvalidRequestError:
        return None
    pm_id = (customer.get("invoice_settings") or {}).get("default_payment_method")
    if not pm_id:
        methods = stripe.PaymentMethod.list(customer=customer_id, limit=1)
        if not methods.data:
            return None
        pm_id = methods.data[0].id
    try:
        return stripe.PaymentMethod.retrieve(pm_id)
    except stripe.error.InvalidRequestError:
        return None


def is_reusable(payment_method):
    if not payment_method:
        return False
    return payment_method.get("type") in REUSABLE_TYPES


def decide(subscription, payment_method):
    if not subscription.get("requires_manual_renewal"):
        return ("skip", "subscription already automatic")
    if subscription.get("payment_method") not in ("stripe", ""):
        return ("skip", "not billed through the Stripe gateway")
    if not is_reusable(payment_method):
        return ("keep_manual", "no reusable payment method on the Stripe customer")
    return ("repair", "reusable payment method found, safe to re-enable automatic renewal")


def re_enable_automatic(subscription_id, parent_order_id, payment_method):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={"requires_manual_renewal": False, "payment_method": "stripe"},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{parent_order_id}/notes",
        json={"note": f"Automatic renewal restored. Stripe customer now has a reusable "
                      f"{payment_method['type']} payment method on file, so the Link "
                      f"checkout fallback to manual renewal was cleared by the repair job."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    repaired = 0
    for sub in manual_subscriptions():
        parent_order_id = sub.get("parent_id")
        order = get_order(parent_order_id) if parent_order_id else None
        payment_method = None
        if order:
            intent = get_intent(intent_id_of(order))
            customer_id = intent.get("customer") if intent else None
            payment_method = default_reusable_payment_method(customer_id)
        action, reason = decide(sub, payment_method)
        if action != "repair":
            if action == "keep_manual":
                log.info("Subscription %s: %s", sub["id"], reason)
            continue
        log.info("Subscription %s: %s. %s", sub["id"], reason, "would repair" if DRY_RUN else "repairing")
        if not DRY_RUN:
            re_enable_automatic(sub["id"], parent_order_id, payment_method)
        repaired += 1
    log.info("Done. %d subscription(s) %s.", repaired, "to repair" if DRY_RUN else "repaired")


if __name__ == "__main__":
    run()
restore-automatic-renewal.js
/**
 * Restore automatic renewal on WooCommerce Subscriptions that a Stripe Link
 * checkout left on manual renewal, but only when Stripe now shows a genuine
 * reusable payment method for that customer. Run on a schedule. Safe to run
 * again and again.
 */
import Stripe from "stripe";

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

const REUSABLE_TYPES = new Set(["card", "us_bank_account", "sepa_debit"]);

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

async function* manualSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const sub of batch) {
      if (sub.requires_manual_renewal) yield sub;
    }
    page++;
  }
}

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

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function defaultReusablePaymentMethod(customerId) {
  if (!customerId) return null;
  let customer;
  try {
    customer = await stripe.customers.retrieve(customerId);
  } catch {
    return null;
  }
  let pmId = customer.invoice_settings && customer.invoice_settings.default_payment_method;
  if (!pmId) {
    const methods = await stripe.paymentMethods.list({ customer: customerId, limit: 1 });
    if (!methods.data.length) return null;
    pmId = methods.data[0].id;
  }
  try {
    return await stripe.paymentMethods.retrieve(pmId);
  } catch {
    return null;
  }
}

export function isReusable(paymentMethod) {
  if (!paymentMethod) return false;
  return REUSABLE_TYPES.has(paymentMethod.type);
}

export function decide(subscription, paymentMethod) {
  if (!subscription.requires_manual_renewal) return ["skip", "subscription already automatic"];
  if (!["stripe", ""].includes(subscription.payment_method)) {
    return ["skip", "not billed through the Stripe gateway"];
  }
  if (!isReusable(paymentMethod)) {
    return ["keep_manual", "no reusable payment method on the Stripe customer"];
  }
  return ["repair", "reusable payment method found, safe to re-enable automatic renewal"];
}

async function reEnableAutomatic(subscriptionId, parentOrderId, paymentMethod) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({ requires_manual_renewal: false, payment_method: "stripe" }),
  });
  await woo(`/orders/${parentOrderId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Automatic renewal restored. Stripe customer now has a reusable ` +
            `${paymentMethod.type} payment method on file, so the Link checkout ` +
            `fallback to manual renewal was cleared by the repair job.`,
    }),
  });
}

async function run() {
  let repaired = 0;
  for await (const sub of manualSubscriptions()) {
    const parentOrderId = sub.parent_id;
    const order = parentOrderId ? await woo(`/orders/${parentOrderId}`) : null;
    let paymentMethod = null;
    if (order) {
      const intent = await getIntent(intentIdOf(order));
      const customerId = intent && intent.customer;
      paymentMethod = await defaultReusablePaymentMethod(customerId);
    }
    const [action, reason] = decide(sub, paymentMethod);
    if (action !== "repair") {
      if (action === "keep_manual") console.log(`Subscription ${sub.id}: ${reason}`);
      continue;
    }
    console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would repair" : "repairing"}`);
    if (!DRY_RUN) await reEnableAutomatic(sub.id, parentOrderId, paymentMethod);
    repaired++;
  }
  console.log(`Done. ${repaired} subscription(s) ${DRY_RUN ? "to repair" : "repaired"}.`);
}

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 subscriptions get switched back to real billing. 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_link_decide.py
from restore_automatic_renewal import decide


def pm(**over):
    base = {"type": "card"}
    base.update(over)
    return base


def test_repair_when_manual_and_reusable_method_found():
    sub = {"requires_manual_renewal": True, "payment_method": "stripe"}
    assert decide(sub, pm())[0] == "repair"


def test_skip_when_already_automatic():
    sub = {"requires_manual_renewal": False, "payment_method": "stripe"}
    assert decide(sub, pm())[0] == "skip"


def test_skip_when_not_stripe_gateway():
    sub = {"requires_manual_renewal": True, "payment_method": "cheque"}
    assert decide(sub, pm())[0] == "skip"


def test_keep_manual_when_no_payment_method():
    sub = {"requires_manual_renewal": True, "payment_method": "stripe"}
    assert decide(sub, None)[0] == "keep_manual"
link-decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./restore-automatic-renewal.js";

const pm = (over = {}) => ({ type: "card", ...over });

test("repair when manual and reusable method found", () => {
  assert.equal(decide({ requires_manual_renewal: true, payment_method: "stripe" }, pm())[0], "repair");
});

test("skip when already automatic", () => {
  assert.equal(decide({ requires_manual_renewal: false, payment_method: "stripe" }, pm())[0], "skip");
});

test("skip when not stripe gateway", () => {
  assert.equal(decide({ requires_manual_renewal: true, payment_method: "cheque" }, pm())[0], "skip");
});

test("keep manual when no payment method", () => {
  assert.equal(decide({ requires_manual_renewal: true, payment_method: "stripe" }, null)[0], "keep_manual");
});

Case studies

Membership site

The one-click signups that never renewed

A membership site added Stripe Link to speed up signups and saw a jump in completed checkouts. Two months later, renewal revenue was flat despite more members. Every single Link-only signup had quietly landed on manual renewal, and the invoice emails were going straight to spam.

The repair job found forty three subscriptions in that state. Thirty six had a real reusable card sitting on the Stripe customer from a later purchase, and were switched back to automatic in one dry run followed by one real run.

Coaching subscription

The customer who really did remove their card

One flagged subscription looked identical to the others until the job checked Stripe. The customer had paid once with Link, then deleted their Link wallet entirely. There was nothing reusable to find.

The decision function correctly returned keep_manual, so the job left that subscription untouched and let the normal manual invoice flow keep asking the customer to pay, which is exactly what should happen.

What good looks like

After this runs on a schedule, a Stripe Link checkout stops being a quiet tax on renewal revenue. Subscriptions that can be billed automatically go back to being billed automatically, and the ones that genuinely have no saved payment method stay on manual, which is the correct outcome for them. Keep it running even after enabling any gateway update, because new Link checkouts will keep happening.

FAQ

Why does a subscription paid with Stripe Link switch to manual renewal?

WooCommerce Subscriptions needs a reusable, tokenized payment method attached to the customer to bill later. Some Stripe Link checkouts complete the PaymentIntent without a payment method that WooCommerce recognizes as reusable, so the subscription is created with no saved token and falls back to manual renewal, meaning WooCommerce emails an invoice instead of charging automatically.

Is it safe to switch a subscription back to automatic renewal with a script?

Yes, when the script confirms Stripe actually has a reusable payment method attached to the customer and the subscription is on manual renewal only because the token is missing, not because the customer chose manual on purpose. Start in dry run mode so you can review the exact list before anything changes.

How often should this repair job run?

Once a day is enough. New subscriptions from Link checkouts are the ones at risk, and there is no urgency to fix a manual renewal within minutes, so a daily cron job that reports and then repairs is the right pace.

Related field notes

Citations

On the problem:

  1. Stripe docs: Link and how its saved payment details relate to reusable payment methods for a customer. docs.stripe.com/payments/link
  2. WooCommerce Subscriptions docs: renewal payments and why a subscription needs a saved token to bill automatically. woocommerce.com/document/subscriptions/renewal-process
  3. WooCommerce Stripe gateway issue tracker: subscriptions losing automatic renewal tied to specific payment method types. github.com/woocommerce/woocommerce-gateway-stripe/issues

On the solution:

  1. Stripe API: retrieve a PaymentIntent and read its customer field. docs.stripe.com/api/payment_intents/retrieve
  2. Stripe API: list a customer's payment methods and read the default from invoice settings. docs.stripe.com/api/payment_methods/list
  3. WooCommerce Subscriptions REST API: read and update a subscription, including requires_manual_renewal. woocommerce.github.io/subscriptions-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 stuck renewals?

If this saved you a pile of failed renewals or a support queue full of "why was I not charged," 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