Diagnostic
Products disappear from the storefront days after being added by API
You create a product through the webservice API, look it up right after, and it looks perfect. A few days later a customer says they cannot find it. You search for it, browse the category, check "related products," and it is nowhere. But it is still in the database, nothing was deleted, and no error was ever logged. Here is why API-created products quietly fall out of navigation and a small script that catches it before your customers do.
When the API creates a product, several things the back office "Save" form normally does get skipped: the ps_category_product row is written without a valid position_in_category (a read only, server computed field the API cannot set), the product never reaches ps_search_index, and visibility, active, and id_category_default are frequently left at their defaults because they were optional fields the caller forgot to send. The product row survives, but category listing, search, and related product queries filter on those positions and index rows, so the product silently drops out of navigation once cache expires or a reindex runs. Run a Python or Node.js script that reads each API-created product back with a full field GET, flags anything missing a valid category, visibility, or index signal, and can send a corrective PUT that re-sends the full body the way a back office Save would. Full code, tests, and citations are below.
The problem in plain words
When someone creates a product by hand in the back office, clicking Save does more than insert one row. It writes the product, computes and stores a position_in_category for every category the product belongs to, pushes the product into the search index, and fills in sensible defaults for active, visibility, and id_category_default if you left them blank on the form.
The webservice API only does the first part. It inserts the core Product object, and it writes a row into ps_category_product linking the product to its categories, but position_in_category is read only and server computed, so the API cannot set it and often leaves it invalid or missing. The product is never pushed into ps_search_index at all. And because active, visibility, and id_category_default are optional fields on the API payload, a caller who forgets to send them ends up with defaults that do not match what the back office would have chosen, sometimes leaving the product outside of any real category. Right after creation, a direct product page lookup still works fine, because that lookup does not depend on any of these pieces. But the category front controller, the search listing, and the related products block all filter on positions and index rows, so once the storefront cache expires or a cron reindex job runs, the product quietly stops appearing anywhere a shopper would normally find it. Nothing is logged, and no row is deleted. This is a long standing, repeatedly reported bug (see the citations below).
Why it happens
The API and the back office form controller both end at the same Product object, but they do not do the same amount of work to get there. A few specific gaps cause this:
position_in_categoryinps_category_productis read only and server computed by the back office when a product is saved into a category. The webservice cannot set it, and product creation over the API frequently leaves it invalid, so category listing queries that order and filter by position do not surface the row correctly.- The product is never pushed into
ps_search_indexon API creation. Search results and any block that reads from the index, including some "related products" implementations, simply never see the product until a separate reindex step runs. visibility,active, andid_category_defaultare optional fields on the API payload. When the caller does not send them, they land on defaults, commonlyboth,1, and the root category, which frequently is not the category a shopper would actually browse to find the product.- Because a direct product page lookup does not depend on category position or the search index, the product looks completely fine immediately after creation. The gap only becomes visible once the storefront's own cache expires, or a cron job runs a reindex, at which point the missing pieces start actually mattering to the queries a shopper triggers.
This is a long standing, repeatedly reported bug, not a one off misconfiguration on a single store. It shows up across several issues in the PrestaShop tracker with the same shape: a product created by the API works at first, then becomes unreachable through normal navigation days later, with no error and no deleted row. See the citations at the end for the exact threads.
A product that "disappeared" almost never actually left the database. The core row is intact. What is missing is everything the back office Save form does beyond that single insert: a valid position_in_category, a search index row, and complete active/visibility/id_category_default fields. So the safe pattern is not to look for a deleted product, it is to look for a product present in GET /api/products but absent from its own default category's associations.products, or carrying defaults that do not match what should be storefront visible. That mismatch is the concrete signature of this decay.
The fix, as a flow
We do not touch checkout or storefront browsing. We add a job that polls each recently created product with a full field read, decides with one pure function whether it is at risk, and if it is, and dry run is off, sends a corrective PUT that re-sends the complete product body the way a back office Save would. Anything the API genuinely cannot fix, namely the search index rebuild, gets flagged for a human or a scheduled cron job instead of being auto-triggered.
Build it step by step
Enable the webservice and get a key
In the back office, go to Advanced Parameters, Webservice, and create a key with access to products, categories, and stock_availables. 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.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, change to false to write
// 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 DRY_RUN="true" // start safe, change to false to write
Read a product back with a full field GET
Call GET /api/products/{id}?output_format=JSON&display=full. This is the same shape of call the storefront's own lookups make, so it is the fairest way to see the fields that decide whether the product will keep showing: active, visibility, id_category_default, and the full associations.categories list.
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 get_product(id_product):
data = api_get(f"products/{id_product}", params={"display": "full"})
return data.get("product")
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 getProduct(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
return data.product;
}
Cross-check the category and the stock
Fetch the product's own default category with GET /api/categories/{id_category_default}?output_format=JSON&display=full and check whether its associations.products list still contains the product id. A product present in the products list but absent from its default category's product association is the concrete signature of this decay. Also read GET /api/stock_availables?filter[id_product]={id}&output_format=JSON&display=full so a real out of stock situation is not confused with the delisting bug.
def category_product_ids(id_category):
data = api_get(f"categories/{id_category}", params={"display": "full"})
category = data.get("category") or {}
products = ((category.get("associations") or {}).get("products") or {}).get("product") or []
return {p["id"] for p in products}
def stock_available_for(id_product, id_product_attribute=0):
data = api_get("stock_availables", params={
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
"display": "full",
})
rows = data.get("stock_availables") or []
return rows[0] if rows else None
async function categoryProductIds(idCategory) {
const data = await apiGet(`categories/${idCategory}`, { display: "full" });
const category = data.category || {};
const products = (category.associations && category.associations.products && category.associations.products.product) || [];
return new Set(products.map((p) => p.id));
}
async function stockAvailableFor(idProduct, idProductAttribute = 0) {
const data = await apiGet("stock_availables", {
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute,
display: "full",
});
const rows = data.stock_availables || [];
return rows.length ? rows[0] : null;
}
Decide, with one pure function
Keep the decision in its own function that takes only primitive inputs, no I/O at all. It flags a product as at risk of delisting when active is not "1", when visibility is not one of "both" or "catalog", when id_category_default is 0, when that default category id is not present in the product's own category id list, when the category id list is empty, or when the stock is out and marked to deny orders. Because it is pure, the same function decides both whether to flag a product for detection and whether a repair payload is now complete enough to be considered safe.
def is_product_at_risk_of_delisting(active, visibility, id_category_default,
category_ids, stock_quantity, out_of_stock):
reasons = []
if active != "1":
reasons.append("active is not \"1\"")
if visibility not in ("both", "catalog"):
reasons.append("visibility is not storefront visible")
if id_category_default == 0:
reasons.append("id_category_default is 0")
if not category_ids:
reasons.append("associations.categories is empty")
elif id_category_default not in category_ids:
reasons.append("id_category_default is not in associations.categories")
if stock_quantity <= 0 and out_of_stock == 2:
reasons.append("out of stock and denying orders")
return (len(reasons) > 0, reasons)
export function isProductAtRiskOfDelisting(
active, visibility, idCategoryDefault, categoryIds, stockQuantity, outOfStock
) {
const reasons = [];
if (active !== "1") reasons.push('active is not "1"');
if (visibility !== "both" && visibility !== "catalog") reasons.push("visibility is not storefront visible");
if (idCategoryDefault === 0) reasons.push("id_category_default is 0");
if (!categoryIds.length) reasons.push("associations.categories is empty");
else if (!categoryIds.includes(idCategoryDefault)) reasons.push("id_category_default is not in associations.categories");
if (stockQuantity <= 0 && outOfStock === 2) reasons.push("out of stock and denying orders");
return [reasons.length > 0, reasons];
}
Repair with a full corrective PUT, guarded by dry run
The API cannot rewrite position_in_category directly, it is read only. So the safe repair is to PUT the full product body again with explicit, complete fields: active=1, visibility=both, id_category_default set, and associations.categories.category[].id containing every intended category id, including the same id it already had. Re-adding the same category id forces PrestaShop to rewrite the ps_category_product row, including recomputing its position. When DRY_RUN is true, only log the diff between the current and intended payload and never call PUT.
def build_corrective_payload(product, id_category_default, category_ids):
body = dict(product)
body["active"] = "1"
body["visibility"] = "both"
body["id_category_default"] = id_category_default
body["associations"] = dict(body.get("associations") or {})
body["associations"]["categories"] = {
"category": [{"id": cid} for cid in sorted(set(category_ids) | {id_category_default})]
}
return body
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 repair_product(id_product, payload):
return api_put(f"products/{id_product}", "product", payload)
function buildCorrectivePayload(product, idCategoryDefault, categoryIds) {
const body = { ...product };
body.active = "1";
body.visibility = "both";
body.id_category_default = idCategoryDefault;
const ids = Array.from(new Set([...categoryIds, idCategoryDefault])).sort((a, b) => a - b);
body.associations = { ...(body.associations || {}), categories: { category: ids.map((id) => ({ id })) } };
return body;
}
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 repairProduct(idProduct, payload) {
return apiPut(`products/${idProduct}`, "product", payload);
}
Wire it together and flag the reindex step
The loop lists recently created product ids, reads each one back in full, cross checks its default category association and stock, runs the pure decision function, and only PUTs the corrective payload when the product is at risk and DRY_RUN is false. After a PUT, log the product for manual confirmation that it reappears in the category's associations. Rebuilding the search index itself is not exposed over the webservice API, it needs the back office "Add missing products to the index" action or a cron running bin/console prestashop:index, so that step is only reported to a human or an ops job, never auto-triggered here.
Always start with DRY_RUN=true and read the logged diffs before switching it off. The script never rewrites position_in_category directly and never calls a reindex. It only resends a complete product body, the same fields a back office Save would send, and it flags the search index rebuild for a human every time.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, never claims to trigger a reindex, respects the dry run flag, and only ever PUTs a full corrective payload for a product it has already proven is at risk.
"""Detect and repair PrestaShop products that disappear days after API creation.
Creating a product through the webservice API inserts the core Product object, but
skips several side effects the back office Save form normally does: position_in_category
in ps_category_product is left invalid (it is read only and server computed, the API
cannot set it), the product never reaches ps_search_index, and active, visibility, and
id_category_default are frequently left at defaults because they were optional fields
the caller forgot to send. The product row survives, but category listing, search, and
related products queries filter on those missing pieces, so the product quietly drops
out of navigation once cache expires or a reindex runs (PrestaShop/PrestaShop issues
#36129, #15317, #28586, #28409, #11682).
This script polls each product back with a full field GET, cross-checks its default
category's own product associations and its stock, and runs a pure decision function
that flags products at risk. The only sanctioned write (when DRY_RUN=false) is a
corrective PUT that resends the full product body with explicit active, visibility,
id_category_default, and associations.categories, mirroring a back office Save. This
forces PrestaShop to rewrite the category_product row, including its position. Search
index rebuilding is not exposed over the webservice API, so that step is only reported
to a human or an ops job, never triggered here.
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("detect_and_repair_delisted_products")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")
def is_product_at_risk_of_delisting(active, visibility, id_category_default,
category_ids, stock_quantity, out_of_stock):
"""Pure decision function, no I/O.
active: the product's "active" field, as the string the API returns ("0" or "1").
visibility: "both", "catalog", "search", or "none".
id_category_default: the product's default category id.
category_ids: list of category ids from associations.categories.
stock_quantity: current stock_availables.quantity.
out_of_stock: stock_availables.out_of_stock (0 deny, 1 allow, 2 use default policy
as configured; treated here as deny for the at-risk check).
Returns (is_at_risk, reasons). Used both to detect at-risk products from a plain
GET, and to check whether a corrective PUT payload is now complete enough to be
considered safe, without ever touching the network.
"""
reasons = []
if active != "1":
reasons.append("active is not \"1\"")
if visibility not in ("both", "catalog"):
reasons.append("visibility is not storefront visible")
if id_category_default == 0:
reasons.append("id_category_default is 0")
if not category_ids:
reasons.append("associations.categories is empty")
elif id_category_default not in category_ids:
reasons.append("id_category_default is not in associations.categories")
if stock_quantity <= 0 and out_of_stock == 2:
reasons.append("out of stock and denying orders")
return (len(reasons) > 0, reasons)
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_product_ids(min_id, max_id, limit=100):
data = api_get("products", params={
"filter[id]": f"[{min_id},{max_id}]",
"display": "[id,active,visibility,id_category_default]",
"limit": limit,
})
products = data.get("products") or []
return [int(p["id"]) for p in products]
def get_product(id_product):
data = api_get(f"products/{id_product}", params={"display": "full"})
return data.get("product")
def category_product_ids(id_category):
data = api_get(f"categories/{id_category}", params={"display": "full"})
category = data.get("category") or {}
products = ((category.get("associations") or {}).get("products") or {}).get("product") or []
return {int(p["id"]) for p in products}
def stock_available_for(id_product, id_product_attribute=0):
data = api_get("stock_availables", params={
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
"display": "full",
})
rows = data.get("stock_availables") or []
return rows[0] if rows else None
def category_ids_from_product(product):
cats = ((product.get("associations") or {}).get("categories") or {}).get("category") or []
return [int(c["id"]) for c in cats]
def build_corrective_payload(product, id_category_default, category_ids):
body = dict(product)
body["active"] = "1"
body["visibility"] = "both"
body["id_category_default"] = id_category_default
body["associations"] = dict(body.get("associations") or {})
body["associations"]["categories"] = {
"category": [{"id": cid} for cid in sorted(set(category_ids) | {id_category_default})]
}
return body
def repair_product(id_product, payload):
return api_put(f"products/{id_product}", "product", payload)
def run():
min_id = int(os.environ.get("SCAN_MIN_ID", "1"))
max_id = int(os.environ.get("SCAN_MAX_ID", "100"))
flagged = 0
repaired = 0
for id_product in list_recent_product_ids(min_id, max_id):
product = get_product(id_product)
if not product:
continue
active = str(product.get("active", "0"))
visibility = product.get("visibility", "both")
id_category_default = int(product.get("id_category_default", 0))
category_ids = category_ids_from_product(product)
row = stock_available_for(id_product)
stock_quantity = int(row["quantity"]) if row else 0
out_of_stock = int(row["out_of_stock"]) if row else 0
at_risk, reasons = is_product_at_risk_of_delisting(
active, visibility, id_category_default, category_ids, stock_quantity, out_of_stock
)
if not at_risk:
continue
flagged += 1
log.warning("Product %s at risk of delisting: %s", id_product, "; ".join(reasons))
if id_category_default:
present = id_product in category_product_ids(id_category_default)
if not present:
log.warning(
"Product %s is missing from its default category %s associations.products.",
id_product, id_category_default,
)
if DRY_RUN:
log.info("Dry run: would PUT corrective payload for product %s.", id_product)
continue
payload = build_corrective_payload(
product,
id_category_default or int(os.environ.get("FALLBACK_CATEGORY_ID", "2")),
category_ids,
)
repair_product(id_product, payload)
repaired += 1
log.info(
"Repaired product %s. Flagging for manual confirmation and for a human or cron "
"to run the search index rebuild (not exposed over the webservice API).",
id_product,
)
log.info("Done. %d product(s) flagged, %d repaired.", flagged, repaired)
if __name__ == "__main__":
run()
/**
* Detect and repair PrestaShop products that disappear days after API creation.
*
* Creating a product through the webservice API inserts the core Product object, but
* skips several side effects the back office Save form normally does: position_in_category
* in ps_category_product is left invalid (it is read only and server computed, the API
* cannot set it), the product never reaches ps_search_index, and active, visibility, and
* id_category_default are frequently left at defaults because they were optional fields
* the caller forgot to send. The product row survives, but category listing, search, and
* related products queries filter on those missing pieces, so the product quietly drops
* out of navigation once cache expires or a reindex runs (PrestaShop/PrestaShop issues
* #36129, #15317, #28586, #28409, #11682).
*
* This script polls each product back with a full field GET, cross-checks its default
* category's own product associations and its stock, and runs a pure decision function
* that flags products at risk. The only sanctioned write (when DRY_RUN=false) is a
* corrective PUT that resends the full product body with explicit active, visibility,
* id_category_default, and associations.categories, mirroring a back office Save. This
* forces PrestaShop to rewrite the category_product row, including its position. Search
* index rebuilding is not exposed over the webservice API, so that step is only reported
* to a human or an ops job, never triggered here.
*
* Guide: https://www.allanninal.dev/prestashop/products-disappear-days-after-api-creation/
*/
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 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.
*
* active: the product's "active" field, as the string the API returns ("0" or "1").
* visibility: "both", "catalog", "search", or "none".
* idCategoryDefault: the product's default category id.
* categoryIds: array of category ids from associations.categories.
* stockQuantity: current stock_availables.quantity.
* outOfStock: stock_availables.out_of_stock (0 deny, 1 allow, 2 use default policy as
* configured; treated here as deny for the at-risk check).
*
* Returns [isAtRisk, reasons]. Used both to detect at-risk products from a plain GET,
* and to check whether a corrective PUT payload is now complete enough to be considered
* safe, without ever touching the network.
*/
export function isProductAtRiskOfDelisting(
active, visibility, idCategoryDefault, categoryIds, stockQuantity, outOfStock
) {
const reasons = [];
if (active !== "1") reasons.push('active is not "1"');
if (visibility !== "both" && visibility !== "catalog") reasons.push("visibility is not storefront visible");
if (idCategoryDefault === 0) reasons.push("id_category_default is 0");
if (!categoryIds.length) reasons.push("associations.categories is empty");
else if (!categoryIds.includes(idCategoryDefault)) reasons.push("id_category_default is not in associations.categories");
if (stockQuantity <= 0 && outOfStock === 2) reasons.push("out of stock and denying orders");
return [reasons.length > 0, reasons];
}
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 listRecentProductIds(minId, maxId, limit = 100) {
const data = await apiGet("products", {
"filter[id]": `[${minId},${maxId}]`,
display: "[id,active,visibility,id_category_default]",
limit,
});
const products = data.products || [];
return products.map((p) => Number(p.id));
}
async function getProduct(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
return data.product;
}
async function categoryProductIds(idCategory) {
const data = await apiGet(`categories/${idCategory}`, { display: "full" });
const category = data.category || {};
const products = (category.associations && category.associations.products && category.associations.products.product) || [];
return new Set(products.map((p) => Number(p.id)));
}
async function stockAvailableFor(idProduct, idProductAttribute = 0) {
const data = await apiGet("stock_availables", {
"filter[id_product]": idProduct,
"filter[id_product_attribute]": idProductAttribute,
display: "full",
});
const rows = data.stock_availables || [];
return rows.length ? rows[0] : null;
}
function categoryIdsFromProduct(product) {
const cats = (product.associations && product.associations.categories && product.associations.categories.category) || [];
return cats.map((c) => Number(c.id));
}
function buildCorrectivePayload(product, idCategoryDefault, categoryIds) {
const body = { ...product };
body.active = "1";
body.visibility = "both";
body.id_category_default = idCategoryDefault;
const ids = Array.from(new Set([...categoryIds, idCategoryDefault])).sort((a, b) => a - b);
body.associations = { ...(body.associations || {}), categories: { category: ids.map((id) => ({ id })) } };
return body;
}
async function repairProduct(idProduct, payload) {
return apiPut(`products/${idProduct}`, "product", payload);
}
export async function run() {
const minId = Number(process.env.SCAN_MIN_ID || 1);
const maxId = Number(process.env.SCAN_MAX_ID || 100);
let flagged = 0;
let repaired = 0;
for (const idProduct of await listRecentProductIds(minId, maxId)) {
const product = await getProduct(idProduct);
if (!product) continue;
const active = String(product.active || "0");
const visibility = product.visibility || "both";
const idCategoryDefault = Number(product.id_category_default || 0);
const categoryIds = categoryIdsFromProduct(product);
const row = await stockAvailableFor(idProduct);
const stockQuantity = row ? Number(row.quantity) : 0;
const outOfStock = row ? Number(row.out_of_stock) : 0;
const [atRisk, reasons] = isProductAtRiskOfDelisting(
active, visibility, idCategoryDefault, categoryIds, stockQuantity, outOfStock
);
if (!atRisk) continue;
flagged++;
console.warn(`Product ${idProduct} at risk of delisting: ${reasons.join("; ")}`);
if (idCategoryDefault) {
const present = (await categoryProductIds(idCategoryDefault)).has(idProduct);
if (!present) {
console.warn(`Product ${idProduct} is missing from its default category ${idCategoryDefault} associations.products.`);
}
}
if (DRY_RUN) {
console.log(`Dry run: would PUT corrective payload for product ${idProduct}.`);
continue;
}
const fallbackCategoryId = Number(process.env.FALLBACK_CATEGORY_ID || 2);
const payload = buildCorrectivePayload(product, idCategoryDefault || fallbackCategoryId, categoryIds);
await repairProduct(idProduct, payload);
repaired++;
console.log(
`Repaired product ${idProduct}. Flagging for manual confirmation and for a human or cron ` +
`to run the search index rebuild (not exposed over the webservice API).`
);
}
console.log(`Done. ${flagged} product(s) flagged, ${repaired} repaired.`);
}
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 a product gets flagged and, later, whether a repair payload is finally complete. Because we kept is_product_at_risk_of_delisting pure, the test needs no network and no PrestaShop store. It just feeds in plain values and checks the answer.
from detect_and_repair_delisted_products import is_product_at_risk_of_delisting
def test_healthy_product_is_not_at_risk():
at_risk, reasons = is_product_at_risk_of_delisting("1", "both", 3, [2, 3], 12, 0)
assert at_risk is False
assert reasons == []
def test_flags_when_inactive():
at_risk, reasons = is_product_at_risk_of_delisting("0", "both", 3, [2, 3], 12, 0)
assert at_risk is True
assert any("active" in r for r in reasons)
def test_flags_when_visibility_is_none():
at_risk, reasons = is_product_at_risk_of_delisting("1", "none", 3, [2, 3], 12, 0)
assert at_risk is True
assert any("visibility" in r for r in reasons)
def test_visibility_catalog_is_allowed():
at_risk, _ = is_product_at_risk_of_delisting("1", "catalog", 3, [2, 3], 12, 0)
assert at_risk is False
def test_flags_when_id_category_default_is_zero():
at_risk, reasons = is_product_at_risk_of_delisting("1", "both", 0, [2, 3], 12, 0)
assert at_risk is True
assert any("id_category_default is 0" in r for r in reasons)
def test_flags_when_category_ids_empty():
at_risk, reasons = is_product_at_risk_of_delisting("1", "both", 3, [], 12, 0)
assert at_risk is True
assert any("empty" in r for r in reasons)
def test_flags_when_default_category_not_in_category_ids():
at_risk, reasons = is_product_at_risk_of_delisting("1", "both", 9, [2, 3], 12, 0)
assert at_risk is True
assert any("not in associations.categories" in r for r in reasons)
def test_flags_when_out_of_stock_and_denying_orders():
at_risk, reasons = is_product_at_risk_of_delisting("1", "both", 3, [2, 3], 0, 2)
assert at_risk is True
assert any("out of stock" in r for r in reasons)
def test_out_of_stock_but_backorder_allowed_is_not_flagged_for_stock():
at_risk, reasons = is_product_at_risk_of_delisting("1", "both", 3, [2, 3], 0, 1)
assert at_risk is False
def test_multiple_reasons_can_stack():
at_risk, reasons = is_product_at_risk_of_delisting("0", "none", 0, [], 0, 2)
assert at_risk is True
assert len(reasons) == 5
import { test } from "node:test";
import assert from "node:assert/strict";
import { isProductAtRiskOfDelisting } from "./detect-and-repair-delisted-products.js";
test("healthy product is not at risk", () => {
const [atRisk, reasons] = isProductAtRiskOfDelisting("1", "both", 3, [2, 3], 12, 0);
assert.equal(atRisk, false);
assert.deepEqual(reasons, []);
});
test("flags when inactive", () => {
const [atRisk, reasons] = isProductAtRiskOfDelisting("0", "both", 3, [2, 3], 12, 0);
assert.equal(atRisk, true);
assert.ok(reasons.some((r) => r.includes("active")));
});
test("flags when visibility is none", () => {
const [atRisk, reasons] = isProductAtRiskOfDelisting("1", "none", 3, [2, 3], 12, 0);
assert.equal(atRisk, true);
assert.ok(reasons.some((r) => r.includes("visibility")));
});
test("visibility catalog is allowed", () => {
const [atRisk] = isProductAtRiskOfDelisting("1", "catalog", 3, [2, 3], 12, 0);
assert.equal(atRisk, false);
});
test("flags when id_category_default is zero", () => {
const [atRisk, reasons] = isProductAtRiskOfDelisting("1", "both", 0, [2, 3], 12, 0);
assert.equal(atRisk, true);
assert.ok(reasons.some((r) => r.includes("id_category_default is 0")));
});
test("flags when category ids empty", () => {
const [atRisk, reasons] = isProductAtRiskOfDelisting("1", "both", 3, [], 12, 0);
assert.equal(atRisk, true);
assert.ok(reasons.some((r) => r.includes("empty")));
});
test("flags when default category not in category ids", () => {
const [atRisk, reasons] = isProductAtRiskOfDelisting("1", "both", 9, [2, 3], 12, 0);
assert.equal(atRisk, true);
assert.ok(reasons.some((r) => r.includes("not in associations.categories")));
});
test("flags when out of stock and denying orders", () => {
const [atRisk, reasons] = isProductAtRiskOfDelisting("1", "both", 3, [2, 3], 0, 2);
assert.equal(atRisk, true);
assert.ok(reasons.some((r) => r.includes("out of stock")));
});
test("out of stock but backorder allowed is not flagged for stock", () => {
const [atRisk] = isProductAtRiskOfDelisting("1", "both", 3, [2, 3], 0, 1);
assert.equal(atRisk, false);
});
test("multiple reasons can stack", () => {
const [atRisk, reasons] = isProductAtRiskOfDelisting("0", "none", 0, [], 0, 2);
assert.equal(atRisk, true);
assert.equal(reasons.length, 5);
});
Case studies
The catalog migration that half vanished within a week
A store migrated three thousand products through a bulk API import script. Everything looked right at launch, product pages loaded, prices were correct, images were attached. Ten days later support tickets started mentioning products that "used to be there." Category pages had quietly dropped hundreds of the imported items after the nightly cron ran a reindex.
The import script had never sent associations.categories at all for a large batch, so those products landed with an empty category list and a root id_category_default. Running the detection script against the whole imported id range surfaced every one of them in under a minute, and the corrective PUT put them all back in their categories the same afternoon.
The ERP feed that kept losing new products
An ERP integration created new products through the API every night as inventory arrived. New arrivals showed up fine on the day they were added, since staff checked them by opening the product page directly. But new arrivals kept mysteriously failing to show up in search a few days later, and no one could reproduce it on demand.
Adding the detection script as a follow up job, run an hour after the nightly sync, caught the pattern immediately: the ERP feed never sent visibility, so it defaulted in a way that satisfied the product page but not search. Fixing the feed to always send visibility=both stopped new cases, and the corrective PUT cleaned up the backlog that had already decayed.
After this runs on a schedule, an API-created product that would have quietly decayed gets caught and corrected before a customer ever notices. The pure decision function makes the exact criteria testable and auditable, dry run lets you review every diff before it writes anything, and the one thing the API truly cannot do, rebuilding the search index, is always handed to a human or a cron job instead of being silently assumed to have happened.
FAQ
Why do products created through the PrestaShop webservice API disappear a few days later?
Creating a product through the API inserts the core product row, but it skips side effects that the back office Save form normally does: the category_product row gets no valid position_in_category, the product never reaches the search index, and visibility, active, and id_category_default are often left at defaults. The product still exists and can still be opened directly, but category listings, search, and related products filter on those missing pieces, so the product quietly stops showing once cache expires or a reindex runs.
Can I fix position_in_category or force a reindex through the API?
No. position_in_category is a read only, server computed field, so the webservice cannot set it directly, and rebuilding the search index is not exposed over the webservice API at all. The reliable workaround is to PUT the full product again with explicit active, visibility, id_category_default, and associations.categories, which makes PrestaShop rewrite the category_product row including its position. The index rebuild itself still needs the back office Add missing products to the index action or a prestashop:index cron run.
How do I detect which API-created products are at risk before they disappear?
Poll each newly created product with a full field GET and check active, visibility, id_category_default, and associations.categories. A product that is present in the products list but missing from its default category associations, or that has active not equal to 1, an unexpected visibility, or no valid default category, is showing the exact signature of this issue even though the product row itself was never deleted.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Products disappear after few days when added by API (Webservices), issue #36129. github.com/PrestaShop/PrestaShop/issues/36129
- PrestaShop GitHub: Invisible Product in front added with WebService, issue #15317. github.com/PrestaShop/PrestaShop/issues/15317
- PrestaShop GitHub: Invisible Product in front added with WebService, issue #28586. github.com/PrestaShop/PrestaShop/issues/28586
On the solution:
- PrestaShop Developer Documentation: Create a product from start to finish with Webservices. devdocs.prestashop-project.org/9/webservice/tutorials/create-product-az
- PrestaShop Developer Documentation: Products webservice resource. devdocs.prestashop-project.org/9/webservice/resources/products
- PrestaShop Developer Documentation: Categories webservice resource. 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.
Did this bring a product back?
If this saved you a confusing support ticket or a quiet drop in sales, 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