Diagnostic
Category created via webservice ignores shop scoping in multistore
Your integration posts a new category to the PrestaShop webservice, meaning it for a single shop in a multistore setup. Check the back office and the category is sitting in every shop, not just the one you meant. Nothing errored. Nothing warned you. This is not a mistake in your JSON body. The webservice categories resource has no visible field for scoping shops, so unless you explicitly add id_shop to the query string, PrestaShop associates the category with the entire shop context by default. Here is why that happens and a small script that flags every category this has likely happened to.
When a category is created or updated via a plain POST or PUT to /api/categories, PrestaShop's underlying ObjectModel::add() and update() associate the object with every shop in the current shop context unless the request explicitly scopes the write with an id_shop query parameter. The categories schema exposes id_shop_default, but that field only marks the shop used for display, it does not restrict which ps_category_shop rows get written. Because there is no visible shops association node for categories, most integrators never pass id_shop, and every category ends up linked to all shops (see PrestaShop/PrestaShop issues #13987 and #22918 in the citations). Run a Python or Node.js script that pulls back the categories your integration just wrote, checks whether their associated shops go beyond what you intended, and flags the over-associated ones for review. Repair is a separate, explicitly confirmed step. Full code, tests, and citations are below.
The problem in plain words
In a single shop PrestaShop install, there is only one shop to associate anything with, so this never comes up. Multistore changes that. Every category, product, and CMS page can be linked to one shop, a handful of shops, or all of them, and the webservice write path has to know which one you meant.
For products, the webservice schema gives you an associations node where you can list the shops explicitly. Categories do not get that same treatment. The categories resource schema has no shops association array at all. So when your integration sends a plain POST /api/categories or PUT /api/categories/{id} with no id_shop on the query string, PrestaShop falls back to whatever the current shop context is, which in practice usually means every shop the request has access to. The category is created, the response looks fine, and the only field that hints at shops, id_shop_default, quietly reports one shop while the category is actually attached to all of them.
Why it happens
This is a documented gap between how multistore associations work for products and how they work for categories, not a bug in any one integration. A few things make it easy to miss:
- PrestaShop's core
ObjectModel::add()andupdate()methods associate a multistore-aware object with the shops in the current context by default, unless the caller explicitly narrows that with anid_shoporid_group_shopparameter. - The webservice schema for
productsexposes anassociationsblock where shops can be listed directly in the body. Thecategoriesschema has no equivalent shops association node, so there is no obvious place in the JSON or XML to even attempt scoping it there. id_shop_defaultlooks like it should be the shop association, but it only controls which shop is used to build things like the category's canonical link. It is a display hint, not a restriction onps_category_shoprows.- Because nothing in the request or the response calls this out, most integrators never learn they needed to pass
?id_shop=Xuntil someone in the back office notices the category showing up where it should not (confirmed in PrestaShop/PrestaShop issues #13987 and #22918).
This has come up often enough on the PrestaShop forums and issue tracker that the official guidance now explicitly calls out the query parameter as the only lever. See the citations at the end for the exact threads and docs.
The webservice cannot tell you which categories are over-associated, because it does not expose a shops list for categories at all. So the safe pattern is not to guess and rewrite associations automatically. It is to compare what you expected against what a plausible signal shows, using id_shop_default together with the shop count from /api/shops, and flag anything that looks like it was written without id_shop for a human to confirm before any corrective write happens.
The fix, as a flow
We do not touch the storefront or the live category tree automatically. We add a job that reads the shops your PrestaShop install actually has, pulls back the categories your integration wrote, and runs a pure decision function that flags any category whose association looks broader than intended. A corrective PUT is only sent when it is explicitly authorized, one category at a time.
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 categories and shops. 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 EXPECTED_SHOP_IDS="1" # comma separated shop ids the integration should use
export DRY_RUN="true" # start safe, change to false to allow the scoping PUT
// 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 integration should use
export DRY_RUN="true" // start safe, change to false to allow the scoping PUT
Enumerate the shops in this install
Call GET /api/shops?output_format=JSON&display=full to get every valid shop id. This is what tells you both the total shop count and, together with your own configuration, which shop id the integration was actually supposed to use.
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 all_shop_ids():
data = api_get("shops", params={"display": "full"})
rows = data.get("shops") or []
return {int(row["id"]) for row in rows}
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 allShopIds() {
const data = await apiGet("shops", { display: "full" });
const rows = data.shops || [];
return new Set(rows.map((row) => Number(row.id)));
}
Pull back the categories the integration wrote
Call GET /api/categories?output_format=JSON&display=full&filter[date_add]=[...] filtered to the recent window your sync job just touched, or by id or id_parent for the specific categories it created. Read id, id_shop_default, and, when your setup exposes it, any associated shops list. Categories give you no dedicated association endpoint, so this read is the closest signal available over the webservice.
def recent_categories(date_from):
data = api_get("categories", params={
"display": "full",
"filter[date_add]": f"[{date_from},today]",
})
return data.get("categories") or []
async function recentCategories(dateFrom) {
const data = await apiGet("categories", {
display: "full",
"filter[date_add]": `[${dateFrom},today]`,
});
return data.categories || [];
}
Decide, with one pure function
Keep the decision in its own function that takes only plain dicts and sets, no I/O at all. It compares the shop ids a category is actually associated with against the shop ids you expected, and against the full set of shops in the install. A category is flagged when its associated shops go beyond what was expected, or when it is linked to every shop while you only ever intended a subset.
def resolved_shop_ids(category):
associations = (category.get("associations") or {}).get("shops")
if associations:
return {int(row["id"]) for row in associations}
default = category.get("id_shop_default")
return {int(default)} if default is not None else set()
def is_over_associated(category, expected_shop_ids, all_shop_ids):
associated = resolved_shop_ids(category)
if not associated:
return False
over_expected = bool(associated - set(expected_shop_ids))
all_shops_but_expected_narrower = (
associated == set(all_shop_ids) and len(expected_shop_ids) < len(all_shop_ids)
)
return over_expected or all_shops_but_expected_narrower
def unintended_shop_ids(category, expected_shop_ids):
return resolved_shop_ids(category) - set(expected_shop_ids)
export function resolvedShopIds(category) {
const associations = category.associations && category.associations.shops;
if (associations && associations.length) {
return new Set(associations.map((row) => Number(row.id)));
}
const fallback = category.id_shop_default;
return fallback != null ? new Set([Number(fallback)]) : new Set();
}
export function isOverAssociated(category, expectedShopIds, allShopIds) {
const associated = resolvedShopIds(category);
if (associated.size === 0) return false;
const overExpected = [...associated].some((id) => !expectedShopIds.has(id));
const sameSize = associated.size === allShopIds.size;
const coversAllShops = sameSize && [...allShopIds].every((id) => associated.has(id));
const allShopsButExpectedNarrower = coversAllShops && expectedShopIds.size < allShopIds.size;
return overExpected || allShopsButExpectedNarrower;
}
Report by default, repair only when explicitly confirmed
The association cannot be safely narrowed with a generic webservice call, because there is no way to tell an intentional multi-shop assignment from an accidental one. So the default behavior is to log every flagged category id, its unintended shop ids, and stop there. Only when DRY_RUN=false and a --confirm flag is passed does the script send the corrective write, and it does so one category at a time.
def api_put(path, resource_key, body, params):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params=params, auth=AUTH,
json={resource_key: body}, timeout=30,
)
r.raise_for_status()
return r.json()
def rescope_category_to_single_shop(category, id_shop):
# Resend the identical body, only scoping the query string to id_shop and
# updating id_shop_default. This is the documented pattern; there is no
# dedicated association endpoint for categories.
body = dict(category)
body["id_shop_default"] = id_shop
return api_put(
f"categories/{category['id']}", "category", body,
params={"output_format": "JSON", "id_shop": id_shop},
)
async function apiPut(path, resourceKey, body, params) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
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 rescopeCategoryToSingleShop(category, idShop) {
// Resend the identical body, only scoping the query string to id_shop and
// updating id_shop_default. This is the documented pattern; there is no
// dedicated association endpoint for categories.
const body = { ...category, id_shop_default: idShop };
return apiPut(`categories/${category.id}`, "category", body, {
output_format: "JSON",
id_shop: idShop,
});
}
Wire it together with a dry run guard
The loop ties every piece together: list shops, list recent categories, run each through is_over_associated, log the flagged ones with their unintended shop ids, and only call the scoping PUT when both DRY_RUN=false and --confirm are present for that run. Leave DRY_RUN on and review the report first, since narrowing a category's shops is not reversible from the webservice alone if you get the expected shop wrong.
Always start with DRY_RUN=true. Flagging is safe and reversible; narrowing a category's shop association is not something the webservice can undo on its own. Only pass --confirm once a human has checked the flagged list and agreed the extra shops were never intended.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, reports by default, and only sends the scoping PUT to a specific category id when both the dry run flag is off and the confirm flag is explicitly passed.
"""Flag PrestaShop categories written via the webservice without shop scoping.
When a category is created or updated with a plain POST or PUT to /api/categories,
PrestaShop's ObjectModel::add()/update() associates it with every shop in the current
shop context unless the request explicitly narrows that with an id_shop query
parameter. The categories schema exposes id_shop_default, but that only marks the
shop used for display, it is not an association list (PrestaShop/PrestaShop issues
#13987 and #22918).
This script lists the shops in the install, pulls back categories in a given window,
and runs a pure decision function that flags any category whose resolved shop ids go
beyond what was expected. It reports by default. A corrective PUT that resends the
same category body scoped to a single id_shop is only sent when DRY_RUN=false and
--confirm is passed, one category id at a time.
Run on a schedule, or right after a sync job. Safe to run again and again.
"""
import os
import sys
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_category_shop_scope")
PRESTASHOP_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
PRESTASHOP_WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
EXPECTED_SHOP_IDS = {
int(x) for x in os.environ.get("EXPECTED_SHOP_IDS", "1").split(",") if x.strip()
}
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")
def resolved_shop_ids(category):
"""Best-effort extraction of the shops a category is actually linked to.
Prefers an explicit associations.shops list when the install exposes one.
Falls back to id_shop_default, which is a display hint, not a true
association list, but is the only signal the standard schema guarantees.
"""
associations = (category.get("associations") or {}).get("shops")
if associations:
return {int(row["id"]) for row in associations}
default = category.get("id_shop_default")
return {int(default)} if default is not None else set()
def is_over_associated(category, expected_shop_ids, all_shop_ids):
"""Pure decision function, no I/O.
category: plain dict with at least id_shop_default and, when available,
associations.shops.
expected_shop_ids: set[int] of shop ids the integration intended to use.
all_shop_ids: set[int] of every shop id in the install.
Returns True when the category's resolved shop ids are a superset of the
expected set with extras, or when it is associated with every shop while
the expected set is narrower than that.
"""
associated = resolved_shop_ids(category)
if not associated:
return False
over_expected = bool(associated - set(expected_shop_ids))
all_shops_but_expected_narrower = (
associated == set(all_shop_ids) and len(expected_shop_ids) < len(all_shop_ids)
)
return over_expected or all_shops_but_expected_narrower
def unintended_shop_ids(category, expected_shop_ids):
"""Companion function: the diff set for reporting. Pure, no I/O."""
return resolved_shop_ids(category) - set(expected_shop_ids)
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, params):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params=params, auth=AUTH,
json={resource_key: body}, timeout=30,
)
r.raise_for_status()
return r.json()
def all_shop_ids():
data = api_get("shops", params={"display": "full"})
rows = data.get("shops") or []
return {int(row["id"]) for row in rows}
def recent_categories(date_from):
data = api_get("categories", params={
"display": "full",
"filter[date_add]": f"[{date_from},today]",
})
return data.get("categories") or []
def rescope_category_to_single_shop(category, id_shop):
# Resend the identical body, only scoping the query string to id_shop and
# updating id_shop_default. This is the documented pattern; there is no
# dedicated association endpoint for categories.
body = dict(category)
body["id_shop_default"] = id_shop
return api_put(
f"categories/{category['id']}", "category", body,
params={"output_format": "JSON", "id_shop": id_shop},
)
def run(date_from="2000-01-01", confirm=False):
shops = all_shop_ids()
flagged = 0
repaired = 0
for category in recent_categories(date_from):
if not is_over_associated(category, EXPECTED_SHOP_IDS, shops):
continue
flagged += 1
extra = sorted(unintended_shop_ids(category, EXPECTED_SHOP_IDS))
log.warning(
"Category id=%s id_shop_default=%s unintended_shop_ids=%s",
category.get("id"), category.get("id_shop_default"), extra,
)
if not DRY_RUN and confirm and len(EXPECTED_SHOP_IDS) == 1:
target_shop = next(iter(EXPECTED_SHOP_IDS))
rescope_category_to_single_shop(category, target_shop)
repaired += 1
log.info("Rescoped category id=%s to id_shop=%s.", category.get("id"), target_shop)
log.info("Done. %d categorie(s) flagged, %d repaired.", flagged, repaired)
if __name__ == "__main__":
run(confirm="--confirm" in sys.argv)
/**
* Flag PrestaShop categories written via the webservice without shop scoping.
*
* When a category is created or updated with a plain POST or PUT to /api/categories,
* PrestaShop's ObjectModel::add()/update() associates it with every shop in the current
* shop context unless the request explicitly narrows that with an id_shop query
* parameter. The categories schema exposes id_shop_default, but that only marks the
* shop used for display, it is not an association list (PrestaShop/PrestaShop issues
* #13987 and #22918).
*
* This script lists the shops in the install, pulls back categories in a given window,
* and runs a pure decision function that flags any category whose resolved shop ids go
* beyond what was expected. It reports by default. A corrective PUT that resends the
* same category body scoped to a single id_shop is only sent when DRY_RUN=false and
* --confirm is passed, one category id at a time.
*
* Guide: https://www.allanninal.dev/prestashop/webservice-category-ignores-shop-scope/
*/
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 = new Set(
(process.env.EXPECTED_SHOP_IDS || "1").split(",").map((x) => Number(x.trim())).filter((x) => !Number.isNaN(x))
);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Best-effort extraction of the shops a category is actually linked to.
* Prefers an explicit associations.shops list when the install exposes one.
* Falls back to id_shop_default, which is a display hint, not a true
* association list, but is the only signal the standard schema guarantees.
*/
export function resolvedShopIds(category) {
const associations = category.associations && category.associations.shops;
if (associations && associations.length) {
return new Set(associations.map((row) => Number(row.id)));
}
const fallback = category.id_shop_default;
return fallback != null ? new Set([Number(fallback)]) : new Set();
}
/**
* Pure decision function, no I/O.
*
* category: plain object with at least id_shop_default and, when available,
* associations.shops.
* expectedShopIds: Set of shop ids the integration intended to use.
* allShopIds: Set of every shop id in the install.
*
* Returns true when the category's resolved shop ids are a superset of the
* expected set with extras, or when it is associated with every shop while
* the expected set is narrower than that.
*/
export function isOverAssociated(category, expectedShopIds, allShopIds) {
const associated = resolvedShopIds(category);
if (associated.size === 0) return false;
const overExpected = [...associated].some((id) => !expectedShopIds.has(id));
const sameSize = associated.size === allShopIds.size;
const coversAllShops = sameSize && [...allShopIds].every((id) => associated.has(id));
const allShopsButExpectedNarrower = coversAllShops && expectedShopIds.size < allShopIds.size;
return overExpected || allShopsButExpectedNarrower;
}
/** Companion function: the diff set for reporting. Pure, no I/O. */
export function unintendedShopIds(category, expectedShopIds) {
const associated = resolvedShopIds(category);
return new Set([...associated].filter((id) => !expectedShopIds.has(id)));
}
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, params) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
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 allShopIds() {
const data = await apiGet("shops", { display: "full" });
const rows = data.shops || [];
return new Set(rows.map((row) => Number(row.id)));
}
async function recentCategories(dateFrom) {
const data = await apiGet("categories", {
display: "full",
"filter[date_add]": `[${dateFrom},today]`,
});
return data.categories || [];
}
async function rescopeCategoryToSingleShop(category, idShop) {
// Resend the identical body, only scoping the query string to id_shop and
// updating id_shop_default. This is the documented pattern; there is no
// dedicated association endpoint for categories.
const body = { ...category, id_shop_default: idShop };
return apiPut(`categories/${category.id}`, "category", body, {
output_format: "JSON",
id_shop: idShop,
});
}
export async function run(dateFrom = "2000-01-01", confirm = false) {
const shops = await allShopIds();
let flagged = 0;
let repaired = 0;
for (const category of await recentCategories(dateFrom)) {
if (!isOverAssociated(category, EXPECTED_SHOP_IDS, shops)) continue;
flagged++;
const extra = [...unintendedShopIds(category, EXPECTED_SHOP_IDS)].sort((a, b) => a - b);
console.warn(`Category id=${category.id} id_shop_default=${category.id_shop_default} unintended_shop_ids=${JSON.stringify(extra)}`);
if (!DRY_RUN && confirm && EXPECTED_SHOP_IDS.size === 1) {
const targetShop = [...EXPECTED_SHOP_IDS][0];
await rescopeCategoryToSingleShop(category, targetShop);
repaired++;
console.log(`Rescoped category id=${category.id} to id_shop=${targetShop}.`);
}
}
console.log(`Done. ${flagged} categorie(s) flagged, ${repaired} repaired.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const confirm = process.argv.includes("--confirm");
run(undefined, confirm).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which categories get reported, and it gates the only write path in the script. Because we kept is_over_associated and unintended_shop_ids pure, the tests need no network and no PrestaShop store. They just feed in plain dicts and sets and check the answer.
from flag_category_shop_scope import is_over_associated, unintended_shop_ids, resolved_shop_ids
def test_flags_when_associated_with_every_shop_but_one_expected():
category = {"id": 10, "id_shop_default": 1, "associations": {"shops": [{"id": 1}, {"id": 2}, {"id": 3}]}}
assert is_over_associated(category, {1}, {1, 2, 3}) is True
def test_no_flag_when_associated_matches_expected_exactly():
category = {"id": 11, "id_shop_default": 1, "associations": {"shops": [{"id": 1}]}}
assert is_over_associated(category, {1}, {1, 2, 3}) is False
def test_no_flag_when_expected_covers_all_shops():
category = {"id": 12, "id_shop_default": 1, "associations": {"shops": [{"id": 1}, {"id": 2}, {"id": 3}]}}
assert is_over_associated(category, {1, 2, 3}, {1, 2, 3}) is False
def test_falls_back_to_id_shop_default_when_no_associations_node():
category = {"id": 13, "id_shop_default": 2}
assert resolved_shop_ids(category) == {2}
assert is_over_associated(category, {1}, {1, 2, 3}) is True
def test_no_flag_when_no_shop_signal_at_all():
category = {"id": 14}
assert is_over_associated(category, {1}, {1, 2, 3}) is False
def test_unintended_shop_ids_reports_the_diff():
category = {"id": 15, "id_shop_default": 1, "associations": {"shops": [{"id": 1}, {"id": 2}, {"id": 3}]}}
assert unintended_shop_ids(category, {1}) == {2, 3}
def test_two_expected_shops_narrower_than_all_is_flagged():
category = {"id": 16, "id_shop_default": 1, "associations": {"shops": [{"id": 1}, {"id": 2}, {"id": 3}]}}
assert is_over_associated(category, {1, 2}, {1, 2, 3}) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { isOverAssociated, unintendedShopIds, resolvedShopIds } from "./flag-category-shop-scope.js";
test("flags when associated with every shop but one expected", () => {
const category = { id: 10, id_shop_default: 1, associations: { shops: [{ id: 1 }, { id: 2 }, { id: 3 }] } };
assert.equal(isOverAssociated(category, new Set([1]), new Set([1, 2, 3])), true);
});
test("no flag when associated matches expected exactly", () => {
const category = { id: 11, id_shop_default: 1, associations: { shops: [{ id: 1 }] } };
assert.equal(isOverAssociated(category, new Set([1]), new Set([1, 2, 3])), false);
});
test("no flag when expected covers all shops", () => {
const category = { id: 12, id_shop_default: 1, associations: { shops: [{ id: 1 }, { id: 2 }, { id: 3 }] } };
assert.equal(isOverAssociated(category, new Set([1, 2, 3]), new Set([1, 2, 3])), false);
});
test("falls back to id_shop_default when no associations node", () => {
const category = { id: 13, id_shop_default: 2 };
assert.deepEqual(resolvedShopIds(category), new Set([2]));
assert.equal(isOverAssociated(category, new Set([1]), new Set([1, 2, 3])), true);
});
test("no flag when no shop signal at all", () => {
const category = { id: 14 };
assert.equal(isOverAssociated(category, new Set([1]), new Set([1, 2, 3])), false);
});
test("unintendedShopIds reports the diff", () => {
const category = { id: 15, id_shop_default: 1, associations: { shops: [{ id: 1 }, { id: 2 }, { id: 3 }] } };
assert.deepEqual(unintendedShopIds(category, new Set([1])), new Set([2, 3]));
});
test("two expected shops narrower than all is flagged", () => {
const category = { id: 16, id_shop_default: 1, associations: { shops: [{ id: 1 }, { id: 2 }, { id: 3 }] } };
assert.equal(isOverAssociated(category, new Set([1, 2]), new Set([1, 2, 3])), true);
});
Case studies
The new region that inherited everyone else's categories
A franchise group ran one PrestaShop install with a separate shop per region. Their PIM synced new seasonal categories through the webservice, meant only for the region that was launching them. Every sync, the categories showed up in all the other regions too, confusing shoppers who saw a season that had not started where they lived.
Running the report script against a week of category writes showed the pattern immediately: every affected category had associations.shops covering all regions, not just the one that requested it. Once the sync script started sending id_shop on every write, new categories stopped bleeding across regions, and the flagged backlog was cleaned up one confirmed category at a time.
Wholesale categories leaking into the public storefront
A merchant ran two shops in one PrestaShop install, a public storefront and a wholesale portal, and used the webservice to push wholesale-only categories into the portal shop. Because the integration never passed id_shop, those categories were also silently attached to the public storefront, and wholesale pricing tiers started showing up where retail customers could see them.
The team used EXPECTED_SHOP_IDS set to the wholesale shop id only, ran the script in dry run to confirm the flagged list matched exactly the wholesale-only categories, then confirmed the rescoping PUT one category at a time to pull them out of the public storefront.
After this runs, every category your integration writes gets checked against the shop or shops you actually intended, and nothing gets silently attached to a shop no one meant to touch. The report tells you exactly which category ids and which extra shop ids are involved, so a human can confirm before anything changes, and the fix for going forward is simple: always send id_shop on category writes in a multistore install.
FAQ
Why does a category created through the PrestaShop webservice show up in every shop?
When you POST or PUT to /api/categories without an id_shop query parameter, PrestaShop's ObjectModel::add() and update() associate the category with every shop in the current shop context by default. The categories schema does not expose an associations/shops node like products do, so most integrators never realize they need to pass id_shop, and the category silently ends up linked to every shop instead of just one.
Does id_shop_default control which shops a category is associated with?
No. id_shop_default only marks which shop is used for display purposes, such as building the category's canonical link. It is not an association list and it does not restrict which ps_category_shop rows get written. A category can have id_shop_default set to one shop while still being associated with every shop in the installation.
How do I scope a category to a single shop after it was created without id_shop?
There is no dedicated association endpoint for categories. The documented pattern is to resend the same category body with a PUT to /api/categories/{id}, adding the id_shop query parameter for the single shop you want, plus id_shop_default set to that same shop id. Sending identical content while scoping the query string re-associates the category to just that shop without changing its data.
Related field notes
Citations
On the problem:
- PrestaShop GitHub: Multistore, when you create a category through webservice, it creates the category in all stores, issue #13987. github.com/PrestaShop/PrestaShop/issues/13987
- PrestaShop GitHub: Category update problem in the API, issue #22918. github.com/PrestaShop/PrestaShop/issues/22918
- PrestaShop Forums: Webservice API Multistore. prestashop.com/forums/topic/954396-webservice-api-multistore
On the solution:
- PrestaShop Developer Documentation: Manage Multishop. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/manage-multishop
- PrestaShop Developer Documentation: Categories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/categories
- PrestaShop Developer Documentation: Multi-shop context. devdocs.prestashop-project.org/9/admin-api/multi-shop
Stuck on a tricky one?
If you have a problem in PrestaShop multistore, categories, stock, orders, 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 untangle your multistore categories?
If this saved you a category that leaked into the wrong shop, 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