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.
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.
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:
- A fatal PHP error, a plugin conflict, or a memory limit hit right after the charge request was sent to Stripe but before the order row and its meta finished saving.
- A staging or cloned site with production Stripe keys ran the scheduled action, charged a real card, and then rolled back or never persisted the order because the environment was reset.
- The store's database write timed out or deadlocked during the renewal, so the API call to Stripe went out from a request that itself failed to commit.
- A custom integration or a bulk migration tool charged subscriptions directly through the Stripe API to "catch up" overdue renewals, bypassing WooCommerce Subscriptions entirely, so no order was ever meant to exist for those charges.
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.
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.
Build it step by step
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.
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
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
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.
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
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;
}
}
}
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.
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
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;
}
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.
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")
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"];
}
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.
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
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;
}
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.
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 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 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.
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"
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
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.
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.
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:
- WooCommerce Subscriptions docs: how renewal orders are generated and processed by Action Scheduler. woocommerce.com/document/subscriptions/renewal-process
- WooCommerce docs: Action Scheduler and how failed or stuck scheduled actions are diagnosed. woocommerce.com/document/status-scheduled-actions
- Stripe docs: subscriptions and invoicing have no concept of a merchant's own order records. docs.stripe.com/billing/subscriptions/overview
On the solution:
- Stripe API: list PaymentIntents with auto pagination and a created filter. docs.stripe.com/api/payment_intents/list
- WooCommerce REST API: create an order, read a subscription, and add an order note. woocommerce.github.io/woocommerce-rest-api-docs
- 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.
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