Skip to content

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.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Labeled packs on a shelf
Photo by Franki Chamaki on Unsplash
The short answer

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).

POST /api/products core row inserted category_product no valid position_in_category (read only, server computed) search_index never gets a row Product page direct lookup still works Cache expires or cron reindex runs category listing filters on position search filters on index rows related products block filters too product vanishes, nothing deleted
The product row survives untouched. But category, search, and related product queries filter on pieces the API creation flow never wrote, so the product quietly stops showing once cache or a reindex catches up.

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:

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.

The key insight

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.

Poll the product GET /api/products/{id}, display=full Check fields and category active, visibility, id_category_default At risk of delisting? yes, and not dry run Corrective PUT full body, rewrites category_product no, or dry run: log diff only Flag for human review Flag reindex to ops not exposed over the webservice API
The script never rewrites position_in_category by hand and never triggers a reindex itself. It sends a full corrective PUT that makes PrestaShop recompute the position on its own, and it hands the reindex step to a human or a cron job.

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, 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.

setup (shell)
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
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 DRY_RUN="true"   // start safe, change to false to write
2

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.

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 get_product(id_product):
    data = api_get(f"products/{id_product}", params={"display": "full"})
    return data.get("product")
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 getProduct(idProduct) {
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  return data.product;
}
3

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.

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

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.

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

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.

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

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.

Run it safe

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.

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.
detect_and_repair_delisted_products.py
"""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-delisted-products.js
/**
 * 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.

test_products_at_risk.py
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
products-at-risk.test.js
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

Bulk import

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.

Nightly sync

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.

What good looks like

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:

  1. PrestaShop GitHub: Products disappear after few days when added by API (Webservices), issue #36129. github.com/PrestaShop/PrestaShop/issues/36129
  2. PrestaShop GitHub: Invisible Product in front added with WebService, issue #15317. github.com/PrestaShop/PrestaShop/issues/15317
  3. PrestaShop GitHub: Invisible Product in front added with WebService, issue #28586. github.com/PrestaShop/PrestaShop/issues/28586

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. devdocs.prestashop-project.org/9/webservice/resources/products
  3. 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.

Contact me on LinkedIn

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

Back to all PrestaShop field notes