Repair Subscription lifecycle

Renewal charged, no order made

Stripe shows a renewal that went through. The customer's card was billed, the invoice is marked paid, and the subscription's next billing date has already moved forward. But WooCommerce has no renewal order for that charge anywhere in the order list. No confirmation email went out, nothing was added to their order history, and your books are missing a sale that already happened. Here is why the order never gets made and a small script that finds every orphaned renewal charge and creates the order it should have had.

Python and Node.js Runs on a schedule Safe by default (dry run)
A hand holding a paper with a bar code
Photo by Am on Unsplash
The short answer

WooCommerce Subscriptions is supposed to create a renewal order first, then charge it through Stripe. When that creation step fails partway, silently times out, or the scheduled action never fires at all, Stripe still processes and keeps the charge, but no renewal order exists to hold it. Run a small Python or Node.js script on a schedule that lists recent succeeded renewal charges from Stripe, checks whether a matching renewal order already exists on the subscription, and creates the missing order when it does not. Full code, tests, and a dry run guard are below.

The problem in plain words

A normal renewal in WooCommerce Subscriptions happens in a fixed order. First, a scheduled action fires and creates a new renewal order tied to the subscription, copied from the original items and totals. Only after that order exists does the store ask Stripe to charge the saved card against it. When Stripe confirms the charge, the order is marked paid and the subscription's dates move forward.

That works as long as every step completes. If the order creation step throws an error, if the site hits a fatal error partway through, or if the scheduled action queue drops the job after the charge was already requested, you can end up with the charge succeeding on Stripe's side while the WooCommerce order that should hold it was never written to the database. The subscription still advances because Stripe's webhook or a retried action nudges it forward, but the specific order is gone. The money is real. The record of it is not.

Renewal due scheduled action fires Stripe charges saved card, succeeded order creation failed No renewal order nothing was saved No email No order history
The money is taken at the charge step. No order survives to hold it, because the order creation step broke before or during the charge.

Why it happens

WooCommerce runs subscription renewals through Action Scheduler, a background job queue built into WooCommerce Core. That queue is reliable, but it is not immune to the environment it runs in. A few common ways the order creation step disappears while the charge still lands:

WooCommerce Subscriptions documents the expected renewal order flow and treats a missing renewal order as a data integrity gap, not a normal state. Stripe's own subscriptions and invoicing docs are explicit that Stripe has no concept of a WooCommerce order, so nothing on Stripe's side will ever create one for you. See the citations at the end for the exact references.

The key insight

Stripe is the source of truth for money, but WooCommerce is the source of truth for order history, inventory, and taxes. When a renewal charge exists on Stripe with metadata pointing at a real subscription, and no order on that subscription matches it, the correct fix is to create the order, not to touch the charge. The charge already happened. The record simply needs to catch up to it.

The fix, as a flow

We do not touch live checkout or live renewals. We add a job that runs every few minutes, looks at recent succeeded renewal charges on Stripe, and checks whether the subscription those charges belong to already has a matching renewal order. If it does not, we create a new renewal order the same way WooCommerce Subscriptions would, mark it paid with the Stripe charge attached, and link it back to the subscription.

Scheduled job every few minutes List succeeded renewal charges (last 48h) Read subscription_id from metadata Order already exists for this charge? yes, skip no Create order mark processing, link sub
The script reads the truth from Stripe and only creates an order when a real succeeded charge has no matching order already on file. 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 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 LOOKBACK_HOURS="48"
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_HOURS="48"
export DRY_RUN="true"   // start safe, change to false to write
2

List the succeeded renewal charges from Stripe

Ask Stripe for PaymentIntents created in your lookback window. We page through all of them and keep only the ones with status succeeded that carry a subscription_id in their metadata, since that is what WooCommerce Subscriptions writes onto every renewal charge it initiates.

step2.py
import os, time, stripe

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

def recent_renewal_charges(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
        if intent.status == "succeeded" and intent.metadata.get("subscription_id"):
            yield intent
step2.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

async function* recentRenewalCharges(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
    if (intent.status === "succeeded" && intent.metadata.subscription_id) {
      yield intent;
    }
  }
}
3

Check the subscription's existing renewal orders

Use the WooCommerce REST API to read the subscription's related order IDs, then load each of those orders and read the saved PaymentIntent id from order meta _stripe_intent_id or the order's transaction_id. If none of them matches the charge we are looking at, no order exists for it yet.

step3.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 get_subscription(sub_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/subscriptions/{sub_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()

def intent_id_of(order):
    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 has_order_for_intent(subscription, intent_id):
    for related_id in subscription.get("related_orders", {}).get("renewal", []):
        r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{related_id}", auth=AUTH, timeout=30)
        if r.status_code == 404:
            continue
        r.raise_for_status()
        if intent_id_of(r.json()) == intent_id:
            return True
    return False
step3.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

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

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 hasOrderForIntent(subscription, intentId) {
  const renewalIds = (subscription.related_orders && subscription.related_orders.renewal) || [];
  for (const relatedId of renewalIds) {
    const order = await woo(`/orders/${relatedId}`);
    if (order && intentIdOf(order) === intentId) return true;
  }
  return false;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a subscription, an intent, and a flag for whether a matching order was already found, 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. If the intent is not succeeded, skip it. If the subscription is missing, flag it as an orphan. If a matching order already exists, skip it. Otherwise, create the order.

decide.py
def amount_minor_from_decimal(amount_str):
    # 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(amount_str) * 100)

def decide(subscription, intent, order_already_exists):
    if intent.get("status") != "succeeded":
        return ("skip", "intent not succeeded")
    if subscription is None:
        return ("orphan", "subscription not found")
    if order_already_exists:
        return ("skip", "renewal order already exists for this charge")
    return ("create", "charged on Stripe, no renewal order on file")
decide.js
export function amountMinorFromDecimal(amountStr) {
  // 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(amountStr) * 100);
}

export function decide(subscription, intent, orderAlreadyExists) {
  if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
  if (!subscription) return ["orphan", "subscription not found"];
  if (orderAlreadyExists) return ["skip", "renewal order already exists for this charge"];
  return ["create", "charged on Stripe, no renewal order on file"];
}
5

Create the renewal order the way WooCommerce Subscriptions would

When the action is create, build a new order with the subscription's line items and totals, set it to Processing, save the PaymentIntent id as the transaction ID, and link the order back to the parent subscription with the _subscription_renewal meta key. Then add an order note so the shop manager can see the order was created after the fact and why.

apply.py
def build_renewal_payload(subscription, intent):
    charge_id = intent.get("latest_charge") or intent["id"]
    return {
        "status": "processing",
        "customer_id": subscription["customer_id"],
        "payment_method": subscription.get("payment_method", "stripe"),
        "payment_method_title": subscription.get("payment_method_title", "Credit card (Stripe)"),
        "transaction_id": charge_id,
        "line_items": [
            {"product_id": item["product_id"], "quantity": item["quantity"]}
            for item in subscription.get("line_items", [])
        ],
        "meta_data": [
            {"key": "_stripe_intent_id", "value": intent["id"]},
            {"key": "_subscription_renewal", "value": str(subscription["id"])},
        ],
    }

def create_renewal_order(subscription, intent):
    payload = build_renewal_payload(subscription, intent)
    r = requests.post(f"{WOO_URL}/wp-json/wc/v3/orders", json=payload, auth=AUTH, timeout=30)
    r.raise_for_status()
    order = r.json()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Created after the fact from Stripe PaymentIntent {intent['id']}. "
                      f"The renewal charge succeeded on Stripe but the store never made an order for it."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    return order
apply.js
function buildRenewalPayload(subscription, intent) {
  const chargeId = intent.latest_charge || intent.id;
  return {
    status: "processing",
    customer_id: subscription.customer_id,
    payment_method: subscription.payment_method || "stripe",
    payment_method_title: subscription.payment_method_title || "Credit card (Stripe)",
    transaction_id: chargeId,
    line_items: (subscription.line_items || []).map((item) => ({
      product_id: item.product_id,
      quantity: item.quantity,
    })),
    meta_data: [
      { key: "_stripe_intent_id", value: intent.id },
      { key: "_subscription_renewal", value: String(subscription.id) },
    ],
  };
}

async function createRenewalOrder(subscription, intent) {
  const order = await woo("/orders", {
    method: "POST",
    body: JSON.stringify(buildRenewalPayload(subscription, intent)),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Created after the fact from Stripe PaymentIntent ${intent.id}. ` +
            `The renewal charge succeeded on Stripe but the store never made an order for it.`,
    }),
  });
  return order;
}
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 create. Read the output, trust it, then switch it off to let it write. Run it on a schedule with cron every fifteen to thirty minutes, since renewals do not need second by second coverage.

Run it safe

Always start with DRY_RUN=true. This script writes new orders into your store, 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 script 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 creates a second order for a charge that already has one.

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

create_missing_renewal.py
"""Create the WooCommerce renewal order for a Stripe renewal charge that succeeded
with no order behind it. Run on a schedule. Safe to run again and again.
"""
import os
import time
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("create_missing_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"])
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "48"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def recent_renewal_charges(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    for intent in stripe.PaymentIntent.list(limit=100, created={"gte": since}).auto_paging_iter():
        if intent.status == "succeeded" and intent.metadata.get("subscription_id"):
            yield intent


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


def intent_id_of(order):
    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 has_order_for_intent(subscription, intent_id):
    for related_id in subscription.get("related_orders", {}).get("renewal", []):
        r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{related_id}", auth=AUTH, timeout=30)
        if r.status_code == 404:
            continue
        r.raise_for_status()
        if intent_id_of(r.json()) == intent_id:
            return True
    return False


def amount_minor_from_decimal(amount_str):
    return round(float(amount_str) * 100)


def decide(subscription, intent, order_already_exists):
    if intent.get("status") != "succeeded":
        return ("skip", "intent not succeeded")
    if subscription is None:
        return ("orphan", "subscription not found")
    if order_already_exists:
        return ("skip", "renewal order already exists for this charge")
    return ("create", "charged on Stripe, no renewal order on file")


def build_renewal_payload(subscription, intent):
    charge_id = intent.get("latest_charge") or intent["id"]
    return {
        "status": "processing",
        "customer_id": subscription["customer_id"],
        "payment_method": subscription.get("payment_method", "stripe"),
        "payment_method_title": subscription.get("payment_method_title", "Credit card (Stripe)"),
        "transaction_id": charge_id,
        "line_items": [
            {"product_id": item["product_id"], "quantity": item["quantity"]}
            for item in subscription.get("line_items", [])
        ],
        "meta_data": [
            {"key": "_stripe_intent_id", "value": intent["id"]},
            {"key": "_subscription_renewal", "value": str(subscription["id"])},
        ],
    }


def create_renewal_order(subscription, intent):
    payload = build_renewal_payload(subscription, intent)
    r = requests.post(f"{WOO_URL}/wp-json/wc/v3/orders", json=payload, auth=AUTH, timeout=30)
    r.raise_for_status()
    order = r.json()
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/notes",
        json={"note": f"Created after the fact from Stripe PaymentIntent {intent['id']}. "
                      f"The renewal charge succeeded on Stripe but the store never made an order for it."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    return order


def run():
    created = 0
    for intent in recent_renewal_charges(LOOKBACK_HOURS):
        sub_id = intent.metadata["subscription_id"]
        subscription = get_subscription(sub_id)
        already_exists = bool(subscription) and has_order_for_intent(subscription, intent["id"])
        action, reason = decide(subscription, intent, already_exists)
        if action == "orphan":
            log.warning("Intent %s points to subscription %s which is missing", intent.id, sub_id)
            continue
        if action == "skip":
            continue
        log.info("Subscription %s: %s. %s", sub_id, reason, "would create" if DRY_RUN else "creating")
        if not DRY_RUN:
            create_renewal_order(subscription, intent)
        created += 1
    log.info("Done. %d order(s) %s.", created, "to create" if DRY_RUN else "created")


if __name__ == "__main__":
    run()
create-missing-renewal.js
/**
 * Create the WooCommerce renewal order for a Stripe renewal charge that succeeded
 * with no order behind it. 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 LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 48);
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();
}

async function* recentRenewalCharges(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const intent of stripe.paymentIntents.list({ limit: 100, created: { gte: since } })) {
    if (intent.status === "succeeded" && intent.metadata.subscription_id) yield intent;
  }
}

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 hasOrderForIntent(subscription, intentId) {
  const renewalIds = (subscription.related_orders && subscription.related_orders.renewal) || [];
  for (const relatedId of renewalIds) {
    const order = await woo(`/orders/${relatedId}`);
    if (order && intentIdOf(order) === intentId) return true;
  }
  return false;
}

function amountMinorFromDecimal(amountStr) {
  return Math.round(parseFloat(amountStr) * 100);
}

function decide(subscription, intent, orderAlreadyExists) {
  if (intent.status !== "succeeded") return ["skip", "intent not succeeded"];
  if (!subscription) return ["orphan", "subscription not found"];
  if (orderAlreadyExists) return ["skip", "renewal order already exists for this charge"];
  return ["create", "charged on Stripe, no renewal order on file"];
}

function buildRenewalPayload(subscription, intent) {
  const chargeId = intent.latest_charge || intent.id;
  return {
    status: "processing",
    customer_id: subscription.customer_id,
    payment_method: subscription.payment_method || "stripe",
    payment_method_title: subscription.payment_method_title || "Credit card (Stripe)",
    transaction_id: chargeId,
    line_items: (subscription.line_items || []).map((item) => ({
      product_id: item.product_id,
      quantity: item.quantity,
    })),
    meta_data: [
      { key: "_stripe_intent_id", value: intent.id },
      { key: "_subscription_renewal", value: String(subscription.id) },
    ],
  };
}

async function createRenewalOrder(subscription, intent) {
  const order = await woo("/orders", {
    method: "POST",
    body: JSON.stringify(buildRenewalPayload(subscription, intent)),
  });
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Created after the fact from Stripe PaymentIntent ${intent.id}. ` +
            `The renewal charge succeeded on Stripe but the store never made an order for it.`,
    }),
  });
  return order;
}

async function run() {
  let created = 0;
  for await (const intent of recentRenewalCharges(LOOKBACK_HOURS)) {
    const subId = intent.metadata.subscription_id;
    const subscription = await woo(`/subscriptions/${subId}`);
    const alreadyExists = subscription ? await hasOrderForIntent(subscription, intent.id) : false;
    const [action, reason] = decide(subscription, intent, alreadyExists);
    if (action === "orphan") { console.warn(`Intent ${intent.id} points to missing subscription ${subId}`); continue; }
    if (action === "skip") continue;
    console.log(`Subscription ${subId}: ${reason}. ${DRY_RUN ? "would create" : "creating"}`);
    if (!DRY_RUN) await createRenewalOrder(subscription, intent);
    created++;
  }
  console.log(`Done. ${created} order(s) ${DRY_RUN ? "to create" : "created"}.`);
}

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 new order gets written into real order history. 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_renewal_decide.py
from create_missing_renewal import decide


def intent(**over):
    base = {"status": "succeeded", "id": "pi_1"}
    base.update(over)
    return base


def test_create_when_charged_and_no_order():
    subscription = {"id": 42, "customer_id": 7}
    assert decide(subscription, intent(), False)[0] == "create"


def test_skip_when_order_already_exists():
    subscription = {"id": 42, "customer_id": 7}
    assert decide(subscription, intent(), True)[0] == "skip"


def test_skip_when_intent_not_succeeded():
    subscription = {"id": 42, "customer_id": 7}
    assert decide(subscription, intent(status="requires_payment_method"), False)[0] == "skip"


def test_orphan_when_subscription_missing():
    assert decide(None, intent(), False)[0] == "orphan"
decide.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide } from "./create-missing-renewal.js";

const intent = (over = {}) => ({ status: "succeeded", id: "pi_1", ...over });

test("create when charged and no order", () => {
  assert.equal(decide({ id: 42, customer_id: 7 }, intent(), false)[0], "create");
});

test("skip when order already exists", () => {
  assert.equal(decide({ id: 42, customer_id: 7 }, intent(), true)[0], "skip");
});

test("skip when intent not succeeded", () => {
  assert.equal(decide({ id: 42, customer_id: 7 }, intent({ status: "requires_payment_method" }), false)[0], "skip");
});

test("orphan when subscription missing", () => {
  assert.equal(decide(null, intent(), false)[0], "orphan");
});

Case studies

Fatal error mid renewal

The plugin update that broke renewal day

A store updated a shipping plugin the night before a large batch of renewals ran. The update introduced a fatal error that fired partway through the renewal action, right after Stripe had already been asked to charge the card. Sixty renewals charged successfully overnight with zero matching orders anywhere in WooCommerce.

The script ran in dry run first thing in the morning, listed the exact sixty charges with their subscription IDs, and after a quick review the team switched it to write mode. All sixty renewal orders were created with the right totals and the right note explaining why they showed up late.

Staging leaked live keys

The clone that billed real customers

A staging copy of the store was created for testing and accidentally kept the live Stripe keys. A scheduled renewal ran on staging, charged real customers, and updated the subscription's next billing date, but the staging database was wiped an hour later, taking the renewal orders with it.

Because Stripe still had the succeeded charges with the subscription IDs in their metadata, the script recreated the missing renewal orders on the production site once it was pointed at the right store, closing the gap between what customers were billed and what the store's records showed.

What good looks like

After this runs on a schedule, a renewal that charges without an order stops being an invisible billing dispute waiting to happen. The worst case becomes a short delay before the script creates the order and sends the confirmation the customer was expecting. Keep it running even after you find the root cause, since a fatal error at the wrong moment can always slip through again.

FAQ

Why did Stripe charge a renewal but WooCommerce has no order for it?

WooCommerce Subscriptions is supposed to create a renewal order first and then charge it. If that scheduled job errors out, times out, or the store misses the step that creates the order, Stripe still holds the successful charge but no renewal order was ever made to record it. A script that reads recent successful renewal charges from Stripe and creates the missing order fixes it.

Is it safe to create an order automatically from a script?

Yes, when the script only acts on a charge that is confirmed succeeded on Stripe, is tied to a real subscription and customer, and has no matching renewal order already on file. Start in dry run mode so you can review the exact list of charges before anything is created.

How do I stop this from happening again?

You cannot fully prevent an occasional missed renewal order, since the scheduled job can still fail from a timeout or a server error. Keep the script running on a schedule as a safety net so any gap gets closed within minutes instead of turning into a billing dispute.

Related field notes

Citations

On the problem:

  1. WooCommerce Subscriptions docs: how renewal orders are generated and processed by Action Scheduler. woocommerce.com/document/subscriptions/renewal-process
  2. WooCommerce docs: Action Scheduler and how failed or stuck scheduled actions are diagnosed. woocommerce.com/document/status-scheduled-actions
  3. Stripe docs: subscriptions and invoicing have no concept of a merchant's own order records. docs.stripe.com/billing/subscriptions/overview

On the solution:

  1. Stripe API: list PaymentIntents with auto pagination and a created filter. docs.stripe.com/api/payment_intents/list
  2. WooCommerce REST API: create an order, read a subscription, and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
  3. WooCommerce Subscriptions REST API: subscription resource and its related orders. woocommerce.com/document/subscriptions/develop/rest-api

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 missing renewal orders?

If this saved you a pile of confused customers or a reconciliation headache, 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