Repair WooCommerce core: products and catalog

Broken featured images on WooCommerce products

A shopper opens a product page, or an order confirmation email lands in an inbox, and instead of the product photo there is a small broken image icon. The product is real, the price is right, the listing still sells, but the picture is gone. This is one of the quieter WooCommerce problems, because nothing crashes and no error shows up in a log. Here is why it happens and a small script that finds every product pointing at a missing file and clears it safely.

Python and Node.js Runs on a schedule Safe by default (dry run)
A black bug on a blue surface
Photo by Katelyn G on Unsplash
The short answer

WooCommerce stores a featured image as an attachment ID on the product, not as a picture. When the file behind that ID is deleted, moved, or never finished uploading, the product keeps pointing at nothing and every page that shows the product renders a broken image icon. Run a small Python or Node.js script on a schedule that checks each product's featured image URL, and clears the image reference on any product whose file no longer resolves. WooCommerce then falls back to its placeholder image instead of a broken icon. Full code, tests, and a dry run guard are below.

The problem in plain words

A WooCommerce product does not carry its photo directly. It carries a reference, an attachment ID, that points at a media file stored in the WordPress uploads folder. The theme and the product page ask for that attachment's URL and drop it into an img tag.

That reference is only as good as the file behind it. If the file is gone, the browser gets a 404 for the image URL and shows its default broken image icon. The product itself is completely fine. Its title, price, description, and stock are untouched. Only the picture is missing, which makes this bug easy to miss until a customer or a teammate happens to look at the right page.

Product has attachment id Uploads folder file deleted or moved image 404s Broken icon shown on the page Storefront and order emails
The product record never changes. The picture only fails because the file it points to is no longer there.

Why it happens

The WordPress media library and the product catalog are two separate systems joined by an ID. A few common ways they fall out of sync:

WordPress core does not verify that an attachment ID still has a file behind it before rendering it. It trusts the reference. That is efficient, but it means a file removed anywhere outside of WooCommerce quietly breaks the product page with no warning to the shop manager.

The key insight

The product's image field can be wrong, but the file system is the source of truth for whether the picture exists. A script that actually requests the image URL and checks the response, rather than trusting the database record, is the only reliable way to find every broken reference in the catalog.

The fix, as a flow

We do not touch products at random. We add a job that runs on a schedule, looks at products tied to orders that Stripe confirms were genuinely paid, and checks each one's featured image URL. If the URL does not resolve, we clear the image reference on the product so WooCommerce falls back to its placeholder instead of showing a broken icon.

Scheduled job every day Read paid orders confirm with Stripe Check image URL for each product File missing? (404 or error) yes no, skip Clear image falls back to placeholder
The script only clears a product's image after it actually confirms the file is gone. Everything else is left alone.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders and 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 LOOKBACK_HOURS="24"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="24"
export DRY_RUN="true"   // start safe, change to false to write
2

List products from recent paid orders

Rather than scan the entire catalog blind, we start from orders WooCommerce marked Processing or Completed in the lookback window. Each order lists its line items, and each line item carries a product_id. We collect the unique product IDs worth checking from there.

step2.py
import requests
from requests.auth import HTTPBasicAuth

WOO_URL = "https://yourstore.com"
AUTH = HTTPBasicAuth("ck_...", "cs_...")

def recent_paid_orders(since_iso):
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={"status": "processing,completed", "after": since_iso, "per_page": 50, "page": page},
            auth=AUTH, timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1
step2.js
const WOO_URL = "https://yourstore.com";
const AUTH = "Basic " + Buffer.from("ck_...:cs_...").toString("base64");

async function* recentPaidOrders(sinceIso) {
  let page = 1;
  while (true) {
    const res = await fetch(
      `${WOO_URL}/wp-json/wc/v3/orders?status=processing,completed&after=${sinceIso}&per_page=50&page=${page}`,
      { headers: { Authorization: AUTH } }
    );
    const batch = await res.json();
    if (!batch.length) return;
    for (const order of batch) yield order;
    page++;
  }
}
3

Confirm the order is really paid with Stripe

WooCommerce order status alone can be wrong. To keep this script from touching products on orders that only look paid, we read the Stripe PaymentIntent ID from the order, either from the _stripe_intent_id meta field or from transaction_id, and confirm the amount and status directly with Stripe before we bother checking that order's products.

step3.py
PAID_STATUSES = {"processing", "completed"}

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 order_amount_minor(order):
    return round(float(order["total"]) * 100)

def payment_confirmed(order, intent):
    if order["status"] not in PAID_STATUSES:
        return False
    if intent is None or intent.get("status") != "succeeded":
        return False
    return abs(order_amount_minor(order) - intent.get("amount_received", 0)) <= 1
step3.js
const PAID_STATUSES = new Set(["processing", "completed"]);

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;
}

function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

function paymentConfirmed(order, intent) {
  if (!PAID_STATUSES.has(order.status)) return false;
  if (!intent || intent.status !== "succeeded") return false;
  return Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) <= 1;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the product and whether its image resolved, 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 product has no image at all, skip it. If we could not judge reachability, skip it. If the image resolves, skip it. Otherwise, clear it.

decide.py
def decide(product, image_reachable):
    images = product.get("images") or []
    if not images:
        return ("skip", "product has no featured image")
    if image_reachable is None:
        return ("skip", "no reachability result to judge")
    if image_reachable:
        return ("skip", "featured image resolves fine")
    return ("clear", "featured image file is missing (404 or error)")
decide.js
export function decide(product, imageReachable) {
  const images = product.images || [];
  if (images.length === 0) return ["skip", "product has no featured image"];
  if (imageReachable === null || imageReachable === undefined) {
    return ["skip", "no reachability result to judge"];
  }
  if (imageReachable) return ["skip", "featured image resolves fine"];
  return ["clear", "featured image file is missing (404 or error)"];
}
5

Check the file and clear the reference

A quick HEAD request against the image URL tells us whether the file is really there. Some hosts reject HEAD, so we fall back to a GET. When the action is clear, we update the product's images field to an empty list through the REST API. WooCommerce then shows its placeholder image instead of a broken icon, and nothing else about the product changes.

apply.py
def image_url_reachable(url):
    try:
        r = requests.head(url, timeout=15, allow_redirects=True)
        if r.status_code == 405:
            r = requests.get(url, timeout=15, stream=True)
        return r.status_code < 400
    except requests.RequestException:
        return False

def clear_featured_image(product_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json={"images": []},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
async function imageUrlReachable(url) {
  try {
    let res = await fetch(url, { method: "HEAD" });
    if (res.status === 405) res = await fetch(url, { method: "GET" });
    return res.status < 400;
  } catch {
    return false;
  }
}

async function clearFeaturedImage(productId) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({ images: [] }),
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which products it would clear. Read the output, trust it, then switch it off to let it write. Run it once a day with cron, since featured images do not usually go missing in the middle of a checkout.

Run it safe

Always start with DRY_RUN=true. Clearing an image reference is easy to reverse by re-uploading the photo, but you still want to see the exact list of products before anything writes.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, confirms payment with Stripe before checking any product, logs what it does, respects the dry run flag, and is safe to run again and again because it only clears a product whose image truly does not resolve.

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

repair_broken_images.py
"""Find and clear WooCommerce product featured images that point to a missing file.

This walks products that appear on recent paid orders (verified against Stripe so we
only touch products real customers actually bought), checks whether each product's
featured image URL resolves, and clears the image reference on any product whose file
is missing. WooCommerce then falls back to the store placeholder image instead of a
broken icon. Read only by default. Run on a schedule.
"""
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("repair_broken_images")

stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "sk_test_dummy")
WOO_URL = os.environ.get("WOO_STORE_URL", "https://example.com").rstrip("/")
AUTH = HTTPBasicAuth(
    os.environ.get("WOO_CONSUMER_KEY", "ck_dummy"),
    os.environ.get("WOO_CONSUMER_SECRET", "cs_dummy"),
)
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    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 decide(product, image_reachable):
    """Pure decision: should we clear this product's featured image reference?"""
    images = product.get("images") or []
    if not images:
        return ("skip", "product has no featured image")
    if image_reachable is None:
        return ("skip", "no reachability result to judge")
    if image_reachable:
        return ("skip", "featured image resolves fine")
    return ("clear", "featured image file is missing (404 or error)")


def order_amount_minor(order):
    return round(float(order["total"]) * 100)


def payment_confirmed(order, intent):
    """True when Stripe confirms this order was really paid the amount on file."""
    if order["status"] not in PAID_STATUSES:
        return False
    if intent is None or intent.get("status") != "succeeded":
        return False
    return abs(order_amount_minor(order) - intent.get("amount_received", 0)) <= 1


def recent_paid_orders(lookback_hours):
    since = time.strftime(
        "%Y-%m-%dT%H:%M:%S", time.gmtime(time.time() - lookback_hours * 3600)
    )
    page = 1
    while True:
        r = requests.get(
            f"{WOO_URL}/wp-json/wc/v3/orders",
            params={
                "status": "processing,completed",
                "after": since,
                "per_page": 50,
                "page": page,
            },
            auth=AUTH,
            timeout=30,
        )
        r.raise_for_status()
        batch = r.json()
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def get_intent(intent_id):
    if not intent_id:
        return None
    try:
        return stripe.PaymentIntent.retrieve(intent_id)
    except stripe.error.InvalidRequestError:
        return None


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


def image_url_reachable(url):
    try:
        r = requests.head(url, timeout=15, allow_redirects=True)
        if r.status_code == 405:
            r = requests.get(url, timeout=15, stream=True)
        return r.status_code < 400
    except requests.RequestException:
        return False


def clear_featured_image(product_id):
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/products/{product_id}",
        json={"images": []},
        auth=AUTH,
        timeout=30,
    ).raise_for_status()


def run():
    checked_products = {}
    cleared = 0
    for order in recent_paid_orders(LOOKBACK_HOURS):
        intent = get_intent(intent_id_of(order))
        if not payment_confirmed(order, intent):
            continue
        for line_item in order.get("line_items", []):
            product_id = line_item.get("product_id")
            if not product_id or product_id in checked_products:
                continue
            checked_products[product_id] = True
            product = get_product(product_id)
            if product is None:
                log.warning("Product %s from order %s no longer exists", product_id, order["id"])
                continue
            images = product.get("images") or []
            reachable = image_url_reachable(images[0]["src"]) if images else None
            action, reason = decide(product, reachable)
            if action == "skip":
                continue
            log.warning(
                "Product %s: %s. %s", product_id, reason, "would clear" if DRY_RUN else "clearing"
            )
            if not DRY_RUN:
                clear_featured_image(product_id)
            cleared += 1
    log.info("Done. %d product(s) %s.", cleared, "to clear" if DRY_RUN else "cleared")


if __name__ == "__main__":
    run()
repair-broken-images.js
/**
 * Find and clear WooCommerce product featured images that point to a missing file.
 *
 * This walks products that appear on recent paid orders (verified against Stripe so
 * we only touch products real customers actually bought), checks whether each
 * product's featured image URL resolves, and clears the image reference on any
 * product whose file is missing. WooCommerce then falls back to the store
 * placeholder image instead of a broken icon. Read only by default. Run on a schedule.
 */
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 LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

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;
}

export function decide(product, imageReachable) {
  const images = product.images || [];
  if (images.length === 0) return ["skip", "product has no featured image"];
  if (imageReachable === null || imageReachable === undefined) {
    return ["skip", "no reachability result to judge"];
  }
  if (imageReachable) return ["skip", "featured image resolves fine"];
  return ["clear", "featured image file is missing (404 or error)"];
}

export function orderAmountMinor(order) {
  return Math.round(parseFloat(order.total) * 100);
}

export function paymentConfirmed(order, intent) {
  if (!PAID_STATUSES.has(order.status)) return false;
  if (!intent || intent.status !== "succeeded") return false;
  return Math.abs(orderAmountMinor(order) - (intent.amount_received || 0)) <= 1;
}

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* recentPaidOrders(lookbackHours) {
  const since = new Date(Date.now() - lookbackHours * 3600 * 1000).toISOString();
  let page = 1;
  while (true) {
    const batch = await woo(
      `/orders?status=processing,completed&after=${since}&per_page=50&page=${page}`
    );
    if (!batch || batch.length === 0) return;
    for (const order of batch) yield order;
    page++;
  }
}

async function getIntent(intentId) {
  if (!intentId) return null;
  try {
    return await stripe.paymentIntents.retrieve(intentId);
  } catch {
    return null;
  }
}

async function imageUrlReachable(url) {
  try {
    let res = await fetch(url, { method: "HEAD" });
    if (res.status === 405) res = await fetch(url, { method: "GET" });
    return res.status < 400;
  } catch {
    return false;
  }
}

async function clearFeaturedImage(productId) {
  await woo(`/products/${productId}`, {
    method: "PUT",
    body: JSON.stringify({ images: [] }),
  });
}

export async function run() {
  const checked = new Set();
  let cleared = 0;
  for await (const order of recentPaidOrders(LOOKBACK_HOURS)) {
    const intent = await getIntent(intentIdOf(order));
    if (!paymentConfirmed(order, intent)) continue;
    for (const lineItem of order.line_items || []) {
      const productId = lineItem.product_id;
      if (!productId || checked.has(productId)) continue;
      checked.add(productId);
      const product = await woo(`/products/${productId}`);
      if (!product) {
        console.warn(`Product ${productId} from order ${order.id} no longer exists`);
        continue;
      }
      const images = product.images || [];
      const reachable = images.length ? await imageUrlReachable(images[0].src) : null;
      const [action, reason] = decide(product, reachable);
      if (action === "skip") continue;
      console.warn(`Product ${productId}: ${reason}. ${DRY_RUN ? "would clear" : "clearing"}`);
      if (!DRY_RUN) await clearFeaturedImage(productId);
      cleared++;
    }
  }
  console.log(`Done. ${cleared} product(s) ${DRY_RUN ? "to clear" : "cleared"}.`);
}

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

Add a test

The decision rule and the payment check are the parts most worth testing, because together they decide which products get touched. Because we kept decide and paymentConfirmed pure, the tests need no network and no Stripe account. They just feed in plain objects and check the result.

test_broken_decide.py
from repair_broken_images import decide, intent_id_of, payment_confirmed


def product(**over):
    base = {"id": 1, "images": [{"id": 55, "src": "https://example.com/wp-content/uploads/photo.jpg"}]}
    base.update(over)
    return base


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


def test_clear_when_image_not_reachable():
    assert decide(product(), False)[0] == "clear"


def test_skip_when_image_reachable():
    assert decide(product(), True)[0] == "skip"


def test_skip_when_no_images_at_all():
    assert decide(product(images=[]), None)[0] == "skip"


def test_payment_confirmed_true_when_matching_and_succeeded():
    order = {"status": "processing", "total": "50.00"}
    assert payment_confirmed(order, intent()) is True


def test_payment_confirmed_false_when_amount_mismatch():
    order = {"status": "processing", "total": "80.00"}
    assert payment_confirmed(order, intent()) is False


def test_intent_id_from_meta():
    order = {"meta_data": [{"key": "_stripe_intent_id", "value": "pi_123"}], "transaction_id": ""}
    assert intent_id_of(order) == "pi_123"
repair-broken-images.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, intentIdOf, paymentConfirmed } from "./repair-broken-images.js";

const product = (over = {}) => ({
  id: 1,
  images: [{ id: 55, src: "https://example.com/wp-content/uploads/photo.jpg" }],
  ...over,
});
const intent = (over = {}) => ({ status: "succeeded", amount_received: 5000, ...over });

test("clear when image not reachable", () => {
  assert.equal(decide(product(), false)[0], "clear");
});

test("skip when image reachable", () => {
  assert.equal(decide(product(), true)[0], "skip");
});

test("skip when no images at all", () => {
  assert.equal(decide(product({ images: [] }), null)[0], "skip");
});

test("paymentConfirmed true when matching and succeeded", () => {
  assert.equal(paymentConfirmed({ status: "processing", total: "50.00" }, intent()), true);
});

test("paymentConfirmed false when amount mismatch", () => {
  assert.equal(paymentConfirmed({ status: "processing", total: "80.00" }, intent()), false);
});

test("intentIdOf from meta", () => {
  assert.equal(
    intentIdOf({ meta_data: [{ key: "_stripe_intent_id", value: "pi_123" }], transaction_id: "" }),
    "pi_123"
  );
});

Case studies

Server migration

The move that left the pictures behind

A store moved hosts and the team copied the WordPress database but ran out of time to fully sync wp-content/uploads before going live. Roughly three hundred products, mostly older, seasonal items, showed a broken image icon on the new server while everything else worked.

The script ran in dry run first, listed every affected product in under a minute, and the team re-uploaded the real photos for the top sellers while letting the fix clear the rest to the placeholder so the catalog looked clean immediately.

Cleanup plugin

The unused media cleaner that was not so sure

A media cleanup plugin was installed to shrink a bloated uploads folder. It flagged a batch of images as unused and deleted them, but its scan missed images that were only referenced through a product's gallery field rather than the main content editor.

Nobody noticed until a customer sent a screenshot of a broken thumbnail. The script found nineteen affected products the same day it was scheduled to run and cleared every one, well before the next sale.

What good looks like

After this runs on a schedule, a deleted or missing image file becomes a placeholder for a day instead of a broken icon indefinitely. Keep it running even after you find the root cause of a specific incident, since files can go missing again from a completely different cause next time.

FAQ

Why does my WooCommerce product show a broken image icon?

The product still points at an attachment ID whose file no longer exists in the uploads folder. This happens after a migration that skipped media, a plugin or host that deleted unused files, or an upload that never finished. A script that checks each featured image URL and clears the reference when the file is gone fixes it.

Is it safe to clear a product's image with a script?

Yes, when the script only clears a product after confirming the image URL truly does not resolve, and it only checks products tied to real, Stripe confirmed paid orders. Start in dry run mode to review the exact list before it writes anything.

Will clearing the image delete the product or its data?

No. Clearing the featured image only removes the broken image reference from the product. WooCommerce then falls back to the store's placeholder image. The product, its price, its stock, and its orders are untouched.

Related field notes

Citations

On the problem:

  1. WordPress Developer docs: how attachments and media are stored and referenced. developer.wordpress.org/apis/handbook/rest-api/reference/media
  2. WooCommerce docs: managing product images and galleries. woocommerce.com/document/managing-product-images
  3. WordPress support forum: featured images broken after a site or host migration. wordpress.org/support/topic/images-broken-after-migration

On the solution:

  1. WooCommerce REST API: read and update products, including the images field. woocommerce.github.io/woocommerce-rest-api-docs
  2. WooCommerce REST API: list and filter orders by status and date. woocommerce.github.io/woocommerce-rest-api-docs
  3. Stripe API: retrieve a PaymentIntent to confirm its status and amount. docs.stripe.com/api/payment_intents/retrieve

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 broken images?

If this saved you a pile of "why does my product look broken" messages, 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