Repair WooCommerce Subscriptions: manual renewal and dunning

Free trials forced to manual renewal

A customer signs up for a free trial, gets a warm welcome email, and everyone forgets about it until the trial ends. That is when support finds out the subscription never actually renewed. Not because the card was declined, but because WooCommerce never had a card to charge in the first place, so it quietly dropped the subscription to manual renewal. Here is why that happens and a small script that finds every subscription stuck this way and puts the ones that can be fixed back on automatic billing.

Python and Node.js Runs on a schedule Safe by default (dry run)
A green and white wrapped gift with green ribbon
Photo by Maia I on Unsplash
The short answer

Automatic renewal needs a saved, reusable Stripe payment method on the customer. A free trial checkout is supposed to confirm a zero dollar setup and save the card for later, but if that confirmation is interrupted, the subscription reaches its trial end with no token to charge, and WooCommerce Subscriptions falls back to "requires manual renewal" instead of failing loudly. Run a small Python or Node.js repair job on a schedule that checks Stripe for a real, usable payment method on each manual subscription and switches the renewal mode back to automatic when one exists. Full code, tests, and a dry run guard are below.

The problem in plain words

A normal paid subscription saves the customer's card at checkout, then Stripe charges that saved card automatically on every renewal date. A free trial works almost the same way, except the checkout confirms a setup with no money moving yet. The card is only saved, not charged, because the customer owes nothing today.

That save step needs to finish just as much as a real charge does. If the browser is closed mid redirect, if 3D Secure is abandoned, or if the plugin that listens for the confirmation event never runs, WooCommerce ends the trial with an empty hand. There is no error banner for this, because nothing failed at checkout, the trial order still completed. The subscription simply has no payment token to bill against, so on the first renewal attempt WooCommerce Subscriptions marks it "requires manual renewal" and waits for the customer to pay by hand instead.

Starts free trial $0 due today Setup intent should save the card save interrupted No card saved trial still completes Trial ends forced manual
Nothing errors at checkout because no money is due yet. The gap only shows up weeks later, when the trial ends and there is no card to bill.

Why it happens

WooCommerce Subscriptions and the Stripe gateway both document that automatic renewals depend entirely on a saved payment method attached to the customer. A few common ways a trial ends up without one:

WooCommerce Subscriptions is intentionally conservative here. Rather than silently retry a charge with no payment method and risk a confusing failure, it marks the subscription "manual renewal required" so the customer is asked to pay directly. That is the right fallback, but it is also invisible to the shop owner unless something checks for it.

The key insight

"Manual renewal" is not always the customer's fault and it is not always permanent. If Stripe now has a valid, reusable payment method attached to that customer, most likely because they added a card later or the setup actually did complete just after the flag was set, the subscription can be switched back to automatic. The fix is to check Stripe, not to guess.

The fix, as a flow

We do not touch checkout or the trial itself. We add a job that runs on a schedule, looks at every subscription currently marked "requires manual renewal," and asks Stripe whether that customer actually has a working, reusable payment method now. If Stripe says yes and the subscription is not already automatic, we flip the requires_manual_renewal flag off and attach the payment method as the subscription's default, the same way a successful checkout would have.

Scheduled job once a day List subscriptions requires manual renewal Look up Stripe customer payment methods Reusable and not expired? yes no, skip Set automatic save token + note
The job only acts when Stripe confirms a real, reusable payment method exists. Everything still genuinely trial or still genuinely cardless 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 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 stuck on manual renewal

The WooCommerce Subscriptions REST API exposes each subscription's requires_manual_renewal flag directly. We page through active and pending subscriptions and keep only the ones where that flag is true, since those are the only ones eligible for a fix.

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_renewal_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,pending", "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.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

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

Ask Stripe for a reusable payment method

Read the Stripe PaymentIntent or SetupIntent id from the subscription's parent order meta, either _stripe_intent_id or the order's transaction_id, to find the Stripe customer. Then list that customer's saved cards. A card must not be expired to count as usable.

step3.py
import stripe
from datetime import date

def usable_payment_method(customer_id):
    methods = stripe.PaymentMethod.list(customer=customer_id, type="card")
    today = date.today()
    for pm in methods.auto_paging_iter():
        card = pm.card
        if card and not (card.exp_year, card.exp_month) < (today.year, today.month):
            return pm
    return None
step3.js
async function usablePaymentMethod(customerId) {
  const methods = await stripe.paymentMethods.list({ customer: customerId, type: "card" });
  const now = new Date();
  const thisYear = now.getFullYear();
  const thisMonth = now.getMonth() + 1;
  for (const pm of methods.data) {
    const card = pm.card;
    if (card && !(card.exp_year < thisYear || (card.exp_year === thisYear && card.exp_month < thisMonth))) {
      return pm;
    }
  }
  return null;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a subscription and a payment method and returns an action. The rule is simple. If the subscription is not actually flagged for manual renewal, skip it. If there is no usable payment method, skip it, that customer still genuinely needs to add a card. Otherwise, restore automatic billing.

decide.py
def decide(subscription, payment_method):
    if not subscription.get("requires_manual_renewal"):
        return ("skip", "subscription is already automatic")
    if payment_method is None:
        return ("skip", "no reusable payment method on file yet")
    return ("restore", "Stripe has a usable card, safe to restore automatic billing")
decide.js
export function decide(subscription, paymentMethod) {
  if (!subscription.requires_manual_renewal) return ["skip", "subscription is already automatic"];
  if (!paymentMethod) return ["skip", "no reusable payment method on file yet"];
  return ["restore", "Stripe has a usable card, safe to restore automatic billing"];
}
5

Restore automatic billing and save the token

When the action is restore, set requires_manual_renewal to false and write the payment method id onto the subscription's meta so the gateway uses it on the next renewal. Then add a note so the shop manager can see what changed and why.

apply.py
def restore_automatic(subscription_id, payment_method):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={
            "requires_manual_renewal": False,
            "meta_data": [{"key": "_stripe_source_id", "value": payment_method.id}],
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": f"Restored automatic renewal. Found reusable payment method "
                      f"{payment_method.id} on the Stripe customer. Set by the repair job."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function restoreAutomatic(subscriptionId, paymentMethod) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({
      requires_manual_renewal: false,
      meta_data: [{ key: "_stripe_source_id", value: paymentMethod.id }],
    }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Restored automatic renewal. Found reusable payment method ` +
            `${paymentMethod.id} on the Stripe customer. Set 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, since trials do not end every minute.

Run it safe

Always start with DRY_RUN=true. This job changes billing behavior on real subscriptions, so you want to see its plan before it acts. Once the report looks right for a day, 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 never restores a subscription that is already automatic or still has no usable card.

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

restore_trial_billing.py
"""Restore automatic billing for free trial subscriptions that were forced to
manual renewal because the card save was never confirmed. Run on a schedule.
Safe to run again and again.
"""
import os
import logging
from datetime import date
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_trial_billing")

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 manual_renewal_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "active,pending", "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 stripe_customer_id(subscription):
    for meta in subscription.get("meta_data") or []:
        if meta.get("key") == "_stripe_customer_id" and meta.get("value"):
            return meta["value"]
    return None


def usable_payment_method(customer_id):
    if not customer_id:
        return None
    methods = stripe.PaymentMethod.list(customer=customer_id, type="card")
    today = date.today()
    for pm in methods.auto_paging_iter():
        card = pm.card
        if card and not (card.exp_year, card.exp_month) < (today.year, today.month):
            return pm
    return None


def decide(subscription, payment_method):
    if not subscription.get("requires_manual_renewal"):
        return ("skip", "subscription is already automatic")
    if payment_method is None:
        return ("skip", "no reusable payment method on file yet")
    return ("restore", "Stripe has a usable card, safe to restore automatic billing")


def restore_automatic(subscription_id, payment_method):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}",
        json={
            "requires_manual_renewal": False,
            "meta_data": [{"key": "_stripe_source_id", "value": payment_method.id}],
        },
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription_id}/notes",
        json={"note": f"Restored automatic renewal. Found reusable payment method "
                      f"{payment_method.id} on the Stripe customer. Set by the repair job."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    restored = 0
    for sub in manual_renewal_subscriptions():
        customer_id = stripe_customer_id(sub)
        pm = usable_payment_method(customer_id)
        action, reason = decide(sub, pm)
        if action == "skip":
            log.info("Subscription %s: %s", sub["id"], reason)
            continue
        log.info("Subscription %s: %s. %s", sub["id"], reason, "would restore" if DRY_RUN else "restoring")
        if not DRY_RUN:
            restore_automatic(sub["id"], pm)
        restored += 1
    log.info("Done. %d subscription(s) %s.", restored, "to restore" if DRY_RUN else "restored")


if __name__ == "__main__":
    run()
restore-trial-billing.js
/**
 * Restore automatic billing for free trial subscriptions that were forced to
 * manual renewal because the card save was never confirmed. 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";

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* manualRenewalSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=active,pending&per_page=50&page=${page}`);
    if (!batch.length) return;
    for (const sub of batch) {
      if (sub.requires_manual_renewal) yield sub;
    }
    page++;
  }
}

function stripeCustomerId(subscription) {
  for (const meta of subscription.meta_data || []) {
    if (meta.key === "_stripe_customer_id" && meta.value) return meta.value;
  }
  return null;
}

async function usablePaymentMethod(customerId) {
  if (!customerId) return null;
  const methods = await stripe.paymentMethods.list({ customer: customerId, type: "card" });
  const now = new Date();
  const thisYear = now.getFullYear();
  const thisMonth = now.getMonth() + 1;
  for (const pm of methods.data) {
    const card = pm.card;
    if (card && !(card.exp_year < thisYear || (card.exp_year === thisYear && card.exp_month < thisMonth))) {
      return pm;
    }
  }
  return null;
}

export function decide(subscription, paymentMethod) {
  if (!subscription.requires_manual_renewal) return ["skip", "subscription is already automatic"];
  if (!paymentMethod) return ["skip", "no reusable payment method on file yet"];
  return ["restore", "Stripe has a usable card, safe to restore automatic billing"];
}

async function restoreAutomatic(subscriptionId, paymentMethod) {
  await woo(`/subscriptions/${subscriptionId}`, {
    method: "PUT",
    body: JSON.stringify({
      requires_manual_renewal: false,
      meta_data: [{ key: "_stripe_source_id", value: paymentMethod.id }],
    }),
  });
  await woo(`/subscriptions/${subscriptionId}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Restored automatic renewal. Found reusable payment method ` +
            `${paymentMethod.id} on the Stripe customer. Set by the repair job.`,
    }),
  });
}

async function run() {
  let restored = 0;
  for await (const sub of manualRenewalSubscriptions()) {
    const customerId = stripeCustomerId(sub);
    const pm = await usablePaymentMethod(customerId);
    const [action, reason] = decide(sub, pm);
    if (action === "skip") { console.log(`Subscription ${sub.id}: ${reason}`); continue; }
    console.log(`Subscription ${sub.id}: ${reason}. ${DRY_RUN ? "would restore" : "restoring"}`);
    if (!DRY_RUN) await restoreAutomatic(sub.id, pm);
    restored++;
  }
  console.log(`Done. ${restored} subscription(s) ${DRY_RUN ? "to restore" : "restored"}.`);
}

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 their billing mode changed. 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_trial_restore_decide.py
from restore_trial_billing import decide


def card(**over):
    base = {"id": "pm_1"}
    base.update(over)
    return base


def test_restore_when_manual_and_card_found():
    sub = {"requires_manual_renewal": True}
    assert decide(sub, card())[0] == "restore"


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


def test_skip_when_no_payment_method():
    sub = {"requires_manual_renewal": True}
    assert decide(sub, None)[0] == "skip"
decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./decide.js";

const card = (over = {}) => ({ id: "pm_1", ...over });

test("restore when manual and card found", () => {
  assert.equal(decide({ requires_manual_renewal: true }, card())[0], "restore");
});

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

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

Case studies

Checkout redesign

The page builder that skipped a script tag

A store rebuilt its checkout page with a page builder plugin and the new thank you page template dropped a script include, the one that called confirmSetupIntent after a trial signup. Every trial for three weeks completed normally but ended with no saved card, and dozens of subscriptions quietly switched to manual renewal.

The repair job found forty two affected subscriptions in dry run. About half of the customers had since added a card through their account page anyway, so those were restored automatically. The rest were flagged for a support follow up.

Card reissue

The bank that reissued cards after a breach

A bank reissued a batch of cards after a data breach, and every affected customer's saved Stripe payment method quietly expired. Their next renewal attempt failed, and WooCommerce Subscriptions correctly dropped those to manual renewal.

Once each customer added their new card through the account page, the repair job caught it on its next daily run and switched automatic billing back on within a day, instead of the customer staying on manual until they happened to notice.

What good looks like

After this runs on a schedule, "requires manual renewal" stops being a silent trap that support only discovers from a confused customer email. Genuinely cardless subscriptions still wait for the customer, exactly as intended, but every subscription that already has a usable card gets its automatic billing back within a day.

FAQ

Why did my free trial subscription switch to manual renewal?

A trial subscription needs a saved, reusable payment method to bill automatically once the trial ends. If the setup step that saves the card is never confirmed, WooCommerce Subscriptions has nothing to charge and marks the subscription requires manual renewal instead of failing silently.

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

Yes, when the script confirms Stripe actually has a reusable, non-expired payment method attached to the customer and the subscription is not already automatic. It only changes the renewal mode, never the price or the dates, and dry run mode lets you review the list first.

Will this charge the customer early or change their trial end date?

No. The fix only updates how the next renewal will be billed, from manual to automatic. It does not create a charge, does not move the trial end date, and does not touch subscriptions that are still genuinely on trial with no card on file yet.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how automatic renewal depends on a saved payment method and when a subscription requires manual renewal. woocommerce.com/document/subscriptions/renewal-process
  2. Stripe docs: setting up future payments, including the free trial pattern of a zero amount SetupIntent that saves a card for later. docs.stripe.com/payments/save-and-reuse
  3. WooCommerce Stripe plugin issue: trial subscriptions ending up without a saved source when the setup confirmation is interrupted. github.com/woocommerce/woocommerce-gateway-stripe/issues

On the solution:

  1. Stripe API: list a customer's saved payment methods and check card expiry before reuse. docs.stripe.com/api/payment_methods/list
  2. WooCommerce Subscriptions REST API: read and update a subscription, including the requires_manual_renewal field. woocommerce.github.io/subscriptions-rest-api-docs
  3. WooCommerce REST API: add a note to an order or subscription for an audit trail. 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 trial subscriptions?

If this saved you a pile of confused support tickets about billing that never happened, 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