Skip to content

Repair

Product created via webservice is invisible on the storefront

You POST a new product through the webservice, the response comes back with an id, and the back office grid shows it active. Then you check the storefront and it is nowhere: not in its category, not in search, not anywhere in the catalog. Nothing errored. Nothing in the response hinted anything was wrong. Here is why the webservice can create a product that is active but structurally invisible, and a small script that finds and repairs the missing links.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Apples in a store crate
Photo by Gemma C on Unsplash
The short answer

The full admin product save wires up category_product links, shop associations, and search index rows as side effects of the whole controller save chain. The webservice Product::add() and update() path only writes what is explicitly present in the payload, so a body that sets active=1 without an associations.categories block carrying id_category_default, or without an associations.shops entry, leaves the product row active but with no category link and no shop association. The front-end catalog and category queries join through those tables, so they never return it. Run a Python or Node.js script that fetches recently created products with display=full, checks each one's associations.categories, associations.shops, id_category_default, and visibility, and merges the missing links back with a PUT that never blind-overwrites the record. Full code, tests, and citations are below.

The problem in plain words

When you create or edit a product by hand in the PrestaShop back office, one Save click triggers a long chain of work. The admin controller does not just write the product row, it also writes the category_product links for whichever categories you ticked, writes a product_shop row for every shop the product belongs to, and rebuilds the search index so the catalog and search pages can find it.

The webservice does not run that chain. Product::add() and Product::update() only persist what is explicitly present in the XML or JSON body you send. If your integration script sets the scalar fields, price, name, active=1, and stops there, without an associations.categories block or an associations.shops block, PrestaShop happily creates the product row. The back office grid reads straight from product and product_shop, so it shows the product as active. But there is no category_product row linking it to its default category, and no valid shop association, so every front-end query that joins through those tables to build a catalog or category page skips right over it.

POST /api/products active=1, no associations Product row saved shows active in back office no links written category_product and product_shop missing Back office grid: product looks active and fine Storefront catalog never joins to it
The product row exists and looks fine in the back office, but with no category_product link and no shop association, the storefront's join-based catalog queries never surface it.

Why it happens

This is a long-standing, repeatedly reported behavior of the webservice, not a one-off bug in your integration. A few things make it easy to hit:

This is confirmed across multiple GitHub issues and forum threads (see the citations at the end), usually phrased as "product created via API does not show on the front" or "webservice cannot assign category when adding a product."

The key insight

The webservice write is literal: it persists exactly what you send and nothing more. Setting active=1 is not the same as making a product sellable. A product only becomes visible when its resource body also carries a non-empty associations.categories that includes id_category_default, a valid associations.shops entry for its shop, and a visibility other than none. Detect and repair by merging those pieces onto the existing resource, never by blind-overwriting the whole record.

The fix, as a flow

We do not touch checkout or storefront rendering. We add a job that lists recently created active products, inspects the associations block that display=full already returns, and flags any product whose categories, default category, shops, or visibility look broken. For anything repairable, it fetches the full current resource, merges in the missing links, and PUTs the merged body back, then re-checks it. A product whose default category itself does not exist is never auto-written, only reported.

List recent products GET /api/products, display=full Check associations categories, shops, visibility Needs repair? yes, category valid Merge and PUT never blind-overwrite Re-GET confirm fixed category invalid, flag human Report, no write
The job only ever merges the missing associations onto the product it already fetched, and it never guesses a replacement category when the default category itself is invalid.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with access to products and categories. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.

setup (shell)
pip install requests

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export EXPECTED_SHOP_IDS="1"   # comma separated shop ids the product should sell in
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export EXPECTED_SHOP_IDS="1"   // comma separated shop ids the product should sell in
export DRY_RUN="true"   // start safe, change to false to write
2

List recently created products with their full associations

Call GET /api/products?output_format=JSON&display=full&filter[active]=1&filter[date_add]=[2026-07-01,2026-07-11]&limit=100. With display=full the response already includes the associations block, so you get associations.categories.category[], associations.shops.shop[], id_category_default, and visibility in one call, no second request needed just to see whether the links exist.

step2.py
import os, requests

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
AUTH = (PRESTASHOP_WS_KEY, "")

def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()

def list_recent_active_products(date_from, date_to, limit=100):
    data = api_get("products", params={
        "display": "full",
        "filter[active]": 1,
        "filter[date_add]": f"[{date_from},{date_to}]",
        "limit": limit,
    })
    return data.get("products") or []
step2.js
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY;

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function listRecentActiveProducts(dateFrom, dateTo, limit = 100) {
  const data = await apiGet("products", {
    display: "full",
    "filter[active]": 1,
    "filter[date_add]": `[${dateFrom},${dateTo}]`,
    limit,
  });
  return data.products || [];
}
3

Confirm the default category itself is real

A product can point id_category_default at a category that no longer exists, or at the orphaned root "Home" placeholder. Before you decide anything is repairable, cross-check with GET /api/categories/{id}?output_format=JSON and keep only the categories that exist and are active. Feed that list of valid ids into the decision function.

step3.py
def category_is_valid(id_category):
    try:
        data = api_get(f"categories/{id_category}")
    except requests.HTTPError:
        return False
    category = data.get("category") or {}
    return str(category.get("active", "0")) == "1"
step3.js
async function categoryIsValid(idCategory) {
  try {
    const data = await apiGet(`categories/${idCategory}`);
    const category = data.category || {};
    return String(category.active) === "1";
  } catch (err) {
    return false;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes only the plain product fields, the expected shop ids, and the set of valid category ids, no I/O at all. It flags a product as needing repair when its categories are empty, its default category is missing from its category list, its shops are empty or wrong, or its visibility is none. If the default category itself is invalid, it returns unrepairable instead, since guessing a replacement category could mis-file the product.

decide.py
def decide_product_repair(product, context):
    """product: {active, visibility, id_category_default, associations: {categories, shops}}
    context: {expectedShopIds, validCategoryIds}
    Returns {status, missing, patch}. No I/O.
    """
    if product["active"] != 1:
        return {"status": "ok", "missing": [], "patch": None}

    missing = []
    categories = product["associations"]["categories"]
    id_category_default = product["id_category_default"]

    if len(categories) == 0:
        missing.append("categories")
    elif id_category_default not in categories:
        missing.append("id_category_default_not_in_categories")

    shops = product["associations"]["shops"]
    expected_shop_ids = context["expectedShopIds"]
    if len(shops) == 0 or not any(sid in shops for sid in expected_shop_ids):
        missing.append("shops")

    if product["visibility"] == "none":
        missing.append("visibility")

    if id_category_default not in context["validCategoryIds"]:
        missing.append("default_category_invalid")
        return {"status": "unrepairable", "missing": missing, "patch": None}

    if not missing:
        return {"status": "ok", "missing": [], "patch": None}

    patch = {}
    if "categories" in missing or "id_category_default_not_in_categories" in missing:
        patch["associations"] = {
            "categories": sorted(set(categories) | {id_category_default})
        }
    if "shops" in missing:
        patch.setdefault("associations", {})["shops"] = list(expected_shop_ids)
    if "visibility" in missing:
        patch["visibility"] = "both"

    return {"status": "needs_repair", "missing": missing, "patch": patch}
decide.js
export function decideProductRepair(product, context) {
  // product: {active, visibility, id_category_default, associations: {categories, shops}}
  // context: {expectedShopIds, validCategoryIds}
  // Returns {status, missing, patch}. No I/O.
  if (product.active !== 1) {
    return { status: "ok", missing: [], patch: null };
  }

  const missing = [];
  const { categories, shops } = product.associations;
  const idCategoryDefault = product.id_category_default;

  if (categories.length === 0) {
    missing.push("categories");
  } else if (!categories.includes(idCategoryDefault)) {
    missing.push("id_category_default_not_in_categories");
  }

  const expectedShopIds = context.expectedShopIds;
  if (shops.length === 0 || !expectedShopIds.some((id) => shops.includes(id))) {
    missing.push("shops");
  }

  if (product.visibility === "none") {
    missing.push("visibility");
  }

  if (!context.validCategoryIds.includes(idCategoryDefault)) {
    missing.push("default_category_invalid");
    return { status: "unrepairable", missing, patch: null };
  }

  if (missing.length === 0) {
    return { status: "ok", missing: [], patch: null };
  }

  const patch = {};
  if (missing.includes("categories") || missing.includes("id_category_default_not_in_categories")) {
    patch.associations = { categories: [...new Set([...categories, idCategoryDefault])] };
  }
  if (missing.includes("shops")) {
    patch.associations = { ...(patch.associations || {}), shops: expectedShopIds };
  }
  if (missing.includes("visibility")) {
    patch.visibility = "both";
  }

  return { status: "needs_repair", missing, patch };
}
5

Merge the patch onto the full resource, never blind-overwrite

The webservice PUT expects the complete resource, so partial fields are ignored or reset if you send only what changed. Fetch the full product again, merge in the computed patch in memory, and PUT the merged body back. In dry run, only log the before and after associations and stop there.

apply.py
def get_full_product(id_product):
    data = api_get(f"products/{id_product}", params={"display": "full"})
    return data["product"]

def merge_patch_onto_resource(full_product, patch):
    merged = dict(full_product)
    if "associations" in patch:
        merged["associations"] = {**merged.get("associations", {}), **patch["associations"]}
    if "visibility" in patch:
        merged["visibility"] = patch["visibility"]
    return merged

def put_product(id_product, merged_product):
    r = requests.put(
        f"{PRESTASHOP_URL}/api/products/{id_product}",
        params={"output_format": "JSON"}, auth=AUTH,
        json={"product": merged_product}, timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function getFullProduct(idProduct) {
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  return data.product;
}

function mergePatchOntoResource(fullProduct, patch) {
  const merged = { ...fullProduct };
  if (patch.associations) {
    merged.associations = { ...merged.associations, ...patch.associations };
  }
  if (patch.visibility) {
    merged.visibility = patch.visibility;
  }
  return merged;
}

async function putProduct(idProduct, mergedProduct) {
  const url = new URL(`${PRESTASHOP_URL}/api/products/${idProduct}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ product: mergedProduct }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT products/${idProduct}`);
  return res.json();
}
6

Wire it together, then re-GET to confirm

The loop lists candidates, runs each through decide_product_repair, and for anything needs_repair merges and PUTs when DRY_RUN is off. After writing, re-fetch the product and assert the associations actually contain id_category_default and the expected shop id. If they do not, leave it as needs_repair and log an alert rather than silently retry. Anything unrepairable is only ever reported, never written.

Run it safe

Always start with DRY_RUN=true and read the logged diff before letting it write. Never let the script guess a replacement category for a product whose id_category_default points at something deleted, that decision needs a human who knows the merchant's catalog.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, never blind-overwrites a product, respects the dry run flag, and always re-checks its own work before calling a product repaired.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
repair_invisible_product.py
"""Detect and repair PrestaShop products created via webservice that are invisible
on the storefront despite showing active in the back office.

The full admin product save wires up category_product links, shop associations,
and search index rows as side effects of the whole controller save chain. The
webservice Product::add()/update() path only writes what the submitted resource
body explicitly includes. A payload that sets active=1 without an
associations.categories block carrying id_category_default, or without an
associations.shops entry, leaves the product active in product/product_shop but
with no category link and no shop association, so front-end catalog queries that
join through those tables never return it (PrestaShop/PrestaShop issues #15317
and #28409).

This script lists recently created active products with display=full, inspects
the associations block already returned, cross-checks id_category_default against
real categories, and flags or repairs the missing links. Repair merges the fix
onto the full current resource and PUTs it back, then re-GETs to confirm. A
product whose default category itself is invalid is only ever flagged, never
auto-written, since guessing a replacement category could mis-file it.

Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_invisible_product")

PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
EXPECTED_SHOP_IDS = [int(s) for s in os.environ.get("EXPECTED_SHOP_IDS", "1").split(",") if s.strip()]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


def decide_product_repair(product, context):
    """Pure decision function, no I/O.

    product: {active: 0|1, visibility: "both"|"catalog"|"search"|"none",
              id_category_default: int, associations: {categories: [int], shops: [int]}}
    context: {expectedShopIds: [int], validCategoryIds: [int]}

    Returns {status: "ok"|"needs_repair"|"unrepairable", missing: [str], patch: dict|None}.
    """
    if product["active"] != 1:
        return {"status": "ok", "missing": [], "patch": None}

    missing = []
    categories = product["associations"]["categories"]
    id_category_default = product["id_category_default"]

    if len(categories) == 0:
        missing.append("categories")
    elif id_category_default not in categories:
        missing.append("id_category_default_not_in_categories")

    shops = product["associations"]["shops"]
    expected_shop_ids = context["expectedShopIds"]
    if len(shops) == 0 or not any(sid in shops for sid in expected_shop_ids):
        missing.append("shops")

    if product["visibility"] == "none":
        missing.append("visibility")

    if id_category_default not in context["validCategoryIds"]:
        missing.append("default_category_invalid")
        return {"status": "unrepairable", "missing": missing, "patch": None}

    if not missing:
        return {"status": "ok", "missing": [], "patch": None}

    patch = {}
    if "categories" in missing or "id_category_default_not_in_categories" in missing:
        patch["associations"] = {
            "categories": sorted(set(categories) | {id_category_default})
        }
    if "shops" in missing:
        patch.setdefault("associations", {})["shops"] = list(expected_shop_ids)
    if "visibility" in missing:
        patch["visibility"] = "both"

    return {"status": "needs_repair", "missing": missing, "patch": patch}


def api_get(path, params=None):
    params = dict(params or {})
    params["output_format"] = "JSON"
    r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
    r.raise_for_status()
    return r.json()


def api_put(path, resource_key, body):
    r = requests.put(
        f"{PRESTASHOP_URL}/api/{path}",
        params={"output_format": "JSON"},
        auth=AUTH,
        json={resource_key: body},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def list_recent_active_products(date_from, date_to, limit=100):
    data = api_get("products", params={
        "display": "full",
        "filter[active]": 1,
        "filter[date_add]": f"[{date_from},{date_to}]",
        "limit": limit,
    })
    return data.get("products") or []


def category_is_valid(id_category):
    try:
        data = api_get(f"categories/{id_category}")
    except requests.HTTPError:
        return False
    category = data.get("category") or {}
    return str(category.get("active", "0")) == "1"


def get_full_product(id_product):
    data = api_get(f"products/{id_product}", params={"display": "full"})
    return data["product"]


def merge_patch_onto_resource(full_product, patch):
    merged = dict(full_product)
    if "associations" in patch:
        merged["associations"] = {**merged.get("associations", {}), **patch["associations"]}
    if "visibility" in patch:
        merged["visibility"] = patch["visibility"]
    return merged


def put_product(id_product, merged_product):
    return api_put(f"products/{id_product}", "product", merged_product)


def to_decision_shape(product):
    associations = product.get("associations") or {}
    categories = [c["id"] for c in (associations.get("categories") or {}).get("category", [])]
    shops = [s["id"] for s in (associations.get("shops") or {}).get("shop", [])]
    return {
        "active": int(product.get("active", 0)),
        "visibility": product.get("visibility", "both"),
        "id_category_default": int(product.get("id_category_default", 0)),
        "associations": {"categories": categories, "shops": shops},
    }


def run(date_from="2026-07-01", date_to="2026-07-11"):
    flagged = 0
    repaired = 0
    unrepairable = 0
    valid_category_cache = {}

    for raw_product in list_recent_active_products(date_from, date_to):
        id_product = raw_product["id"]
        product = to_decision_shape(raw_product)
        id_category_default = product["id_category_default"]

        if id_category_default not in valid_category_cache:
            valid_category_cache[id_category_default] = category_is_valid(id_category_default)
        valid_category_ids = [cid for cid, ok in valid_category_cache.items() if ok]

        decision = decide_product_repair(product, {
            "expectedShopIds": EXPECTED_SHOP_IDS,
            "validCategoryIds": valid_category_ids,
        })

        if decision["status"] == "ok":
            continue

        flagged += 1
        log.warning("Product %s status=%s missing=%s", id_product, decision["status"], decision["missing"])

        if decision["status"] == "unrepairable":
            unrepairable += 1
            log.error("Product %s has an invalid id_category_default=%s, needs a human to pick a category.",
                       id_product, id_category_default)
            continue

        if DRY_RUN:
            log.info("Dry run. Would PUT products/%s with patch=%s", id_product, decision["patch"])
            continue

        full_product = get_full_product(id_product)
        merged = merge_patch_onto_resource(full_product, decision["patch"])
        put_product(id_product, merged)

        confirm_raw = get_full_product(id_product)
        confirm = to_decision_shape(confirm_raw)
        confirm_decision = decide_product_repair(confirm, {
            "expectedShopIds": EXPECTED_SHOP_IDS,
            "validCategoryIds": valid_category_ids,
        })
        if confirm_decision["status"] == "ok":
            repaired += 1
            log.info("Repaired product %s.", id_product)
        else:
            log.error("Product %s still needs_repair after PUT, missing=%s. Not retrying silently.",
                       id_product, confirm_decision["missing"])

    log.info("Done. %d flagged, %d repaired, %d unrepairable.", flagged, repaired, unrepairable)


if __name__ == "__main__":
    run()
repair-invisible-product.js
/**
 * Detect and repair PrestaShop products created via webservice that are invisible
 * on the storefront despite showing active in the back office.
 *
 * The full admin product save wires up category_product links, shop associations,
 * and search index rows as side effects of the whole controller save chain. The
 * webservice Product::add()/update() path only writes what the submitted resource
 * body explicitly includes. A payload that sets active=1 without an
 * associations.categories block carrying id_category_default, or without an
 * associations.shops entry, leaves the product active in product/product_shop but
 * with no category link and no shop association, so front-end catalog queries that
 * join through those tables never return it (PrestaShop/PrestaShop issues #15317
 * and #28409).
 *
 * This script lists recently created active products with display=full, inspects
 * the associations block already returned, cross-checks id_category_default against
 * real categories, and flags or repairs the missing links. Repair merges the fix
 * onto the full current resource and PUTs it back, then re-GETs to confirm. A
 * product whose default category itself is invalid is only ever flagged, never
 * auto-written, since guessing a replacement category could mis-file it.
 *
 * Guide: https://www.allanninal.dev/prestashop/webservice-product-invisible-on-storefront/
 */
import { pathToFileURL } from "node:url";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const EXPECTED_SHOP_IDS = (process.env.EXPECTED_SHOP_IDS || "1")
  .split(",").map((s) => s.trim()).filter(Boolean).map(Number);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

function basicAuthHeader() {
  return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}

/**
 * Pure decision function, no I/O.
 *
 * product: {active: 0|1, visibility: "both"|"catalog"|"search"|"none",
 *           idCategoryDefault: number, associations: {categories: number[], shops: number[]}}
 * context: {expectedShopIds: number[], validCategoryIds: number[]}
 *
 * Returns {status: "ok"|"needs_repair"|"unrepairable", missing: string[], patch: object|null}.
 */
export function decideProductRepair(product, context) {
  if (product.active !== 1) {
    return { status: "ok", missing: [], patch: null };
  }

  const missing = [];
  const { categories, shops } = product.associations;
  const idCategoryDefault = product.id_category_default;

  if (categories.length === 0) {
    missing.push("categories");
  } else if (!categories.includes(idCategoryDefault)) {
    missing.push("id_category_default_not_in_categories");
  }

  const expectedShopIds = context.expectedShopIds;
  if (shops.length === 0 || !expectedShopIds.some((id) => shops.includes(id))) {
    missing.push("shops");
  }

  if (product.visibility === "none") {
    missing.push("visibility");
  }

  if (!context.validCategoryIds.includes(idCategoryDefault)) {
    missing.push("default_category_invalid");
    return { status: "unrepairable", missing, patch: null };
  }

  if (missing.length === 0) {
    return { status: "ok", missing: [], patch: null };
  }

  const patch = {};
  if (missing.includes("categories") || missing.includes("id_category_default_not_in_categories")) {
    patch.associations = { categories: [...new Set([...categories, idCategoryDefault])] };
  }
  if (missing.includes("shops")) {
    patch.associations = { ...(patch.associations || {}), shops: expectedShopIds };
  }
  if (missing.includes("visibility")) {
    patch.visibility = "both";
  }

  return { status: "needs_repair", missing, patch };
}

async function apiGet(path, params = {}) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
  return res.json();
}

async function apiPut(path, resourceKey, body) {
  const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ [resourceKey]: body }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
  return res.json();
}

async function listRecentActiveProducts(dateFrom, dateTo, limit = 100) {
  const data = await apiGet("products", {
    display: "full",
    "filter[active]": 1,
    "filter[date_add]": `[${dateFrom},${dateTo}]`,
    limit,
  });
  return data.products || [];
}

async function categoryIsValid(idCategory) {
  try {
    const data = await apiGet(`categories/${idCategory}`);
    const category = data.category || {};
    return String(category.active) === "1";
  } catch (err) {
    return false;
  }
}

async function getFullProduct(idProduct) {
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  return data.product;
}

function mergePatchOntoResource(fullProduct, patch) {
  const merged = { ...fullProduct };
  if (patch.associations) {
    merged.associations = { ...merged.associations, ...patch.associations };
  }
  if (patch.visibility) {
    merged.visibility = patch.visibility;
  }
  return merged;
}

async function putProduct(idProduct, mergedProduct) {
  return apiPut(`products/${idProduct}`, "product", mergedProduct);
}

function toDecisionShape(product) {
  const associations = product.associations || {};
  const categories = ((associations.categories || {}).category || []).map((c) => c.id);
  const shops = ((associations.shops || {}).shop || []).map((s) => s.id);
  return {
    active: Number(product.active || 0),
    visibility: product.visibility || "both",
    id_category_default: Number(product.id_category_default || 0),
    associations: { categories, shops },
  };
}

export async function run(dateFrom = "2026-07-01", dateTo = "2026-07-11") {
  let flagged = 0;
  let repaired = 0;
  let unrepairable = 0;
  const validCategoryCache = new Map();

  for (const rawProduct of await listRecentActiveProducts(dateFrom, dateTo)) {
    const idProduct = rawProduct.id;
    const product = toDecisionShape(rawProduct);
    const idCategoryDefault = product.id_category_default;

    if (!validCategoryCache.has(idCategoryDefault)) {
      validCategoryCache.set(idCategoryDefault, await categoryIsValid(idCategoryDefault));
    }
    const validCategoryIds = [...validCategoryCache.entries()].filter(([, ok]) => ok).map(([id]) => id);

    const decision = decideProductRepair(product, { expectedShopIds: EXPECTED_SHOP_IDS, validCategoryIds });

    if (decision.status === "ok") continue;

    flagged++;
    console.warn(`Product ${idProduct} status=${decision.status} missing=${decision.missing}`);

    if (decision.status === "unrepairable") {
      unrepairable++;
      console.error(`Product ${idProduct} has an invalid id_category_default=${idCategoryDefault}, needs a human to pick a category.`);
      continue;
    }

    if (DRY_RUN) {
      console.log(`Dry run. Would PUT products/${idProduct} with patch=${JSON.stringify(decision.patch)}`);
      continue;
    }

    const fullProduct = await getFullProduct(idProduct);
    const merged = mergePatchOntoResource(fullProduct, decision.patch);
    await putProduct(idProduct, merged);

    const confirmRaw = await getFullProduct(idProduct);
    const confirm = toDecisionShape(confirmRaw);
    const confirmDecision = decideProductRepair(confirm, { expectedShopIds: EXPECTED_SHOP_IDS, validCategoryIds });
    if (confirmDecision.status === "ok") {
      repaired++;
      console.log(`Repaired product ${idProduct}.`);
    } else {
      console.error(`Product ${idProduct} still needs_repair after PUT, missing=${confirmDecision.missing}. Not retrying silently.`);
    }
  }

  console.log(`Done. ${flagged} flagged, ${repaired} repaired, ${unrepairable} unrepairable.`);
}

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

Add a test

The decision function is the part most worth testing, because it decides whether the script writes to a product, and whether it is even safe to do so. Because we kept decide_product_repair pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.

test_webservice_invisible_product.py
from repair_invisible_product import decide_product_repair


def product(**over):
    base = {
        "active": 1,
        "visibility": "both",
        "id_category_default": 5,
        "associations": {"categories": [5], "shops": [1]},
    }
    base.update(over)
    return base


def context(**over):
    base = {"expectedShopIds": [1], "validCategoryIds": [2, 5]}
    base.update(over)
    return base


def test_ok_when_everything_is_wired_up():
    result = decide_product_repair(product(), context())
    assert result["status"] == "ok"
    assert result["patch"] is None


def test_ok_when_inactive_regardless_of_associations():
    p = product(active=0, associations={"categories": [], "shops": []})
    result = decide_product_repair(p, context())
    assert result["status"] == "ok"


def test_needs_repair_when_categories_empty():
    p = product(associations={"categories": [], "shops": [1]})
    result = decide_product_repair(p, context())
    assert result["status"] == "needs_repair"
    assert "categories" in result["missing"]
    assert result["patch"]["associations"]["categories"] == [5]


def test_needs_repair_when_default_category_not_in_categories():
    p = product(associations={"categories": [2], "shops": [1]})
    result = decide_product_repair(p, context())
    assert result["status"] == "needs_repair"
    assert "id_category_default_not_in_categories" in result["missing"]
    assert set(result["patch"]["associations"]["categories"]) == {2, 5}


def test_needs_repair_when_shops_empty():
    p = product(associations={"categories": [5], "shops": []})
    result = decide_product_repair(p, context())
    assert result["status"] == "needs_repair"
    assert "shops" in result["missing"]
    assert result["patch"]["associations"]["shops"] == [1]


def test_needs_repair_when_shops_missing_expected_id():
    p = product(associations={"categories": [5], "shops": [9]})
    result = decide_product_repair(p, context(expectedShopIds=[1, 2]))
    assert result["status"] == "needs_repair"
    assert "shops" in result["missing"]


def test_needs_repair_when_visibility_none():
    p = product(visibility="none")
    result = decide_product_repair(p, context())
    assert result["status"] == "needs_repair"
    assert "visibility" in result["missing"]
    assert result["patch"]["visibility"] == "both"


def test_unrepairable_when_default_category_invalid():
    p = product(id_category_default=999)
    result = decide_product_repair(p, context())
    assert result["status"] == "unrepairable"
    assert "default_category_invalid" in result["missing"]
    assert result["patch"] is None


def test_unrepairable_wins_even_with_other_missing_pieces():
    p = product(id_category_default=999, associations={"categories": [], "shops": []})
    result = decide_product_repair(p, context())
    assert result["status"] == "unrepairable"
    assert result["patch"] is None


def test_multiple_missing_pieces_combine_into_one_patch():
    p = product(visibility="none", associations={"categories": [], "shops": []})
    result = decide_product_repair(p, context())
    assert result["status"] == "needs_repair"
    assert set(result["missing"]) == {"categories", "shops", "visibility"}
    assert result["patch"]["associations"]["categories"] == [5]
    assert result["patch"]["associations"]["shops"] == [1]
    assert result["patch"]["visibility"] == "both"
repair-invisible-product.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideProductRepair } from "./repair-invisible-product.js";

const product = (over = {}) => ({
  active: 1,
  visibility: "both",
  id_category_default: 5,
  associations: { categories: [5], shops: [1] },
  ...over,
});

const context = (over = {}) => ({ expectedShopIds: [1], validCategoryIds: [2, 5], ...over });

test("ok when everything is wired up", () => {
  const result = decideProductRepair(product(), context());
  assert.equal(result.status, "ok");
  assert.equal(result.patch, null);
});

test("ok when inactive regardless of associations", () => {
  const p = product({ active: 0, associations: { categories: [], shops: [] } });
  const result = decideProductRepair(p, context());
  assert.equal(result.status, "ok");
});

test("needs repair when categories empty", () => {
  const p = product({ associations: { categories: [], shops: [1] } });
  const result = decideProductRepair(p, context());
  assert.equal(result.status, "needs_repair");
  assert.ok(result.missing.includes("categories"));
  assert.deepEqual(result.patch.associations.categories, [5]);
});

test("needs repair when default category not in categories", () => {
  const p = product({ associations: { categories: [2], shops: [1] } });
  const result = decideProductRepair(p, context());
  assert.equal(result.status, "needs_repair");
  assert.ok(result.missing.includes("id_category_default_not_in_categories"));
  assert.deepEqual([...result.patch.associations.categories].sort(), [2, 5]);
});

test("needs repair when shops empty", () => {
  const p = product({ associations: { categories: [5], shops: [] } });
  const result = decideProductRepair(p, context());
  assert.equal(result.status, "needs_repair");
  assert.ok(result.missing.includes("shops"));
  assert.deepEqual(result.patch.associations.shops, [1]);
});

test("needs repair when shops missing expected id", () => {
  const p = product({ associations: { categories: [5], shops: [9] } });
  const result = decideProductRepair(p, context({ expectedShopIds: [1, 2] }));
  assert.equal(result.status, "needs_repair");
  assert.ok(result.missing.includes("shops"));
});

test("needs repair when visibility none", () => {
  const p = product({ visibility: "none" });
  const result = decideProductRepair(p, context());
  assert.equal(result.status, "needs_repair");
  assert.ok(result.missing.includes("visibility"));
  assert.equal(result.patch.visibility, "both");
});

test("unrepairable when default category invalid", () => {
  const p = product({ id_category_default: 999 });
  const result = decideProductRepair(p, context());
  assert.equal(result.status, "unrepairable");
  assert.ok(result.missing.includes("default_category_invalid"));
  assert.equal(result.patch, null);
});

test("unrepairable wins even with other missing pieces", () => {
  const p = product({ id_category_default: 999, associations: { categories: [], shops: [] } });
  const result = decideProductRepair(p, context());
  assert.equal(result.status, "unrepairable");
  assert.equal(result.patch, null);
});

test("multiple missing pieces combine into one patch", () => {
  const p = product({ visibility: "none", associations: { categories: [], shops: [] } });
  const result = decideProductRepair(p, context());
  assert.equal(result.status, "needs_repair");
  assert.deepEqual([...result.missing].sort(), ["categories", "shops", "visibility"]);
  assert.deepEqual(result.patch.associations.categories, [5]);
  assert.deepEqual(result.patch.associations.shops, [1]);
  assert.equal(result.patch.visibility, "both");
});

Case studies

ERP import

The nightly import that never showed a single new item

A store synced new items from an ERP into PrestaShop every night through POST /api/products. The import script set price, name, and active=1, and treated the 201 response as success. Every night, a batch of new products landed in the back office looking perfectly normal, and every night, none of them appeared on the storefront until someone opened each one by hand and clicked Save.

Adding a follow-up check that reads associations.categories and associations.shops back from display=full caught the gap immediately: the import payload never included either block. Once the import started sending associations.categories with id_category_default and associations.shops for the shop, new items appeared live within minutes, no manual Save required.

Multistore

The product that sold on one shop and vanished on another

A multistore setup created a shared product once through the API and expected it to be sellable on both shops. The payload's associations.shops only ever named the first shop, so the second shop's catalog never linked to the product, even though the back office showed it as active everywhere.

Running the repair script with EXPECTED_SHOP_IDS set to both shop ids surfaced every product missing the second shop association in one pass. The merge-and-PUT step added the missing shop id without touching anything else already configured on the product, and a re-GET confirmed both shops could see it before the job moved on.

What good looks like

After this runs, every product created through the webservice either has real category and shop links from the moment it is written, or gets caught and repaired on the next pass, or is flagged for a human when its default category itself is gone. Active in the back office and visible on the storefront finally mean the same thing.

FAQ

Why is a product I created with the PrestaShop webservice not showing on the storefront?

PrestaShop's admin controller wires up category links, shop associations, and search index rows as side effects of a full product save. The webservice Product::add or update path only writes what the submitted payload explicitly includes. If the associations.categories or associations.shops blocks are missing, the product row exists and shows active in the back office grid, but there is no category_product link and no shop association, so the front-end catalog queries never return it.

Why does clicking Save on the product in the back office fix it?

Opening the product in the back office and clicking Save re-runs the full admin controller save chain, which rebuilds the category links, shop associations, and search index rows as a side effect. The webservice write skipped that chain, so the manual save is effectively doing the repair the API call should have done.

What exactly should the webservice payload include so a new product is visible?

The product resource body must include an associations.categories block whose category list contains the value of id_category_default, and an associations.shops block naming the shop the product should sell in. Visibility must also be both or catalog, not none. Without all three, the product can be active yet invisible.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Invisible Product in front added with WebService, issue #15317. github.com/PrestaShop/PrestaShop/issues/15317
  2. PrestaShop GitHub: Products created from the api do not show up in the front office, issue #28409. github.com/PrestaShop/PrestaShop/issues/28409
  3. PrestaShop Forums: Webservice API, unable to assign category and supplier when adding product. prestashop.com/forums/topic/434662-webservice-api-unable-to-assign-category-and-supplier-when-adding-product

On the solution:

  1. PrestaShop Developer Documentation: Create a product from start to finish with Webservices. devdocs.prestashop-project.org/9/webservice/tutorials/create-product-az/
  2. PrestaShop Developer Documentation: Products webservice resource reference. devdocs.prestashop-project.org/9/webservice/resources/products/
  3. PrestaShop Developer Documentation: Categories webservice resource reference. devdocs.prestashop-project.org/9/webservice/resources/categories/

Stuck on a tricky one?

If you have a problem in PrestaShop stock, orders, order states, or the webservice API 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 get your product back on the shelf?

If this saved you a confusing "why is it active but invisible" afternoon, 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 PrestaShop field notes