Reconciler WooCommerce Subscriptions: manual renewal and dunning

Dunning stops before its attempts

A renewal failed, the subscription went on-hold, and the retry schedule was supposed to try the card again in a day, then a few days after that. Instead nothing happens. No more attempts run, no email goes out, and the subscription just sits there like it is still mid retry. This is what happens when the dunning schedule quietly dies before it uses every attempt it was configured for, and here is a small job that finds those subscriptions and resumes them.

Python and Node.js Runs on a schedule Safe by default (dry run)
A green gift box with a tag on a table
Photo by Baibhav Kumar on Unsplash
The short answer

The retry schedule is a chain of scheduled actions, and one broken link stops the whole chain, so the subscription is stuck on-hold with retry attempts it never used. Run a small Python or Node.js job on a schedule that reads each on-hold subscription's attempt count from its own meta, waits out the normal gap between attempts, and if the schedule has genuinely gone quiet, charges the next attempt itself against the saved Stripe payment method. Full code, tests, and a dry run guard are below.

The problem in plain words

WooCommerce Subscriptions does not give up on a failed renewal right away. It follows a retry rule you set, something like try again after one day, then three days, then five days, and only cancels or leaves the subscription on-hold for good once every one of those attempts has actually run and failed.

That retry rule depends on a scheduled action being booked for the next attempt every time the current one fails. If that booking step never happens, whether the store's Action Scheduler queue was paused, a worker crashed mid job, or a cron run was skipped, no later attempt is ever scheduled. The subscription is left exactly where the last failed attempt put it: on-hold, one attempt short of the maximum, waiting for a retry that will never come on its own.

Attempt 1 fails books attempt 2 Attempt 2 fails of 3 configured attempt 3 never booked Sub stuck on-hold, forever No email No charge
Each failed attempt is supposed to book the next one. When that booking step is skipped once, the chain ends early and the subscription is left with an attempt it never got to use.

Why it happens

The dunning schedule in WooCommerce Subscriptions runs on Action Scheduler, the same background job system WooCommerce uses for renewals themselves. That makes the retry chain only as reliable as the queue underneath it. A few common reasons the chain snaps early:

WooCommerce Subscriptions support threads describe this as a subscription that "looks like it is retrying" but has not actually attempted a payment in weeks, well past the configured retry window. See the citations at the end for the exact references.

The key insight

Your retry rule is the source of truth for how many attempts a subscription is owed, not whatever Action Scheduler managed to run. If a subscription is on-hold, has not used every attempt your rule allows, and has gone quiet well past the normal wait between attempts, the schedule is broken, not the customer's card. A resume job is a safety net that runs alongside the schedule and picks up any attempt it dropped.

The fix, as a flow

We do not touch the live retry schedule or webhooks. We add a job that runs every few hours, looks at subscriptions that are on-hold, and checks each one's own attempt count against the retry rule. If attempts remain and the subscription has been quiet far longer than the normal gap between tries, we charge the saved payment method for the next attempt ourselves and record it, the same way the missed retry would have.

Scheduled job every few hours List on-hold subscriptions Read attempt count from subscription meta Attempts left and gone quiet? yes no, skip or wait Charge and record saved payment method
The job reads the subscription's own attempt count against your retry rule, and only resumes attempts that are still owed and have gone quiet well past the normal wait. Everything else 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 and orders. 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 DUNNING_MAX_ATTEMPTS="3"
export DUNNING_STALL_HOURS="36"
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 DUNNING_MAX_ATTEMPTS="3"
export DUNNING_STALL_HOURS="36"
export DRY_RUN="true"   // start safe, change to false to write
2

List subscriptions that are on-hold

WooCommerce Subscriptions exposes subscriptions through the same REST namespace as orders. We page through every subscription currently on-hold, since that is the status a failed renewal leaves it in while dunning is in progress.

step2.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 get_subscriptions():
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions",
            params={"status": "on-hold", "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for sub in batch:
            yield sub
        page += 1
step2.js
async function* onHoldSubscriptions() {
  let page = 1;
  while (true) {
    const batch = await woo(`/subscriptions?status=on-hold&per_page=50&page=${page}`);
    if (!batch || !batch.length) return;
    for (const sub of batch) yield sub;
    page++;
  }
}
3

Read the attempt count and the last try time

We store how many retry attempts have run and when the last one happened directly on the subscription as meta, the same field shape WooCommerce uses for other counters. This is what tells us whether a subscription still has attempts owed and whether it has gone quiet long enough to say the schedule broke rather than just being mid wait.

meta.py
def meta_value(obj, key):
    for meta in obj.get("meta_data") or []:
        if meta.get("key") == key and meta.get("value") not in (None, ""):
            return meta["value"]
    return None

def dunning_attempt_count(subscription):
    value = meta_value(subscription, "_dunning_attempt_count")
    try:
        return int(value)
    except (TypeError, ValueError):
        return 0

def hours_since_last_attempt(subscription, now_ts):
    value = meta_value(subscription, "_dunning_last_attempt_ts")
    try:
        last_ts = int(value)
    except (TypeError, ValueError):
        return None
    return max(0, (now_ts - last_ts) / 3600)
meta.js
export function metaValue(obj, key) {
  for (const meta of obj.meta_data || []) {
    if (meta.key === key && meta.value !== undefined && meta.value !== null && meta.value !== "") {
      return meta.value;
    }
  }
  return null;
}

export function dunningAttemptCount(subscription) {
  const value = metaValue(subscription, "_dunning_attempt_count");
  const n = parseInt(value, 10);
  return Number.isFinite(n) ? n : 0;
}

export function hoursSinceLastAttempt(subscription, nowTs) {
  const value = metaValue(subscription, "_dunning_last_attempt_ts");
  const lastTs = parseInt(value, 10);
  if (!Number.isFinite(lastTs)) return null;
  return Math.max(0, (nowTs - lastTs) / 3600);
}
4

Decide, with one pure function

Keep the decision in its own function that takes a subscription, its renewal order, and the current time, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. Skip anything not on-hold or without a renewal order. Mark it exhausted once every configured attempt has run. Wait if the last attempt was recent. Otherwise, resume.

decide.py
STUCK_SUB_STATUSES = {"on-hold"}

def decide(subscription, renewal_order, now_ts, max_attempts=MAX_ATTEMPTS, stall_hours=STALL_HOURS):
    if subscription.get("status") not in STUCK_SUB_STATUSES:
        return ("skip", "subscription is not on-hold")
    if renewal_order is None:
        return ("skip", "no renewal order to retry")
    attempts = dunning_attempt_count(subscription)
    if attempts >= max_attempts:
        return ("exhausted", "every configured retry attempt has already run")
    idle_hours = hours_since_last_attempt(subscription, now_ts)
    if idle_hours is not None and idle_hours < stall_hours:
        return ("wait", "still inside the normal wait between attempts")
    return ("resume", f"attempt {attempts + 1} of {max_attempts} never ran")
decide.js
const STUCK_SUB_STATUSES = new Set(["on-hold"]);

export function decide(subscription, renewalOrder, nowTs, maxAttempts = MAX_ATTEMPTS, stallHours = STALL_HOURS) {
  if (!STUCK_SUB_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not on-hold"];
  }
  if (!renewalOrder) {
    return ["skip", "no renewal order to retry"];
  }
  const attempts = dunningAttemptCount(subscription);
  if (attempts >= maxAttempts) {
    return ["exhausted", "every configured retry attempt has already run"];
  }
  const idleHours = hoursSinceLastAttempt(subscription, nowTs);
  if (idleHours !== null && idleHours < stallHours) {
    return ["wait", "still inside the normal wait between attempts"];
  }
  return ["resume", `attempt ${attempts + 1} of ${maxAttempts} never ran`];
}
5

Charge the next attempt and record it

When the action is resume, read the last known PaymentIntent from order meta _stripe_intent_id, falling back to transaction_id, purely to log what the earlier attempt looked like. Then create a fresh off session PaymentIntent against the saved payment method for the amount of the renewal order, keeping the math in cents. Save the new attempt count and PaymentIntent id, and if it succeeds, move the order and the subscription back to a paid state.

apply.py
def order_amount_minor(order):
    # Works for two decimal currencies. Zero decimal currencies (JPY and friends)
    # have their own guide, since 50.00 is wrong for those.
    return round(float(order["total"]) * 100)

def retry_charge(subscription, order):
    payment_method = meta_value(order, "_stripe_source_id") or meta_value(order, "_payment_method_id")
    customer_id = meta_value(subscription, "_stripe_customer_id")
    return stripe.PaymentIntent.create(
        amount=order_amount_minor(order),
        currency=(order.get("currency") or "usd").lower(),
        customer=customer_id,
        payment_method=payment_method,
        off_session=True,
        confirm=True,
        metadata={"subscription_id": str(subscription["id"]), "order_id": str(order["id"])},
    )

def record_attempt(subscription, order, intent, now_ts):
    attempts = dunning_attempt_count(subscription) + 1
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}",
        json={"meta_data": [
            {"key": "_dunning_attempt_count", "value": str(attempts)},
            {"key": "_dunning_last_attempt_ts", "value": str(int(now_ts))},
        ]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if intent.get("status") == "succeeded":
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
            json={"status": "processing", "transaction_id": intent.get("latest_charge") or intent["id"]},
            auth=AUTH, timeout=30,
        ).raise_for_status()
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}",
            json={"status": "active"}, auth=AUTH, timeout=30,
        ).raise_for_status()
apply.js
export function orderAmountMinor(order) {
  // Works for two decimal currencies. Zero decimal currencies (JPY and friends)
  // have their own guide, since 50.00 is wrong for those.
  return Math.round(parseFloat(order.total) * 100);
}

async function retryCharge(subscription, order) {
  const paymentMethod = metaValue(order, "_stripe_source_id") || metaValue(order, "_payment_method_id");
  const customerId = metaValue(subscription, "_stripe_customer_id");
  return stripe.paymentIntents.create({
    amount: orderAmountMinor(order),
    currency: (order.currency || "usd").toLowerCase(),
    customer: customerId,
    payment_method: paymentMethod,
    off_session: true,
    confirm: true,
    metadata: { subscription_id: String(subscription.id), order_id: String(order.id) },
  });
}

async function recordAttempt(subscription, order, intent, nowTs) {
  const attempts = dunningAttemptCount(subscription) + 1;
  await woo(`/subscriptions/${subscription.id}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_dunning_attempt_count", value: String(attempts) },
        { key: "_dunning_last_attempt_ts", value: String(Math.floor(nowTs)) },
      ],
    }),
  });
  if (intent.status === "succeeded") {
    await woo(`/orders/${order.id}`, {
      method: "PUT",
      body: JSON.stringify({ status: "processing", transaction_id: intent.latest_charge || intent.id }),
    });
    await woo(`/subscriptions/${subscription.id}`, { method: "PUT", body: JSON.stringify({ status: "active" }) });
  }
}
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 which subscriptions it would resume. Read the output, trust it, then switch it off to let it charge. Run it on a schedule with cron every few hours, since renewal retries are spaced out over days, not minutes.

Run it safe

Always start with DRY_RUN=true. This job charges a real card again, so you want to see its plan before it acts. Once the report looks right for a full retry cycle, turn it off.

The full code

Here is the complete resume job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and only acts on subscriptions that are on-hold, have unused attempts, and have gone quiet well past the normal wait.

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

resume_dunning.py
"""Resume a WooCommerce Subscriptions dunning cycle that stopped early.

WooCommerce Subscriptions retries a failed renewal on a schedule (for example
attempt 1 after a day, attempt 2 after three days, attempt 3 after five days),
then only cancels or leaves the subscription on hold once every configured
attempt has run. Sometimes the schedule dies early: a cron miss, a paused
Action Scheduler queue, or a worker that throws before it books the next
retry. The subscription is left on-hold with attempts still unused, and
nothing tries the card again.

This walks subscriptions that are on-hold with unused attempts, reads the
saved Stripe payment method from the renewal order, and if the card has not
already been retried since the subscription went quiet, charges the next
attempt itself and records it, the same way the missed retry would have.

Read the PaymentIntent id from order meta _stripe_intent_id, falling back to
transaction_id. Money math stays in minor units (cents). Safe by default,
DRY_RUN defaults to "true".
"""
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("resume_dunning")

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"),
)
MAX_ATTEMPTS = int(os.environ.get("DUNNING_MAX_ATTEMPTS", "3"))
STALL_HOURS = int(os.environ.get("DUNNING_STALL_HOURS", "36"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

STUCK_SUB_STATUSES = {"on-hold"}


def meta_value(obj, key):
    """Read one value out of a WooCommerce meta_data list."""
    for meta in obj.get("meta_data") or []:
        if meta.get("key") == key and meta.get("value") not in (None, ""):
            return meta["value"]
    return None


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


def dunning_attempt_count(subscription):
    """How many retry attempts have already run, from subscription meta."""
    value = meta_value(subscription, "_dunning_attempt_count")
    try:
        return int(value)
    except (TypeError, ValueError):
        return 0


def hours_since_last_attempt(subscription, now_ts):
    """Hours since the last recorded retry, or None if never recorded."""
    value = meta_value(subscription, "_dunning_last_attempt_ts")
    try:
        last_ts = int(value)
    except (TypeError, ValueError):
        return None
    return max(0, (now_ts - last_ts) / 3600)


def order_amount_minor(order):
    # Works for two decimal currencies. Zero decimal currencies (JPY and friends)
    # have their own guide, since 50.00 is wrong for those.
    return round(float(order["total"]) * 100)


def decide(subscription, renewal_order, now_ts, max_attempts=MAX_ATTEMPTS, stall_hours=STALL_HOURS):
    """Pure decision: should we resume dunning on this subscription right now?

    Returns a (action, reason) tuple. action is one of:
      "skip"   - nothing to do, leave it alone
      "wait"   - attempts remain but the stall window has not passed yet
      "resume" - attempts remain, the schedule has gone quiet, retry now
      "exhausted" - every configured attempt has already run
    """
    if subscription.get("status") not in STUCK_SUB_STATUSES:
        return ("skip", "subscription is not on-hold")
    if renewal_order is None:
        return ("skip", "no renewal order to retry")
    attempts = dunning_attempt_count(subscription)
    if attempts >= max_attempts:
        return ("exhausted", "every configured retry attempt has already run")
    idle_hours = hours_since_last_attempt(subscription, now_ts)
    if idle_hours is not None and idle_hours < stall_hours:
        return ("wait", "still inside the normal wait between attempts")
    return ("resume", f"attempt {attempts + 1} of {max_attempts} never ran")


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


def get_last_renewal_order(subscription):
    order_id = subscription.get("last_order_id") or subscription.get("order_id")
    if not order_id:
        return None
    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 retry_charge(subscription, order):
    """Charge the saved payment method again for the amount of the renewal order."""
    payment_method = meta_value(order, "_stripe_source_id") or meta_value(order, "_payment_method_id")
    customer_id = meta_value(subscription, "_stripe_customer_id")
    intent = stripe.PaymentIntent.create(
        amount=order_amount_minor(order),
        currency=(order.get("currency") or "usd").lower(),
        customer=customer_id,
        payment_method=payment_method,
        off_session=True,
        confirm=True,
        metadata={"subscription_id": str(subscription["id"]), "order_id": str(order["id"])},
    )
    return intent


def record_attempt(subscription, order, intent, now_ts):
    attempts = dunning_attempt_count(subscription) + 1
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}",
        json={"meta_data": [
            {"key": "_dunning_attempt_count", "value": str(attempts)},
            {"key": "_dunning_last_attempt_ts", "value": str(int(now_ts))},
        ]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
        json={"meta_data": [{"key": "_stripe_intent_id", "value": intent["id"]}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    if intent.get("status") == "succeeded":
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}",
            json={"status": "processing", "transaction_id": intent.get("latest_charge") or intent["id"]},
            auth=AUTH, timeout=30,
        ).raise_for_status()
        requests.put(
            f"{WOO_URL}/wp-json/wc/v3/subscriptions/{subscription['id']}",
            json={"status": "active"},
            auth=AUTH, timeout=30,
        ).raise_for_status()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Resumed dunning attempt {attempts}. Stripe PaymentIntent {intent['id']} "
                      f"came back {intent.get('status')}. Triggered by the resume_dunning job "
                      f"because the retry schedule had gone quiet."},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    import time
    now_ts = time.time()
    resumed = 0
    for subscription in get_subscriptions():
        order = get_last_renewal_order(subscription)
        action, reason = decide(subscription, order, now_ts)
        if action in ("skip", "wait"):
            continue
        if action == "exhausted":
            log.info("Subscription %s: %s. Leaving it for a human to cancel or retry manually.",
                      subscription["id"], reason)
            continue
        log.info("Subscription %s: %s. %s", subscription["id"], reason,
                  "would resume" if DRY_RUN else "resuming")
        if not DRY_RUN:
            intent_id = intent_id_of(order)
            log.info("Last known PaymentIntent for order %s was %s", order["id"], intent_id)
            intent = retry_charge(subscription, order)
            record_attempt(subscription, order, intent, now_ts)
        resumed += 1
    log.info("Done. %d subscription(s) %s.", resumed, "to resume" if DRY_RUN else "resumed")


if __name__ == "__main__":
    run()
resume-dunning.js
/**
 * Resume a WooCommerce Subscriptions dunning cycle that stopped early.
 *
 * WooCommerce Subscriptions retries a failed renewal on a schedule (for example
 * attempt 1 after a day, attempt 2 after three days, attempt 3 after five days),
 * then only cancels or leaves the subscription on hold once every configured
 * attempt has run. Sometimes the schedule dies early: a cron miss, a paused
 * Action Scheduler queue, or a worker that throws before it books the next
 * retry. The subscription is left on-hold with attempts still unused, and
 * nothing tries the card again.
 *
 * This walks subscriptions that are on-hold with unused attempts, reads the
 * saved Stripe payment method from the renewal order, and if the card has not
 * already been retried since the subscription went quiet, charges the next
 * attempt itself and records it, the same way the missed retry would have.
 *
 * Read the PaymentIntent id from order meta _stripe_intent_id, falling back
 * to transaction_id. Money math stays in minor units (cents). Safe by
 * default, DRY_RUN defaults to "true".
 */
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 MAX_ATTEMPTS = Number(process.env.DUNNING_MAX_ATTEMPTS || 3);
const STALL_HOURS = Number(process.env.DUNNING_STALL_HOURS || 36);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const STUCK_SUB_STATUSES = new Set(["on-hold"]);

export function metaValue(obj, key) {
  for (const meta of obj.meta_data || []) {
    if (meta.key === key && meta.value !== undefined && meta.value !== null && meta.value !== "") {
      return meta.value;
    }
  }
  return null;
}

export function intentIdOf(order) {
  const value = metaValue(order, "_stripe_intent_id");
  if (value) return value;
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function dunningAttemptCount(subscription) {
  const value = metaValue(subscription, "_dunning_attempt_count");
  const n = parseInt(value, 10);
  return Number.isFinite(n) ? n : 0;
}

export function hoursSinceLastAttempt(subscription, nowTs) {
  const value = metaValue(subscription, "_dunning_last_attempt_ts");
  const lastTs = parseInt(value, 10);
  if (!Number.isFinite(lastTs)) return null;
  return Math.max(0, (nowTs - lastTs) / 3600);
}

export function orderAmountMinor(order) {
  // Works for two decimal currencies. Zero decimal currencies (JPY and friends)
  // have their own guide, since 50.00 is wrong for those.
  return Math.round(parseFloat(order.total) * 100);
}

/**
 * Pure decision: should we resume dunning on this subscription right now?
 * Returns ["skip" | "wait" | "resume" | "exhausted", reason].
 */
export function decide(subscription, renewalOrder, nowTs, maxAttempts = MAX_ATTEMPTS, stallHours = STALL_HOURS) {
  if (!STUCK_SUB_STATUSES.has(subscription.status)) {
    return ["skip", "subscription is not on-hold"];
  }
  if (!renewalOrder) {
    return ["skip", "no renewal order to retry"];
  }
  const attempts = dunningAttemptCount(subscription);
  if (attempts >= maxAttempts) {
    return ["exhausted", "every configured retry attempt has already run"];
  }
  const idleHours = hoursSinceLastAttempt(subscription, nowTs);
  if (idleHours !== null && idleHours < stallHours) {
    return ["wait", "still inside the normal wait between attempts"];
  }
  return ["resume", `attempt ${attempts + 1} of ${maxAttempts} never ran`];
}

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

async function getLastRenewalOrder(subscription) {
  const orderId = subscription.last_order_id || subscription.order_id;
  if (!orderId) return null;
  return woo(`/orders/${orderId}`);
}

async function retryCharge(subscription, order) {
  const paymentMethod = metaValue(order, "_stripe_source_id") || metaValue(order, "_payment_method_id");
  const customerId = metaValue(subscription, "_stripe_customer_id");
  return stripe.paymentIntents.create({
    amount: orderAmountMinor(order),
    currency: (order.currency || "usd").toLowerCase(),
    customer: customerId,
    payment_method: paymentMethod,
    off_session: true,
    confirm: true,
    metadata: { subscription_id: String(subscription.id), order_id: String(order.id) },
  });
}

async function recordAttempt(subscription, order, intent, nowTs) {
  const attempts = dunningAttemptCount(subscription) + 1;
  await woo(`/subscriptions/${subscription.id}`, {
    method: "PUT",
    body: JSON.stringify({
      meta_data: [
        { key: "_dunning_attempt_count", value: String(attempts) },
        { key: "_dunning_last_attempt_ts", value: String(Math.floor(nowTs)) },
      ],
    }),
  });
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: "_stripe_intent_id", value: intent.id }] }),
  });
  if (intent.status === "succeeded") {
    await woo(`/orders/${order.id}`, {
      method: "PUT",
      body: JSON.stringify({ status: "processing", transaction_id: intent.latest_charge || intent.id }),
    });
    await woo(`/subscriptions/${subscription.id}`, {
      method: "PUT",
      body: JSON.stringify({ status: "active" }),
    });
  }
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Resumed dunning attempt ${attempts}. Stripe PaymentIntent ${intent.id} came back ` +
            `${intent.status}. Triggered by the resume-dunning job because the retry schedule had gone quiet.`,
    }),
  });
}

export async function run() {
  const nowTs = Date.now() / 1000;
  let resumed = 0;
  for await (const subscription of onHoldSubscriptions()) {
    const order = await getLastRenewalOrder(subscription);
    const [action, reason] = decide(subscription, order, nowTs);
    if (action === "skip" || action === "wait") continue;
    if (action === "exhausted") {
      console.log(`Subscription ${subscription.id}: ${reason}. Leaving it for a human to cancel or retry manually.`);
      continue;
    }
    console.log(`Subscription ${subscription.id}: ${reason}. ${DRY_RUN ? "would resume" : "resuming"}`);
    if (!DRY_RUN) {
      const intentId = intentIdOf(order);
      console.log(`Last known PaymentIntent for order ${order.id} was ${intentId}`);
      const intent = await retryCharge(subscription, order);
      await recordAttempt(subscription, order, intent, nowTs);
    }
    resumed++;
  }
  console.log(`Done. ${resumed} subscription(s) ${DRY_RUN ? "to resume" : "resumed"}.`);
}

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

Add a test

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

test_dunning_decide.py
from resume_dunning import decide

NOW = 1_800_000_000
DAY = 86400


def sub(**over):
    base = {
        "status": "on-hold",
        "meta_data": [
            {"key": "_dunning_attempt_count", "value": "1"},
            {"key": "_dunning_last_attempt_ts", "value": str(NOW - 2 * DAY)},
        ],
    }
    base.update(over)
    return base


def order(**over):
    base = {"status": "on-hold", "total": "50.00"}
    base.update(over)
    return base


def test_resume_when_stalled_with_attempts_left():
    assert decide(sub(), order(), NOW)[0] == "resume"


def test_wait_when_inside_the_normal_window():
    recent = sub(meta_data=[
        {"key": "_dunning_attempt_count", "value": "1"},
        {"key": "_dunning_last_attempt_ts", "value": str(NOW - 3600)},
    ])
    assert decide(recent, order(), NOW)[0] == "wait"


def test_exhausted_when_every_attempt_ran():
    maxed = sub(meta_data=[
        {"key": "_dunning_attempt_count", "value": "3"},
        {"key": "_dunning_last_attempt_ts", "value": str(NOW - 5 * DAY)},
    ])
    assert decide(maxed, order(), NOW)[0] == "exhausted"


def test_skip_when_subscription_not_on_hold():
    assert decide(sub(status="active"), order(), NOW)[0] == "skip"


def test_skip_when_no_renewal_order():
    assert decide(sub(), None, NOW)[0] == "skip"
resume-dunning.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./resume-dunning.js";

const NOW = 1_800_000_000;
const DAY = 86400;

const sub = (over = {}) => ({
  status: "on-hold",
  meta_data: [
    { key: "_dunning_attempt_count", value: "1" },
    { key: "_dunning_last_attempt_ts", value: String(NOW - 2 * DAY) },
  ],
  ...over,
});

const order = (over = {}) => ({ status: "on-hold", total: "50.00", ...over });

test("resume when stalled with attempts left", () => {
  assert.equal(decide(sub(), order(), NOW)[0], "resume");
});

test("wait when inside the normal window", () => {
  const recent = sub({
    meta_data: [
      { key: "_dunning_attempt_count", value: "1" },
      { key: "_dunning_last_attempt_ts", value: String(NOW - 3600) },
    ],
  });
  assert.equal(decide(recent, order(), NOW)[0], "wait");
});

test("exhausted when every attempt ran", () => {
  const maxed = sub({
    meta_data: [
      { key: "_dunning_attempt_count", value: "3" },
      { key: "_dunning_last_attempt_ts", value: String(NOW - 5 * DAY) },
    ],
  });
  assert.equal(decide(maxed, order(), NOW)[0], "exhausted");
});

test("skip when subscription not on-hold", () => {
  assert.equal(decide(sub({ status: "active" }), order(), NOW)[0], "skip");
});

test("skip when no renewal order", () => {
  assert.equal(decide(sub(), null, NOW)[0], "skip");
});

Case studies

Paused queue

The maintenance plugin that paused Action Scheduler

A store installed a performance plugin that paused background processing during peak hours to save server load. It paused Action Scheduler along with everything else, and the retries for that week's failed renewals never got booked past attempt one.

Weeks later, a support ticket asked why a customer's subscription was "still retrying" with no email in sight. The resume job found eleven subscriptions stuck the same way and cleared the backlog in dry run first, then for real.

Silent fatal

The plugin conflict that ate one hook

An unrelated plugin update introduced a fatal error that only fired during the specific request that handled retry scheduling for one payment gateway. Every other part of the site kept working, so nobody noticed for ten days.

The stall window in the resume job, set comfortably above the normal one to three day gap between attempts, caught every subscription that had gone quiet that long and resumed them without needing to find the plugin conflict first.

What good looks like

After this runs on a schedule, a broken retry chain is no longer a silent, indefinite hold. The worst case becomes a delay of a few hours before the resume job picks up the attempt the schedule dropped. Keep it running even after you find and fix whatever paused the queue, because it will happen again eventually.

FAQ

Why did my subscription stop retrying before it reached the last attempt?

The retry schedule is a chain of scheduled actions. If one link breaks, for example a cron miss or a paused Action Scheduler queue, no later attempt ever gets booked, and the subscription is left on-hold looking like it is mid retry forever. A job that finds subscriptions that have gone quiet past the normal wait and resumes the next attempt fixes it.

Is it safe to charge a card again with a script?

Yes, when the script only acts on subscriptions that are on-hold, still have unused attempts left under your own retry rule, and have gone quiet well past the normal wait between attempts. Start in dry run mode to see the exact list before it charges anything.

What happens once every configured attempt has run?

The job leaves it alone. Once the attempt count reaches your configured maximum, that subscription is marked exhausted and logged so a person can decide whether to cancel it or give the customer one more manual try.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how the renewal retry schedule and dunning rules work. woocommerce.com/document/subscriptions/failed-payment-retry
  2. Action Scheduler documentation: how scheduled actions are queued, run, and can stall. actionscheduler.org
  3. WooCommerce Subscriptions support: subscription on-hold with no further retry activity. wordpress.org/support/plugin/woocommerce-subscriptions

On the solution:

  1. Stripe docs: creating off session PaymentIntents to charge a saved payment method without the customer present. docs.stripe.com/payments/save-and-reuse
  2. Stripe API: the PaymentIntents create endpoint and its off_session and confirm parameters. docs.stripe.com/api/payment_intents/create
  3. WooCommerce REST API: reading and updating subscriptions and orders, including meta_data. 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 stalled dunning?

If this saved you a pile of support tickets or a subscription you almost lost, 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