Repair Catalog, metadata, and scheduling
Sync products to Stripe
A subscription plan sells fine in WooCommerce. Renewals process. Then someone opens Stripe to check billing and there is no Product, no Price, nothing to point to. Products are missing on the Stripe side, so anything that depends on a real Stripe Price, from Stripe-hosted invoices to reporting to a second integration, has nothing to read. Here is why it happens and a small script that creates the matching Stripe products.
A WooCommerce product only gets a Stripe Product and Price when the plugin's own checkout or renewal flow first bills it. Products added by import, duplication, or before the gateway was fully wired up can sit for months with nothing behind them in Stripe. Run a small Python or Node.js script on a schedule that reads each published product, checks the saved Stripe ids in product meta, and creates whatever is missing, then writes the new Stripe Product and Price ids back onto the product. Full code, tests, and a dry run guard are below.
The problem in plain words
In WooCommerce, a product is a row in your catalog: a name, a price, a type. In Stripe, billing runs on two different objects, a Product and a Price. WooCommerce is supposed to create both automatically the first time a customer buys or renews that item through Stripe. Most of the time it does.
But that link is written lazily, only at the moment of a real charge. A product that was imported from another store, duplicated from an existing one, or created while the Stripe gateway was misconfigured can go through its entire life in WooCommerce, showing up on the storefront and even taking orders through a different method, without Stripe ever hearing about it. The gap is invisible until something needs the Stripe side directly, like a Stripe-hosted invoice, a finance export, or a second system reading price data straight from Stripe.
Why it happens
The WooCommerce and Stripe docs describe the normal path: the gateway creates a Stripe Product and Price for an item the first time it is billed through Stripe, and reuses them after that. When that first billing moment never happens through the expected path, the link is never made. A few common reasons:
- The product was imported or duplicated from another product or another store, and only the WooCommerce row came across, not the Stripe object behind it.
- A different payment gateway processed the product's early orders, so Stripe was never involved for that item.
- The product's Stripe Product or Price was deleted or archived directly in the Stripe dashboard, usually by mistake during cleanup.
- The store switched from WooPayments to Stripe, or from one Stripe account to another, and the old Stripe ids on the product now point at an account that no longer has them.
This shows up most on WooCommerce Subscriptions catalogs, where a plan can renew successfully through a saved card for a while even though it was never formally billed as a Stripe-priced product, until a report, a migration, or a second integration goes looking for the Stripe side and comes up empty.
WooCommerce is the source of truth for the catalog: the name, the price, whether the product is for sale. Stripe only needs to mirror what WooCommerce already says. A sync script is not a guess, it reads what WooCommerce has, checks whether Stripe has a matching Product and Price, and creates only what is missing. Nothing about the WooCommerce product itself needs to change.
The fix, as a flow
We do not touch checkout or renewals. We add a job that walks published WooCommerce products, reads the Stripe Product and Price ids saved in product meta, and asks Stripe whether those ids still point at something real. If nothing exists yet, or the price on file has drifted from what WooCommerce now charges, the script creates what is missing in Stripe and writes the fresh ids back onto the product, ready for anything that needs them next.
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 products. 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 DEFAULT_CURRENCY="usd"
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 DEFAULT_CURRENCY="usd"
export DRY_RUN="true" // start safe, change to false to write
List the published WooCommerce products
Page through every product with status publish using the WooCommerce REST API. Draft and private products are skipped, since they are not being sold yet and do not need a Stripe side.
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 woo_products():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/products",
params={"status": "publish", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for product in batch:
yield product
page += 1
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
`${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function* wooProducts() {
let page = 1;
while (true) {
const batch = await woo(`/products?status=publish&per_page=50&page=${page}`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
Look up the saved Stripe ids and ask Stripe if they still exist
WooCommerce stores the Stripe Product and Price id for a product as meta, in _stripe_product_id and _stripe_price_id. Read those, then ask Stripe to retrieve each one. If either id is missing, or Stripe returns a 404 for it, treat it as not existing. That is the state that needs fixing.
import stripe
def stripe_ids_of(product):
product_id = None
price_id = None
for meta in product.get("meta_data") or []:
if meta.get("key") == "_stripe_product_id" and meta.get("value"):
product_id = meta["value"]
if meta.get("key") == "_stripe_price_id" and meta.get("value"):
price_id = meta["value"]
return product_id, price_id
def get_stripe_product(product_id):
if not product_id:
return None
try:
return stripe.Product.retrieve(product_id)
except stripe.error.InvalidRequestError:
return None
def get_stripe_price(price_id):
if not price_id:
return None
try:
return stripe.Price.retrieve(price_id)
except stripe.error.InvalidRequestError:
return None
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export function stripeIdsOf(product) {
let productId = null;
let priceId = null;
for (const meta of product.meta_data || []) {
if (meta.key === "_stripe_product_id" && meta.value) productId = meta.value;
if (meta.key === "_stripe_price_id" && meta.value) priceId = meta.value;
}
return [productId, priceId];
}
async function getStripeProduct(productId) {
if (!productId) return null;
try {
return await stripe.products.retrieve(productId);
} catch {
return null;
}
}
async function getStripePrice(priceId) {
if (!priceId) return null;
try {
return await stripe.prices.retrieve(priceId);
} catch {
return null;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the WooCommerce product, the Stripe product it found (or none), and the Stripe price it found (or none), and returns an action. A pure function like this is easy to read and easy to test, which we do later. Money is compared in minor units (cents), the same unit Stripe stores prices in, so a Price is only rebuilt when the amount genuinely changed.
SYNCABLE_STATUSES = {"publish"}
SYNCABLE_TYPES = {"simple", "subscription", "variable-subscription"}
def product_amount_minor(product):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# have their own guide, since price * 100 is wrong for those.
price = product.get("price") or product.get("regular_price") or "0"
return round(float(price) * 100)
def decide(product, stripe_product, stripe_price):
if product.get("status") not in SYNCABLE_STATUSES:
return ("skip", "product is not published")
if product.get("type") not in SYNCABLE_TYPES:
return ("skip", "product type is not billed through Stripe")
if product_amount_minor(product) <= 0:
return ("skip", "product has no price yet")
if stripe_product is None:
return ("create_both", "no Stripe product exists for this WooCommerce product")
if stripe_product.get("active") is False:
return ("create_both", "the saved Stripe product was archived")
if stripe_price is None:
return ("create_price", "Stripe product exists but the price is missing")
if stripe_price.get("active") is False:
return ("create_price", "the saved Stripe price was archived")
if stripe_price.get("unit_amount") != product_amount_minor(product):
return ("create_price", "WooCommerce price changed since the last sync")
return ("ok", "already in sync")
const SYNCABLE_STATUSES = new Set(["publish"]);
const SYNCABLE_TYPES = new Set(["simple", "subscription", "variable-subscription"]);
export function productAmountMinor(product) {
// Works for two decimal currencies. Zero decimal currencies (JPY and friends)
// have their own guide, since price * 100 is wrong for those.
const price = product.price || product.regular_price || "0";
return Math.round(parseFloat(price) * 100);
}
export function decide(product, stripeProduct, stripePrice) {
if (!SYNCABLE_STATUSES.has(product.status)) return ["skip", "product is not published"];
if (!SYNCABLE_TYPES.has(product.type)) return ["skip", "product type is not billed through Stripe"];
if (productAmountMinor(product) <= 0) return ["skip", "product has no price yet"];
if (!stripeProduct) return ["create_both", "no Stripe product exists for this WooCommerce product"];
if (stripeProduct.active === false) return ["create_both", "the saved Stripe product was archived"];
if (!stripePrice) return ["create_price", "Stripe product exists but the price is missing"];
if (stripePrice.active === false) return ["create_price", "the saved Stripe price was archived"];
if (stripePrice.unit_amount !== productAmountMinor(product)) {
return ["create_price", "WooCommerce price changed since the last sync"];
}
return ["ok", "already in sync"];
}
Create what is missing and save the new ids
When the action is create_both, make a new Stripe Product tagged with the WooCommerce product id, then a Price under it. When the action is create_price, keep the existing Stripe Product and add a fresh Price, since Stripe prices cannot be edited once made. Either way, write the resulting ids back onto the WooCommerce product as meta, so the next run sees the product as already in sync.
def create_stripe_product_and_price(product):
stripe_product = stripe.Product.create(
name=product["name"],
metadata={"woo_product_id": str(product["id"])},
)
stripe_price = stripe.Price.create(
product=stripe_product["id"],
unit_amount=product_amount_minor(product),
currency=DEFAULT_CURRENCY,
)
return stripe_product, stripe_price
def create_stripe_price(stripe_product_id, product):
return stripe.Price.create(
product=stripe_product_id,
unit_amount=product_amount_minor(product),
currency=DEFAULT_CURRENCY,
)
def save_stripe_ids(product_id, stripe_product_id, stripe_price_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json={"meta_data": [
{"key": "_stripe_product_id", "value": stripe_product_id},
{"key": "_stripe_price_id", "value": stripe_price_id},
]},
auth=AUTH, timeout=30,
).raise_for_status()
async function createStripeProductAndPrice(product) {
const stripeProduct = await stripe.products.create({
name: product.name,
metadata: { woo_product_id: String(product.id) },
});
const stripePrice = await stripe.prices.create({
product: stripeProduct.id,
unit_amount: productAmountMinor(product),
currency: DEFAULT_CURRENCY,
});
return [stripeProduct, stripePrice];
}
async function createStripePrice(stripeProductId, product) {
return stripe.prices.create({
product: stripeProductId,
unit_amount: productAmountMinor(product),
currency: DEFAULT_CURRENCY,
});
}
async function saveStripeIds(productId, stripeProductId, stripePriceId) {
await woo(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: "_stripe_product_id", value: stripeProductId },
{ key: "_stripe_price_id", value: stripePriceId },
],
}),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first run, 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 once a day with cron, since catalog drift like this is slow moving and does not need minute by minute checking.
Always start with DRY_RUN=true. This script creates real objects in your live Stripe account, so you want to see its plan before it acts. Once the report looks right, turn it off.
The full code
Here is the complete sync 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 a product already in sync is always skipped.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Create the missing Stripe Product and Price for WooCommerce products that are
billed through Stripe (usually WooCommerce Subscriptions) but have never been synced.
A subscription product can be sold in WooCommerce for months before anyone notices
that Stripe has no matching Product or Price behind it, usually because it was
imported, duplicated, or created before the store gateway was switched on. This
walks WooCommerce products, checks the saved Stripe ids in product meta, and
creates whatever Stripe is missing, then writes the new ids back onto the product.
Read only by default until DRY_RUN is turned off. Safe to run again and again.
"""
import os
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("sync_products_to_stripe")
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"])
DEFAULT_CURRENCY = os.environ.get("DEFAULT_CURRENCY", "usd")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
SYNCABLE_STATUSES = {"publish"}
SYNCABLE_TYPES = {"simple", "subscription", "variable-subscription"}
def stripe_ids_of(product):
"""The saved Stripe Product and Price ids from WooCommerce product meta."""
product_id = None
price_id = None
for meta in product.get("meta_data") or []:
if meta.get("key") == "_stripe_product_id" and meta.get("value"):
product_id = meta["value"]
if meta.get("key") == "_stripe_price_id" and meta.get("value"):
price_id = meta["value"]
return product_id, price_id
def product_amount_minor(product):
# Works for two decimal currencies. Zero decimal currencies (JPY and friends)
# have their own guide, since price * 100 is wrong for those.
price = product.get("price") or product.get("regular_price") or "0"
return round(float(price) * 100)
def decide(product, stripe_product, stripe_price):
"""Pure decision: what does this WooCommerce product need in Stripe?
Returns a tuple of (action, reason). Action is one of:
"skip" - not something we sync (draft, unpriced, wrong type)
"create_both" - no Stripe product or price exists yet, make both
"create_price" - the Stripe product exists but the price is missing or stale
"ok" - already in sync, nothing to do
"""
if product.get("status") not in SYNCABLE_STATUSES:
return ("skip", "product is not published")
if product.get("type") not in SYNCABLE_TYPES:
return ("skip", "product type is not billed through Stripe")
if product_amount_minor(product) <= 0:
return ("skip", "product has no price yet")
if stripe_product is None:
return ("create_both", "no Stripe product exists for this WooCommerce product")
if stripe_product.get("active") is False:
return ("create_both", "the saved Stripe product was archived")
if stripe_price is None:
return ("create_price", "Stripe product exists but the price is missing")
if stripe_price.get("active") is False:
return ("create_price", "the saved Stripe price was archived")
if stripe_price.get("unit_amount") != product_amount_minor(product):
return ("create_price", "WooCommerce price changed since the last sync")
return ("ok", "already in sync")
def get_stripe_product(product_id):
if not product_id:
return None
try:
return stripe.Product.retrieve(product_id)
except stripe.error.InvalidRequestError:
return None
def get_stripe_price(price_id):
if not price_id:
return None
try:
return stripe.Price.retrieve(price_id)
except stripe.error.InvalidRequestError:
return None
def woo_products():
page = 1
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/products",
params={"status": "publish", "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for product in batch:
yield product
page += 1
def save_stripe_ids(product_id, stripe_product_id, stripe_price_id):
requests.put(
f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
json={"meta_data": [
{"key": "_stripe_product_id", "value": stripe_product_id},
{"key": "_stripe_price_id", "value": stripe_price_id},
]},
auth=AUTH, timeout=30,
).raise_for_status()
def create_stripe_product_and_price(product):
stripe_product = stripe.Product.create(
name=product["name"],
metadata={"woo_product_id": str(product["id"])},
)
stripe_price = stripe.Price.create(
product=stripe_product["id"],
unit_amount=product_amount_minor(product),
currency=DEFAULT_CURRENCY,
)
return stripe_product, stripe_price
def create_stripe_price(stripe_product_id, product):
return stripe.Price.create(
product=stripe_product_id,
unit_amount=product_amount_minor(product),
currency=DEFAULT_CURRENCY,
)
def run():
synced = 0
for product in woo_products():
stripe_product_id, stripe_price_id = stripe_ids_of(product)
stripe_product = get_stripe_product(stripe_product_id)
stripe_price = get_stripe_price(stripe_price_id)
action, reason = decide(product, stripe_product, stripe_price)
if action == "skip":
continue
if action == "ok":
continue
log.info(
"Product %s (%s): %s. %s",
product["id"], product.get("name"), reason,
"would sync" if DRY_RUN else "syncing",
)
if not DRY_RUN:
if action == "create_both":
new_product, new_price = create_stripe_product_and_price(product)
save_stripe_ids(product["id"], new_product["id"], new_price["id"])
elif action == "create_price":
new_price = create_stripe_price(stripe_product["id"], product)
save_stripe_ids(product["id"], stripe_product["id"], new_price["id"])
synced += 1
log.info("Done. %d product(s) %s.", synced, "to sync" if DRY_RUN else "synced")
if __name__ == "__main__":
run()
/**
* Create the missing Stripe Product and Price for WooCommerce products that are
* billed through Stripe (usually WooCommerce Subscriptions) but have never been synced.
*
* A subscription product can be sold in WooCommerce for months before anyone notices
* that Stripe has no matching Product or Price behind it, usually because it was
* imported, duplicated, or created before the store gateway was switched on. This
* walks WooCommerce products, checks the saved Stripe ids in product meta, and
* creates whatever Stripe is missing, then writes the new ids back onto the product.
* Read only by default until DRY_RUN is turned off. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/woocommerce/sync-products-to-stripe/
*/
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 DEFAULT_CURRENCY = process.env.DEFAULT_CURRENCY || "usd";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SYNCABLE_STATUSES = new Set(["publish"]);
const SYNCABLE_TYPES = new Set(["simple", "subscription", "variable-subscription"]);
export function stripeIdsOf(product) {
let productId = null;
let priceId = null;
for (const meta of product.meta_data || []) {
if (meta.key === "_stripe_product_id" && meta.value) productId = meta.value;
if (meta.key === "_stripe_price_id" && meta.value) priceId = meta.value;
}
return [productId, priceId];
}
export function productAmountMinor(product) {
const price = product.price || product.regular_price || "0";
return Math.round(parseFloat(price) * 100);
}
/**
* Pure decision: what does this WooCommerce product need in Stripe?
* Returns [action, reason]. Action is one of:
* "skip" - not something we sync (draft, unpriced, wrong type)
* "create_both" - no Stripe product or price exists yet, make both
* "create_price" - the Stripe product exists but the price is missing or stale
* "ok" - already in sync, nothing to do
*/
export function decide(product, stripeProduct, stripePrice) {
if (!SYNCABLE_STATUSES.has(product.status)) return ["skip", "product is not published"];
if (!SYNCABLE_TYPES.has(product.type)) return ["skip", "product type is not billed through Stripe"];
if (productAmountMinor(product) <= 0) return ["skip", "product has no price yet"];
if (!stripeProduct) return ["create_both", "no Stripe product exists for this WooCommerce product"];
if (stripeProduct.active === false) return ["create_both", "the saved Stripe product was archived"];
if (!stripePrice) return ["create_price", "Stripe product exists but the price is missing"];
if (stripePrice.active === false) return ["create_price", "the saved Stripe price was archived"];
if (stripePrice.unit_amount !== productAmountMinor(product)) {
return ["create_price", "WooCommerce price changed since the last sync"];
}
return ["ok", "already in sync"];
}
async function woo(path, options = {}) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
...options,
headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
});
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function getStripeProduct(productId) {
if (!productId) return null;
try {
return await stripe.products.retrieve(productId);
} catch {
return null;
}
}
async function getStripePrice(priceId) {
if (!priceId) return null;
try {
return await stripe.prices.retrieve(priceId);
} catch {
return null;
}
}
async function* wooProducts() {
let page = 1;
while (true) {
const batch = await woo(`/products?status=publish&per_page=50&page=${page}`);
if (!batch.length) return;
for (const product of batch) yield product;
page++;
}
}
async function saveStripeIds(productId, stripeProductId, stripePriceId) {
await woo(`/products/${productId}`, {
method: "PUT",
body: JSON.stringify({
meta_data: [
{ key: "_stripe_product_id", value: stripeProductId },
{ key: "_stripe_price_id", value: stripePriceId },
],
}),
});
}
async function createStripeProductAndPrice(product) {
const stripeProduct = await stripe.products.create({
name: product.name,
metadata: { woo_product_id: String(product.id) },
});
const stripePrice = await stripe.prices.create({
product: stripeProduct.id,
unit_amount: productAmountMinor(product),
currency: DEFAULT_CURRENCY,
});
return [stripeProduct, stripePrice];
}
async function createStripePrice(stripeProductId, product) {
return stripe.prices.create({
product: stripeProductId,
unit_amount: productAmountMinor(product),
currency: DEFAULT_CURRENCY,
});
}
export async function run() {
let synced = 0;
for await (const product of wooProducts()) {
const [stripeProductId, stripePriceId] = stripeIdsOf(product);
const stripeProduct = await getStripeProduct(stripeProductId);
const stripePrice = await getStripePrice(stripePriceId);
const [action, reason] = decide(product, stripeProduct, stripePrice);
if (action === "skip" || action === "ok") continue;
console.log(`Product ${product.id} (${product.name}): ${reason}. ${DRY_RUN ? "would sync" : "syncing"}`);
if (!DRY_RUN) {
if (action === "create_both") {
const [newProduct, newPrice] = await createStripeProductAndPrice(product);
await saveStripeIds(product.id, newProduct.id, newPrice.id);
} else if (action === "create_price") {
const newPrice = await createStripePrice(stripeProduct.id, product);
await saveStripeIds(product.id, stripeProduct.id, newPrice.id);
}
}
synced++;
}
console.log(`Done. ${synced} product(s) ${DRY_RUN ? "to sync" : "synced"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides what gets created in a live Stripe account. 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 sync_products_to_stripe import decide, stripe_ids_of, product_amount_minor
def product(**over):
base = {"id": 42, "name": "Pro Plan", "status": "publish", "type": "subscription", "price": "50.00"}
base.update(over)
return base
def stripe_product(**over):
base = {"id": "prod_1", "active": True}
base.update(over)
return base
def stripe_price(**over):
base = {"id": "price_1", "active": True, "unit_amount": 5000}
base.update(over)
return base
def test_create_both_when_no_stripe_product():
action, _ = decide(product(), None, None)
assert action == "create_both"
def test_create_both_when_stripe_product_archived():
action, _ = decide(product(), stripe_product(active=False), stripe_price())
assert action == "create_both"
def test_create_price_when_price_missing():
action, _ = decide(product(), stripe_product(), None)
assert action == "create_price"
def test_create_price_when_price_archived():
action, _ = decide(product(), stripe_product(), stripe_price(active=False))
assert action == "create_price"
def test_create_price_when_amount_changed():
action, _ = decide(product(price="60.00"), stripe_product(), stripe_price())
assert action == "create_price"
def test_ok_when_already_in_sync():
action, _ = decide(product(), stripe_product(), stripe_price())
assert action == "ok"
def test_skip_when_not_published():
action, _ = decide(product(status="draft"), None, None)
assert action == "skip"
def test_skip_when_no_price_yet():
action, _ = decide(product(price="0"), None, None)
assert action == "skip"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, stripeIdsOf, productAmountMinor } from "./sync-products-to-stripe.js";
const product = (over = {}) => ({
id: 42, name: "Pro Plan", status: "publish", type: "subscription", price: "50.00", ...over,
});
const stripeProduct = (over = {}) => ({ id: "prod_1", active: true, ...over });
const stripePrice = (over = {}) => ({ id: "price_1", active: true, unit_amount: 5000, ...over });
test("create_both when no stripe product", () => {
assert.equal(decide(product(), null, null)[0], "create_both");
});
test("create_price when price missing", () => {
assert.equal(decide(product(), stripeProduct(), null)[0], "create_price");
});
test("create_price when amount changed", () => {
assert.equal(decide(product({ price: "60.00" }), stripeProduct(), stripePrice())[0], "create_price");
});
test("ok when already in sync", () => {
assert.equal(decide(product(), stripeProduct(), stripePrice())[0], "ok");
});
test("skip when not published", () => {
assert.equal(decide(product({ status: "draft" }), null, null)[0], "skip");
});
test("stripeIdsOf reads meta", () => {
const p = product({
meta_data: [
{ key: "_stripe_product_id", value: "prod_9" },
{ key: "_stripe_price_id", value: "price_9" },
],
});
assert.deepEqual(stripeIdsOf(p), ["prod_9", "price_9"]);
});
Case studies
The catalog that came across without Stripe
A store moved its whole product catalog from an older cart into WooCommerce using an importer. Every product, price, and description carried over cleanly. What did not carry over was any Stripe Product or Price, since those never existed anywhere outside the old cart's own billing system.
Orders kept working because the store's Stripe gateway can charge a card without a Stripe Price on file. The gap only surfaced when finance tried to pull a per-plan revenue report straight from Stripe and found half the catalog missing. The sync script ran once in dry run, listed 340 products needing a Stripe Product, then created them all in about ten minutes.
The plan whose Stripe Price still pointed at the old account
A store switched from WooPayments to a direct Stripe integration. Most products picked up new Stripe ids automatically on their next renewal. One low-traffic annual plan had not renewed yet, so its saved _stripe_price_id still pointed at a Price in the old WooPayments-managed Stripe account, which the new API key could not see.
The script's lookup treated that unreachable id as missing, exactly as it would treat a deleted price, and created a fresh Stripe Product and Price under the new account before the plan's next renewal came due.
After this runs on a schedule, every published, priced, Stripe-billed product has a real Stripe Product and Price behind it, and any tool that reads billing data straight from Stripe sees the full catalog. Keep it running even after a migration is done, since a new imported or duplicated product can quietly reintroduce the same gap later.
FAQ
Why does a WooCommerce product have no matching Stripe product?
WooCommerce only creates a Stripe Product and Price when the plugin's own checkout or renewal flow first bills that product. A product that was imported, duplicated, or built before the store's Stripe gateway was fully set up can be sold for months with nothing behind it on the Stripe side.
Is it safe to create Stripe products with a script?
Yes, when the script only creates a Product and Price for items that are missing one, skips anything already in sync, and writes the new ids back onto the WooCommerce product so it never creates the same thing twice. Start in dry run mode to review the list first.
What happens if I change the price in WooCommerce later?
Stripe prices cannot be edited once created. The script checks the saved Stripe price against the current WooCommerce price and creates a new Stripe Price when they differ, then saves the new price id. The old price is left alone so past invoices still show the amount that was actually charged.
Related field notes
Citations
On the problem:
- WooCommerce docs: how the Stripe gateway creates and reuses Products and Prices when billing a product. woocommerce.com/document/stripe
- WooCommerce Subscriptions docs: how products are billed and synced with a payment gateway over time. woocommerce.com/document/subscriptions
- Stripe docs: Products and Prices, and why a Price cannot be edited once created. docs.stripe.com/products-prices/overview
On the solution:
- WooCommerce REST API: list and update products, including custom meta data. woocommerce.github.io/woocommerce-rest-api-docs
- Stripe API: create a Product and a Price, and retrieve either by id. docs.stripe.com/api/products/create
- Stripe API: Price object reference, including
unit_amountandactive. docs.stripe.com/api/prices/object
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 products?
If this saved you a broken invoice or a confusing finance report, 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