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.

Python and Node.js Runs on a schedule Safe by default (dry run)
A card swipe machine
Photo by Blake Wisz on Unsplash
The short answer

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.

Product created import or duplicate Sold in WooCommerce orders go through never billed via Stripe No Stripe Product no Stripe Price Invoices fail reports empty
The product is real and it sells, but nothing ever creates its Stripe Product and Price, so anything downstream that reads from Stripe finds nothing.

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:

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.

The key insight

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.

Scheduled job once a day List published WooCommerce products Read saved ids from product meta Product and price match? yes, skip no Create in Stripe save new product/price id
The sync reads WooCommerce as the source of truth and only creates what Stripe is missing. A product already in sync 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 products. 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 DEFAULT_CURRENCY="usd"
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 DEFAULT_CURRENCY="usd"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])

def 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
step2.js
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");

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

async function* 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++;
  }
}
3

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.

step3.py
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
step3.js
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;
  }
}
4

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.

decide.py
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")
decide.js
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"];
}
5

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.

apply.py
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()
apply.js
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 },
      ],
    }),
  });
}
6

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.

Run it safe

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.

sync_products_to_stripe.py
"""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()
sync-products-to-stripe.js
/**
 * 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.

test_sync_products_decide.py
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"
sync-products-to-stripe.test.js
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

Store migration

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.

Gateway switch

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.

What good looks like

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:

  1. WooCommerce docs: how the Stripe gateway creates and reuses Products and Prices when billing a product. woocommerce.com/document/stripe
  2. WooCommerce Subscriptions docs: how products are billed and synced with a payment gateway over time. woocommerce.com/document/subscriptions
  3. Stripe docs: Products and Prices, and why a Price cannot be edited once created. docs.stripe.com/products-prices/overview

On the solution:

  1. WooCommerce REST API: list and update products, including custom meta data. woocommerce.github.io/woocommerce-rest-api-docs
  2. Stripe API: create a Product and a Price, and retrieve either by id. docs.stripe.com/api/products/create
  3. Stripe API: Price object reference, including unit_amount and active. 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.

Contact me on LinkedIn

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

Back to all WooCommerce and Stripe field notes