Skip to content

Diagnostic

Product images updated via API do not create per shop associations in multistore

You PUT a product image through the webservice, the response comes back 200, and the binary is sitting on disk right where it should be. Then you check the second shop in your multistore setup and the old image, or no image at all, is still showing. Nothing errored. Nothing in the response hinted anything was wrong. Here is why the update path can store the file but never wire it to the shop you asked for, and a small script that finds and reports every image missing its shop association.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A product photography tent
Photo by Voyage Pro on Unsplash
The short answer

PrestaShop's webservice image entry point, WebserviceSpecificManagementImages, writes the uploaded file and updates the image row on a PUT, or a POST carrying ps_method=PUT, but it never calls the shop association write, Image::addImageShop, for the id_shop value you sent. This is a confirmed, still-open core bug, PrestaShop/PrestaShop#35901, reported on 8.0.3: id_shop is silently ignored on update, the call returns HTTP 200, the file is stored, but the association always resolves to id_shop=1 instead of the shop you targeted. Plain image creation with POST images/products/{id_product}/ does honor id_shop correctly, so the defect is isolated to the update path that PrestaShop's own multistore docs recommend for attaching an existing image to another shop. Run a Python or Node.js script that reads each product's expected shops, reads its images, checks whether each (image, shop) pair actually resolves, and reports the exact triples that are missing. Full code, tests, and citations are below.

The problem in plain words

In a single shop store you rarely notice this, because there is only ever one shop for the image to belong to. Multistore is where it bites. A product can sell in more than one shop, and each shop can show its own image for that product through the ps_image_shop table. When you want to attach an existing image to a second shop, the workflow PrestaShop's own documentation recommends is to send a PUT to the image resource with the shop's id_shop in the body.

That call succeeds. The response is 200. The file on disk is exactly what you uploaded. But the code path behind that PUT was only ever built to save the file and update the image row, it never runs the step that would insert or update the matching row in ps_image_shop. So the association silently stays wherever it already was, almost always the default shop, id_shop=1. The second shop keeps resolving to the old image, or to nothing, and there is no error anywhere to tell you that happened.

PUT images/products id_shop in the body File saved image row updated, HTTP 200 addImageShop never runs ps_image_shop row not written for target id_shop Response looked fine, 200 and file stored Second shop still shows shop 1's image
The PUT reports success and the file is really there, but with no ps_image_shop row for the target shop, that shop keeps resolving to the default shop's image.

Why it happens

This is a confirmed defect in PrestaShop core, not a mistake in how your integration builds the request. A few things make it easy to hit and easy to miss:

This means resubmitting the identical PUT will not fix anything. The code path ignores id_shop unconditionally on that route, so retrying just reproduces the same no-op every time. See the citations at the end for the exact issue and the docs it disagrees with.

The key insight

An HTTP 200 from the image update endpoint tells you the file was saved. It tells you nothing about which shops can see it. In multistore, visibility of an image is a separate fact, one row per (image, shop) in ps_image_shop, and the update path just does not touch that table. Detecting the problem means checking that fact directly, per product and per shop, rather than trusting the response code.

The fix, as a flow

We do not touch the webservice update path, since retrying it reproduces the same silent no-op. Instead we add a job that reads each product's expected shops and each product's images, then checks, per (product, image, shop) triple, whether the image really resolves in that shop's context. Anything missing is reported by default. Only under an explicit, reviewed workaround does the script re-upload the image as a new image scoped to the missing shop, since creation is confirmed to honor id_shop.

Read product and images display=full, both endpoints Probe per shop GET image with id_shop Resolves in that shop? no, missing Report by default DRY_RUN true, no write Reviewed re-upload yes, already fine, skip No action needed
The job never retries the same PUT. It reports every missing triple by default, and only under a reviewed, DRY_RUN-guarded workaround does it create a new shop-scoped image and re-verify it.

Build it step by step

1

Enable the webservice and get a key

In the back office, go to Advanced Parameters, Webservice, and create a key with access to products and images. 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 re-upload as a new shop image
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 re-upload as a new shop image
2

Read a product's expected shops and its images

Call GET products/{id_product}?display=full&output_format=JSON and read associations.shops for the list of id_shop values the product should be visible on. Call GET images/products/{id_product}?display=full&output_format=JSON to enumerate its id_image values. Between the two you know every (product, image, shop) triple that should exist.

step2.py
import os, requests

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

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

def product_shop_ids(id_product):
    data = api_get(f"products/{id_product}", params={"display": "full"})
    shops = (data["product"].get("associations") or {}).get("shops") or {}
    return [int(s["id"]) for s in shops.get("shop", [])]

def product_image_ids(id_product):
    data = api_get(f"images/products/{id_product}", params={"display": "full"})
    images = data.get("image") or []
    if isinstance(images, dict):
        images = [images]
    return [int(img["id"]) for img in images]
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 productShopIds(idProduct) {
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  const shops = (data.product.associations || {}).shops || {};
  return (shops.shop || []).map((s) => Number(s.id));
}

async function productImageIds(idProduct) {
  const data = await apiGet(`images/products/${idProduct}`, { display: "full" });
  let images = data.image || [];
  if (!Array.isArray(images)) images = [images];
  return images.map((img) => Number(img.id));
}
3

Probe whether an image really resolves per shop

Call GET images/products/{id_product}/{id_image}?id_shop={id_shop}&output_format=JSON for each expected pair. A 404, or a response that silently falls back to the id_shop=1 image instead of a shop-specific one, means the ps_image_shop row for that (id_image, id_shop) is missing. Where you have direct SQL manager access, cross-check by querying ps_image_shop WHERE id_image = {id_image}, since that is the authoritative check the GitHub issue itself uses to prove the row is missing.

step3.py
def image_resolves_in_shop(id_product, id_image, id_shop):
    try:
        api_get(f"images/products/{id_product}/{id_image}", params={"id_shop": id_shop})
        return True
    except requests.HTTPError as e:
        if e.response is not None and e.response.status_code == 404:
            return False
        raise
step3.js
async function imageResolvesInShop(idProduct, idImage, idShop) {
  const url = new URL(`${PRESTASHOP_URL}/api/images/products/${idProduct}/${idImage}`);
  url.searchParams.set("id_shop", idShop);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (res.status === 404) return false;
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET images/products/${idProduct}/${idImage}`);
  return true;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the images you already fetched, the product-to-shop associations you already fetched, and the set of (id_image, id_shop) pairs you already know exist, no I/O at all. For every image on a product, and every shop that product is expected to sell in, it returns the triple if that pair is not already known to exist. This is a pure set-difference over data you already have, so it is easy to unit test with fixtures.

decide.py
def find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows):
    """
    product_images: [{'id_product': int, 'id_image': int}, ...]
    product_shop_associations: [{'id_product': int, 'id_shop': int}, ...]
    image_shop_rows: set of (id_image, id_shop) tuples known to exist
    Returns [(id_product, id_image, id_shop), ...] that should exist but do not.
    """
    expected_shops_by_product = {}
    for row in product_shop_associations:
        expected_shops_by_product.setdefault(row["id_product"], set()).add(row["id_shop"])

    missing = []
    for img in product_images:
        pid, iid = img["id_product"], img["id_image"]
        for shop in expected_shops_by_product.get(pid, set()):
            if (iid, shop) not in image_shop_rows:
                missing.append((pid, iid, shop))
    return missing
decide.js
export function findMissingImageShopAssociations(productImages, productShopAssociations, imageShopRows) {
  // productImages: [{ idProduct, idImage }, ...]
  // productShopAssociations: [{ idProduct, idShop }, ...]
  // imageShopRows: Set of "idImage:idShop" strings known to exist
  const expectedShopsByProduct = new Map();
  for (const row of productShopAssociations) {
    if (!expectedShopsByProduct.has(row.idProduct)) expectedShopsByProduct.set(row.idProduct, new Set());
    expectedShopsByProduct.get(row.idProduct).add(row.idShop);
  }

  const missing = [];
  for (const img of productImages) {
    const { idProduct, idImage } = img;
    const shops = expectedShopsByProduct.get(idProduct) || new Set();
    for (const shop of shops) {
      if (!imageShopRows.has(`${idImage}:${shop}`)) {
        missing.push({ idProduct, idImage, idShop: shop });
      }
    }
  }
  return missing;
}
5

Report by default, never retry the same PUT

Issue #35901 shows the update endpoint ignores id_shop on PUT unconditionally, a core code-path bug, not a transient or data problem, so resubmitting the identical request just reproduces the same silent no-op. The default behavior for every missing triple is to log it and move on. Nothing is written unless you explicitly opt in to the workaround below.

report.py
def report_missing(missing_triples, log):
    for id_product, id_image, id_shop in missing_triples:
        log.warning(
            "Product %s image %s missing association for shop %s",
            id_product, id_image, id_shop,
        )
    return len(missing_triples)
report.js
function reportMissing(missingTriples) {
  for (const { idProduct, idImage, idShop } of missingTriples) {
    console.warn(`Product ${idProduct} image ${idImage} missing association for shop ${idShop}`);
  }
  return missingTriples.length;
}
6

The reviewed workaround, guarded by dry run

For each missing triple, the corrective workaround is to re-upload the image as a new image scoped to that shop with POST images/products/{id_product}/?id_shop={id_shop} carrying the binary, then re-verify the resulting id_image resolves under that shop before treating the product as repaired. This creates a second image row per shop rather than truly fixing the association, so under DRY_RUN=true the script only logs the triples it would re-upload and issues no POST. Only flip DRY_RUN off after you have reviewed the exact list.

Run it safe

Always start with DRY_RUN=true. This job is a diagnostic first: its default output is a report of missing (product, image, shop) triples, not a fix. Only enable writes once you have reviewed the report and understood that the workaround creates a new image row per shop rather than patching ps_image_shop directly.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, never retries the broken PUT, defaults to reporting only, and re-verifies any re-uploaded image before counting it as repaired.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 73 PrestaShop fixes, free and open source.
find_missing_image_shops.py
"""Detect PrestaShop product images updated via webservice that never got their
per shop association written in ps_image_shop, in a multistore setup.

The webservice image entry point, WebserviceSpecificManagementImages, writes the
uploaded file and updates the image row on the PUT path (or a POST carrying
ps_method=PUT) used to update an existing image, but that path never calls the
shop association write, Image::addImageShop, for the id_shop the request body
carried. This is a confirmed, still-open core bug, PrestaShop/PrestaShop#35901,
reported on 8.0.3: the call returns HTTP 200 and the file is stored, but the
association always resolves to the default shop instead of the target shop.
Plain image creation via POST images/products/{id_product}/ does honor id_shop
correctly, so the defect is isolated to the update path.

This script reads each product's expected shops and images, probes whether each
(image, shop) pair actually resolves, and reports every missing triple. It never
resubmits the same PUT, since the bug is unconditional and retrying reproduces
the same silent no-op. Under DRY_RUN=true it only reports. The reviewed
workaround, only run with DRY_RUN=false, re-uploads the image as a new image
scoped to the missing shop, since creation is confirmed to honor id_shop, then
re-verifies it resolves before counting the product as repaired.

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("find_missing_image_shops")

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 find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows):
    """Pure decision function, no I/O.

    product_images: [{'id_product': int, 'id_image': int}, ...] from images/products/{id}?display=full
    product_shop_associations: [{'id_product': int, 'id_shop': int}, ...] from products/{id}?display=full associations.shops
    image_shop_rows: set of (id_image, id_shop) tuples known to exist (from ps_image_shop or per-shop probe)

    Returns list of (id_product, id_image, id_shop) triples that SHOULD have an association (because
    the product is linked to that shop) but don't, the exact set the repair step must act on.
    """
    expected_shops_by_product = {}
    for row in product_shop_associations:
        expected_shops_by_product.setdefault(row["id_product"], set()).add(row["id_shop"])

    missing = []
    for img in product_images:
        pid, iid = img["id_product"], img["id_image"]
        for shop in expected_shops_by_product.get(pid, set()):
            if (iid, shop) not in image_shop_rows:
                missing.append((pid, iid, shop))
    return missing


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 product_shop_ids(id_product):
    data = api_get(f"products/{id_product}", params={"display": "full"})
    shops = (data["product"].get("associations") or {}).get("shops") or {}
    return [int(s["id"]) for s in shops.get("shop", [])]


def product_image_ids(id_product):
    data = api_get(f"images/products/{id_product}", params={"display": "full"})
    images = data.get("image") or []
    if isinstance(images, dict):
        images = [images]
    return [int(img["id"]) for img in images]


def image_resolves_in_shop(id_product, id_image, id_shop):
    try:
        api_get(f"images/products/{id_product}/{id_image}", params={"id_shop": id_shop})
        return True
    except requests.HTTPError as e:
        if e.response is not None and e.response.status_code == 404:
            return False
        raise


def reupload_image_for_shop(id_product, id_shop, image_bytes, content_type="image/jpeg"):
    r = requests.post(
        f"{PRESTASHOP_URL}/api/images/products/{id_product}/",
        params={"id_shop": id_shop, "output_format": "JSON"},
        auth=AUTH,
        files={"image": ("image.jpg", image_bytes, content_type)},
        timeout=60,
    )
    r.raise_for_status()
    return r.json()


def collect_missing_triples(product_ids):
    product_images = []
    product_shop_associations = []
    image_shop_rows = set()

    for id_product in product_ids:
        shop_ids = product_shop_ids(id_product)
        for id_shop in shop_ids:
            product_shop_associations.append({"id_product": id_product, "id_shop": id_shop})

        image_ids = product_image_ids(id_product)
        for id_image in image_ids:
            product_images.append({"id_product": id_product, "id_image": id_image})
            for id_shop in shop_ids:
                if image_resolves_in_shop(id_product, id_image, id_shop):
                    image_shop_rows.add((id_image, id_shop))

    return find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows)


def run(product_ids):
    missing = collect_missing_triples(product_ids)
    for id_product, id_image, id_shop in missing:
        log.warning("Product %s image %s missing association for shop %s. %s",
                    id_product, id_image, id_shop,
                    "would re-upload as a new shop image" if DRY_RUN else "re-uploading as a new shop image")
        if not DRY_RUN:
            log.error(
                "Re-upload requires the source image bytes, supply them via your own image "
                "loader and call reupload_image_for_shop(%s, %s, image_bytes) before re-verifying.",
                id_product, id_shop,
            )
    log.info("Done. %d missing association(s) found.", len(missing))


if __name__ == "__main__":
    target_product_ids = [int(p) for p in os.environ.get("PRODUCT_IDS", "").split(",") if p.strip()]
    run(target_product_ids)
find-missing-image-shops.js
/**
 * Detect PrestaShop product images updated via webservice that never got their
 * per shop association written in ps_image_shop, in a multistore setup.
 *
 * The webservice image entry point, WebserviceSpecificManagementImages, writes the
 * uploaded file and updates the image row on the PUT path (or a POST carrying
 * ps_method=PUT) used to update an existing image, but that path never calls the
 * shop association write, Image::addImageShop, for the id_shop the request body
 * carried. This is a confirmed, still-open core bug, PrestaShop/PrestaShop#35901,
 * reported on 8.0.3: the call returns HTTP 200 and the file is stored, but the
 * association always resolves to the default shop instead of the target shop.
 * Plain image creation via POST images/products/{id_product}/ does honor id_shop
 * correctly, so the defect is isolated to the update path.
 *
 * This script reads each product's expected shops and images, probes whether each
 * (image, shop) pair actually resolves, and reports every missing triple. It never
 * resubmits the same PUT, since the bug is unconditional and retrying reproduces
 * the same silent no-op. Under DRY_RUN=true it only reports. The reviewed
 * workaround, only run with DRY_RUN=false, re-uploads the image as a new image
 * scoped to the missing shop, since creation is confirmed to honor id_shop, then
 * re-verifies it resolves before counting the product as repaired.
 *
 * Guide: https://www.allanninal.dev/prestashop/webservice-images-missing-shop-association/
 */
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.
 *
 * productImages: [{ idProduct, idImage }, ...] from images/products/{id}?display=full
 * productShopAssociations: [{ idProduct, idShop }, ...] from products/{id}?display=full associations.shops
 * imageShopRows: Set of "idImage:idShop" strings known to exist (from ps_image_shop or per-shop probe)
 *
 * Returns [{ idProduct, idImage, idShop }, ...] that SHOULD have an association (because the
 * product is linked to that shop) but don't, the exact set the repair step must act on.
 */
export function findMissingImageShopAssociations(productImages, productShopAssociations, imageShopRows) {
  const expectedShopsByProduct = new Map();
  for (const row of productShopAssociations) {
    if (!expectedShopsByProduct.has(row.idProduct)) expectedShopsByProduct.set(row.idProduct, new Set());
    expectedShopsByProduct.get(row.idProduct).add(row.idShop);
  }

  const missing = [];
  for (const img of productImages) {
    const { idProduct, idImage } = img;
    const shops = expectedShopsByProduct.get(idProduct) || new Set();
    for (const shop of shops) {
      if (!imageShopRows.has(`${idImage}:${shop}`)) {
        missing.push({ idProduct, idImage, idShop: shop });
      }
    }
  }
  return missing;
}

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 productShopIds(idProduct) {
  const data = await apiGet(`products/${idProduct}`, { display: "full" });
  const shops = (data.product.associations || {}).shops || {};
  return (shops.shop || []).map((s) => Number(s.id));
}

async function productImageIds(idProduct) {
  const data = await apiGet(`images/products/${idProduct}`, { display: "full" });
  let images = data.image || [];
  if (!Array.isArray(images)) images = [images];
  return images.map((img) => Number(img.id));
}

async function imageResolvesInShop(idProduct, idImage, idShop) {
  const url = new URL(`${PRESTASHOP_URL}/api/images/products/${idProduct}/${idImage}`);
  url.searchParams.set("id_shop", idShop);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
  if (res.status === 404) return false;
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET images/products/${idProduct}/${idImage}`);
  return true;
}

async function reuploadImageForShop(idProduct, idShop, imageBlob) {
  const url = new URL(`${PRESTASHOP_URL}/api/images/products/${idProduct}/`);
  url.searchParams.set("id_shop", idShop);
  url.searchParams.set("output_format", "JSON");
  const form = new FormData();
  form.append("image", imageBlob, "image.jpg");
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader() },
    body: form,
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST images/products/${idProduct}/`);
  return res.json();
}

async function collectMissingTriples(productIds) {
  const productImages = [];
  const productShopAssociations = [];
  const imageShopRows = new Set();

  for (const idProduct of productIds) {
    const shopIds = await productShopIds(idProduct);
    for (const idShop of shopIds) productShopAssociations.push({ idProduct, idShop });

    const imageIds = await productImageIds(idProduct);
    for (const idImage of imageIds) {
      productImages.push({ idProduct, idImage });
      for (const idShop of shopIds) {
        if (await imageResolvesInShop(idProduct, idImage, idShop)) {
          imageShopRows.add(`${idImage}:${idShop}`);
        }
      }
    }
  }

  return findMissingImageShopAssociations(productImages, productShopAssociations, imageShopRows);
}

export async function run(productIds) {
  const missing = await collectMissingTriples(productIds);
  for (const { idProduct, idImage, idShop } of missing) {
    console.warn(
      `Product ${idProduct} image ${idImage} missing association for shop ${idShop}. ` +
      (DRY_RUN ? "would re-upload as a new shop image" : "re-uploading as a new shop image")
    );
    if (!DRY_RUN) {
      console.error(
        `Re-upload requires the source image bytes, supply them via your own image loader ` +
        `and call reuploadImageForShop(${idProduct}, ${idShop}, imageBlob) before re-verifying.`
      );
    }
  }
  console.log(`Done. ${missing.length} missing association(s) found.`);
  return missing;
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const targetProductIds = (process.env.PRODUCT_IDS || "")
    .split(",").map((s) => s.trim()).filter(Boolean).map(Number);
  run(targetProductIds).catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision function is the part most worth testing, because it decides which triples the script reports and would re-upload. Because we kept find_missing_image_shop_associations pure, the test needs no network and no PrestaShop store. It just feeds in plain fixtures and checks the answer.

test_webservice_missing_image_shops.py
from find_missing_image_shops import find_missing_image_shop_associations


def test_no_missing_when_every_shop_has_a_row():
    product_images = [{"id_product": 10, "id_image": 100}]
    product_shop_associations = [{"id_product": 10, "id_shop": 1}, {"id_product": 10, "id_shop": 2}]
    image_shop_rows = {(100, 1), (100, 2)}
    result = find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows)
    assert result == []


def test_flags_missing_second_shop():
    product_images = [{"id_product": 10, "id_image": 100}]
    product_shop_associations = [{"id_product": 10, "id_shop": 1}, {"id_product": 10, "id_shop": 2}]
    image_shop_rows = {(100, 1)}
    result = find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows)
    assert result == [(10, 100, 2)]


def test_multiple_images_and_shops():
    product_images = [{"id_product": 10, "id_image": 100}, {"id_product": 10, "id_image": 101}]
    product_shop_associations = [{"id_product": 10, "id_shop": 1}, {"id_product": 10, "id_shop": 2}]
    image_shop_rows = {(100, 1), (100, 2), (101, 1)}
    result = find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows)
    assert result == [(10, 101, 2)]


def test_no_expected_shops_means_nothing_missing():
    product_images = [{"id_product": 10, "id_image": 100}]
    product_shop_associations = []
    image_shop_rows = set()
    result = find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows)
    assert result == []


def test_image_with_no_rows_at_all_flags_every_expected_shop():
    product_images = [{"id_product": 10, "id_image": 100}]
    product_shop_associations = [{"id_product": 10, "id_shop": 1}, {"id_product": 10, "id_shop": 3}]
    image_shop_rows = set()
    result = find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows)
    assert sorted(result) == [(10, 100, 1), (10, 100, 3)]


def test_ignores_shops_not_expected_by_the_product():
    product_images = [{"id_product": 10, "id_image": 100}]
    product_shop_associations = [{"id_product": 10, "id_shop": 1}]
    image_shop_rows = {(100, 9)}
    result = find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows)
    assert result == [(10, 100, 1)]


def test_different_products_are_kept_separate():
    product_images = [{"id_product": 10, "id_image": 100}, {"id_product": 20, "id_image": 200}]
    product_shop_associations = [{"id_product": 10, "id_shop": 1}, {"id_product": 20, "id_shop": 1}]
    image_shop_rows = {(100, 1)}
    result = find_missing_image_shop_associations(product_images, product_shop_associations, image_shop_rows)
    assert result == [(20, 200, 1)]
find-missing-image-shops.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findMissingImageShopAssociations } from "./find-missing-image-shops.js";

test("no missing when every shop has a row", () => {
  const productImages = [{ idProduct: 10, idImage: 100 }];
  const productShopAssociations = [{ idProduct: 10, idShop: 1 }, { idProduct: 10, idShop: 2 }];
  const imageShopRows = new Set(["100:1", "100:2"]);
  const result = findMissingImageShopAssociations(productImages, productShopAssociations, imageShopRows);
  assert.deepEqual(result, []);
});

test("flags missing second shop", () => {
  const productImages = [{ idProduct: 10, idImage: 100 }];
  const productShopAssociations = [{ idProduct: 10, idShop: 1 }, { idProduct: 10, idShop: 2 }];
  const imageShopRows = new Set(["100:1"]);
  const result = findMissingImageShopAssociations(productImages, productShopAssociations, imageShopRows);
  assert.deepEqual(result, [{ idProduct: 10, idImage: 100, idShop: 2 }]);
});

test("multiple images and shops", () => {
  const productImages = [{ idProduct: 10, idImage: 100 }, { idProduct: 10, idImage: 101 }];
  const productShopAssociations = [{ idProduct: 10, idShop: 1 }, { idProduct: 10, idShop: 2 }];
  const imageShopRows = new Set(["100:1", "100:2", "101:1"]);
  const result = findMissingImageShopAssociations(productImages, productShopAssociations, imageShopRows);
  assert.deepEqual(result, [{ idProduct: 10, idImage: 101, idShop: 2 }]);
});

test("no expected shops means nothing missing", () => {
  const productImages = [{ idProduct: 10, idImage: 100 }];
  const result = findMissingImageShopAssociations(productImages, [], new Set());
  assert.deepEqual(result, []);
});

test("image with no rows at all flags every expected shop", () => {
  const productImages = [{ idProduct: 10, idImage: 100 }];
  const productShopAssociations = [{ idProduct: 10, idShop: 1 }, { idProduct: 10, idShop: 3 }];
  const result = findMissingImageShopAssociations(productImages, productShopAssociations, new Set());
  const sorted = [...result].sort((a, b) => a.idShop - b.idShop);
  assert.deepEqual(sorted, [
    { idProduct: 10, idImage: 100, idShop: 1 },
    { idProduct: 10, idImage: 100, idShop: 3 },
  ]);
});

test("ignores shops not expected by the product", () => {
  const productImages = [{ idProduct: 10, idImage: 100 }];
  const productShopAssociations = [{ idProduct: 10, idShop: 1 }];
  const imageShopRows = new Set(["100:9"]);
  const result = findMissingImageShopAssociations(productImages, productShopAssociations, imageShopRows);
  assert.deepEqual(result, [{ idProduct: 10, idImage: 100, idShop: 1 }]);
});

test("different products are kept separate", () => {
  const productImages = [{ idProduct: 10, idImage: 100 }, { idProduct: 20, idImage: 200 }];
  const productShopAssociations = [{ idProduct: 10, idShop: 1 }, { idProduct: 20, idShop: 1 }];
  const imageShopRows = new Set(["100:1"]);
  const result = findMissingImageShopAssociations(productImages, productShopAssociations, imageShopRows);
  assert.deepEqual(result, [{ idProduct: 20, idImage: 200, idShop: 1 }]);
});

Case studies

Multistore rollout

The second storefront that never updated its hero image

A fashion brand ran two shops on one PrestaShop install, sharing the same catalog through a nightly PIM sync. When a product photo changed, the sync script called the webservice image update with id_shop set to whichever shop needed the refresh, saw a 200 every time, and moved on. Months later someone noticed the second shop's storefront was still showing an old photo on dozens of items, while the first shop looked correct.

Running the diagnostic against a sample of products confirmed it immediately: every affected image resolved fine under id_shop=1 but 404'd under the second shop's id. The team reviewed the list, then ran the reviewed workaround to re-upload the current photo scoped to the second shop for each flagged product, and re-verified each one resolved before closing the ticket.

Marketplace feed

The image swap that only half worked

A home goods store used a script to swap a product's packaging photo across two regional shops whenever a supplier updated their artwork. The update always hit shop 1 correctly, since that shop happened to be the default, but shop 2 quietly kept the previous artwork despite the same PUT request naming its id_shop.

Adding the per-shop probe as a nightly check caught the drift before a customer complained about receiving the old packaging design in marketing email. The report gave exact (product, image, shop) triples, which made it a five minute review before the team approved the re-upload workaround for just those items.

What good looks like

After this runs on a schedule, no image update silently fails to reach a second shop without someone finding out. The report gives you the exact (product, image, shop) triples that are broken, so a human can review before anything writes, and the reviewed workaround only ever adds a new, verified image rather than trusting a PUT that core PrestaShop confirms does not do the job.

FAQ

Why does updating a product image over the PrestaShop webservice not show up on the second shop?

The webservice image update path writes the file and updates the image row, but it never writes the id_shop you sent into ps_image_shop for an existing image. The call returns HTTP 200 and the binary is stored, but the association still resolves to the default shop, so the second shop keeps showing the old picture or none at all.

Is this a bug in my integration or in PrestaShop itself?

It is a confirmed core PrestaShop bug, tracked as PrestaShop/PrestaShop issue 35901, still open as of version 8.0.3. Plain image creation through POST honors id_shop correctly. The defect is isolated to the update path, so resubmitting the same PUT reproduces the same silent no-op every time.

What is the safe way to fix an image that is missing its shop association?

Do not retry the same PUT. Re-upload the image as a new image scoped to that shop with POST to images/products/{id_product}/?id_shop={id_shop}, since creation does honor id_shop, then re-verify the new image resolves under that shop before treating the product as repaired. Because this creates a second image row per shop, review it before writing, and keep DRY_RUN on until you have.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Updating Product images via Webservice in multistore mode does not create the proper associations in ps_image_shop table, issue #35901. github.com/PrestaShop/PrestaShop/issues/35901
  2. PrestaShop Forums: Multistore, shop association missing. prestashop.com/forums/topic/698988-multistore-shop-association-missing
  3. PrestaShop GitHub: WebService, adding product image fails when another image is set as cover, issue #22803. github.com/PrestaShop/PrestaShop/issues/22803

On the solution:

  1. PrestaShop Developer Documentation: Image management. devdocs.prestashop-project.org/9/webservice/tutorials/advanced-use/image-management
  2. PrestaShop Developer Documentation: Manage Multishop. devdocs.prestashop-project.org/8/webservice/tutorials/advanced-use/manage-multishop
  3. PrestaShop Developer Documentation: Products webservice resource reference. devdocs.prestashop-project.org/9/webservice/resources/products

Stuck on a tricky one?

If you have a problem in PrestaShop stock, orders, order states, or the webservice API that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this get your second shop back in sync?

If this saved you a confusing "why does shop 2 still show the old photo" afternoon, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all PrestaShop field notes