Skip to content

Diagnostic Catalog and Visibility

Imported products missing website assignment

A CSV import runs clean, or an integration posts a new product through the REST API, and every field looks right. Price, stock, description, all there. But the product never shows up on the storefront, not in category pages, not in search, not even by direct URL. Here is why Magento can save a fully valid product with zero rows in catalog_product_website, and a script that finds every SKU stuck in that state through the REST API.

Python and Node.js Magento REST API Diagnostic by default, repair with an explicit website id
A labeled box on shelves
Photo by Egor Litvinov on Unsplash
The short answer

Magento links a product to a storefront only through rows in catalog_product_website, and neither the CSV importer nor the REST product-create endpoint reliably writes that row by default. During CSV import, an omitted, blank, or typo'd product_websites column makes the importer silently skip the website link while the rest of the product still saves. Via REST, POST /V1/products does not accept a plain website_ids field, it needs extension_attributes.website_ids or a follow-up call to POST /V1/products/{'{'}sku{'}'}/websites. Run a small Python or Node.js script that lists recently updated products, reads extension_attributes.website_ids for each one, confirms with the dedicated /V1/products/{'{'}sku{'}'}/websites endpoint, and reports every SKU with an empty array. Full code, tests, and a safe repair path are below.

The problem in plain words

A product entity in Magento is one thing. Whether that product can be seen on a particular storefront is a separate thing entirely, decided by a row in catalog_product_website that says "this SKU belongs to this website id." Status enabled, visibility set to catalog and search, stock in place, none of that matters if this one table has no row for the product.

Both of the common ways to create products at scale can skip writing that row without telling you. The CSV importer reads the product_websites column and expects a website code that matches store_website.code exactly. Leave the column empty, misspell the code, or export from a system that never populates it, and the importer does not fail the row. It saves everything else and just does not create the website link. The REST API is worse in a different way: POST /V1/products has no plain website_ids field at all, so a "flat" integration payload that only knows about SKU, price, and attributes creates a perfectly valid product that was simply never told which website it belongs to.

CSV import or REST create product SKU Product entity saved price, stock, status all fine website code blank or field unsupported catalog_product_website no row written Storefront invisible The product exists and indexes fine. Nothing about the save fails or warns. The gap only shows up as a SKU nobody can find on the site.
The product entity is correct from the moment it saves. The gap is a missing row in catalog_product_website that neither import path is forced to write.

Why it happens

This exact gap has been reported against core Magento more than once, both as the REST create endpoint having no website field and as products silently missing a website id after being created through the API. See the citations at the end for the exact issue threads and forum reports.

The key insight

Nothing about the product record itself is broken. Status, visibility, price, and stock can all be perfectly correct and the product will still be invisible, because website assignment lives in its own join table that neither import path is forced to populate. So the fix is not to poke at the product's attributes. It is to read extension_attributes.website_ids straight from the product payload, or better, confirm it against the dedicated /V1/products/{'{'}sku{'}'}/websites endpoint, and treat an empty array as the whole story.

The fix, as a flow

The script lists recently changed products, reads back each one's website ids, and cross checks with the dedicated websites endpoint so there is no ambiguity. Anything with an empty array is reported. Only when a caller explicitly supplies a target website id, and the store has exactly one website so the correct assignment is not a guess, does the script link the missing SKUs.

GET /V1/products (updated_at) list recent candidates, paginate GET /V1/products/{'{'}sku{'}'}/websites authoritative website ids isMissingWebsiteAssignment actual ids vs expected ids Affected and override set? yes no, report only POST /V1/products/{'{'}sku{'}'}/websites productWebsiteLink, idempotent
The script only links a website when a target id is explicitly supplied and the store has a single website. Everything else is reported for a human to assign.

Build it step by step

1

Get an admin token and pick a window

Get an admin token by calling POST {'{'}MAGENTO_URL{'}'}/rest/V1/integration/admin/token with your admin username and password, or use a long lived integration token. Pick an UPDATED_SINCE timestamp that covers your last import run, so the script only checks products that could plausibly be affected.

setup (shell)
pip install requests

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export UPDATED_SINCE="2026-07-01 00:00:00"
export EXPECTED_WEBSITE_IDS="1"
export TARGET_WEBSITE_ID=""   # leave empty to only report, set to repair
export DRY_RUN="true"
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export UPDATED_SINCE="2026-07-01 00:00:00"
export EXPECTED_WEBSITE_IDS="1"
export TARGET_WEBSITE_ID=""   // leave empty to only report, set to repair
export DRY_RUN="true"
2

Talk to the Magento REST API

Every call sends the admin token as a bearer header. A small helper wraps GET and POST requests, raises on a bad status code, and returns the parsed JSON body so the rest of the script only deals with plain data.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def post(path, body):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=body,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function get(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function post(path, body) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

List candidate products and page through them

Filter /V1/products on updated_at greater than or equal to your window, and page with pageSize and currentPage so a large import batch is fully covered.

step3.py
PAGE_SIZE = 200

def recent_products(since):
    products, page = [], 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
            "searchCriteria[filterGroups][0][filters][0][value]": since,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
            "searchCriteria[pageSize]": PAGE_SIZE,
            "searchCriteria[currentPage]": page,
        }
        data = get("/products", params)
        items = data.get("items", [])
        products.extend(items)
        if len(items) < PAGE_SIZE:
            return products
        page += 1
step3.js
const PAGE_SIZE = 200;

async function recentProducts(since) {
  const products = [];
  let page = 1;
  while (true) {
    const params = {
      "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
      "searchCriteria[filterGroups][0][filters][0][value]": since,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
      "searchCriteria[pageSize]": PAGE_SIZE,
      "searchCriteria[currentPage]": page,
    };
    const data = await get("/products", params);
    const items = data.items || [];
    products.push(...items);
    if (items.length < PAGE_SIZE) return products;
    page += 1;
  }
}
4

Decide, with one pure function

Keep the decision in its own function so it is easy to read and easy to test. It takes the product payload and the website ids the SKU is expected to have, reads extension_attributes.website_ids, treats a missing key the same as an empty array, and returns the SKU, whether it is affected, and which expected ids are missing.

decide.py
def is_missing_website_assignment(product, expected_website_ids=(1,)):
    extension_attributes = product.get("extension_attributes") or {}
    actual = extension_attributes.get("website_ids") or []
    missing_website_ids = [wid for wid in expected_website_ids if wid not in actual]
    affected = len(actual) == 0 or len(missing_website_ids) > 0
    return {
        "sku": product.get("sku"),
        "affected": affected,
        "missingWebsiteIds": missing_website_ids,
    }
decide.js
export function isMissingWebsiteAssignment(product, expectedWebsiteIds = [1]) {
  const actual = product.extension_attributes?.website_ids ?? [];
  const missingWebsiteIds = expectedWebsiteIds.filter((id) => !actual.includes(id));
  const affected = actual.length === 0 || missingWebsiteIds.length > 0;
  return { sku: product.sku, affected, missingWebsiteIds };
}
5

Confirm with the dedicated websites endpoint

The product payload's extension_attributes.website_ids is usually enough, but the dedicated GET /V1/products/{'{'}sku{'}'}/websites endpoint is the authoritative source, since it reads catalog_product_website directly rather than through the product hydration path. Use it to double check any SKU the pure function flags before you act on it.

step5.py
def confirmed_website_ids(sku):
    return get(f"/products/{sku}/websites")
step5.js
async function confirmedWebsiteIds(sku) {
  return get(`/products/${sku}/websites`);
}
6

Report by default, repair only with an explicit target and a single website

The loop lists candidates, runs each through the pure function, and confirms with the websites endpoint. By default it only logs affected SKUs. Only when TARGET_WEBSITE_ID is set, DRY_RUN is false, and the store has exactly one website does it call POST /V1/products/{'{'}sku{'}'}/websites with {'{'}"productWebsiteLink":{'{'}"sku":"<sku>","website_id":<id>{'}'}{'}'} for each affected SKU. A store with more than one website is always skipped and reported, since the correct assignment cannot be inferred safely.

Run it safe

DRY_RUN defaults to true, and the script only writes when you also set an explicit TARGET_WEBSITE_ID. Never guess which website a product belongs to. If GET /V1/store/websites shows more than one website, treat every affected SKU as a report only case for a human to assign.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, lists recently updated products, confirms website ids through the dedicated endpoint, reports every affected SKU, and only links a website when a target id override is explicitly supplied for a single website store.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
find_missing_website_assignment.py
"""Find Magento products imported without a website assignment.

A product is only visible on a storefront when catalog_product_website has a row
linking its entity id to that website's id. Neither the CSV importer, when the
product_websites column is blank or has a typo'd code, nor the REST product-create
endpoint, which has no plain website_ids field, is guaranteed to write that row.
The product still saves and indexes fine, it is just invisible everywhere on the
storefront.

By default this script only reports affected SKUs. It repairs a SKU only when
TARGET_WEBSITE_ID is set, DRY_RUN is false, and the store has exactly one website,
since the correct assignment cannot be inferred safely when there is more than one.
"""
import os
import logging
import requests

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

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
UPDATED_SINCE = os.environ.get("UPDATED_SINCE", "1970-01-01 00:00:00")
EXPECTED_WEBSITE_IDS = [
    int(w) for w in os.environ.get("EXPECTED_WEBSITE_IDS", "1").split(",") if w.strip()
]
TARGET_WEBSITE_ID = os.environ.get("TARGET_WEBSITE_ID", "").strip()
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAGE_SIZE = 200


def get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def post(path, body):
    r = requests.post(
        f"{MAGENTO_URL}/rest/V1{path}",
        json=body,
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def recent_products(since):
    products, page = [], 1
    while True:
        params = {
            "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
            "searchCriteria[filterGroups][0][filters][0][value]": since,
            "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
            "searchCriteria[pageSize]": PAGE_SIZE,
            "searchCriteria[currentPage]": page,
        }
        data = get("/products", params)
        items = data.get("items", [])
        products.extend(items)
        if len(items) < PAGE_SIZE:
            return products
        page += 1


def is_missing_website_assignment(product, expected_website_ids=(1,)):
    extension_attributes = product.get("extension_attributes") or {}
    actual = extension_attributes.get("website_ids") or []
    missing_website_ids = [wid for wid in expected_website_ids if wid not in actual]
    affected = len(actual) == 0 or len(missing_website_ids) > 0
    return {
        "sku": product.get("sku"),
        "affected": affected,
        "missingWebsiteIds": missing_website_ids,
    }


def confirmed_website_ids(sku):
    return get(f"/products/{sku}/websites")


def store_website_count():
    return len(get("/store/websites"))


def link_website(sku, website_id):
    body = {"productWebsiteLink": {"sku": sku, "website_id": website_id}}
    post(f"/products/{sku}/websites", body)


def run():
    candidates = recent_products(UPDATED_SINCE)
    affected_skus = []
    for product in candidates:
        decision = is_missing_website_assignment(product, EXPECTED_WEBSITE_IDS)
        if not decision["affected"]:
            continue
        confirmed = confirmed_website_ids(decision["sku"])
        if confirmed:
            continue
        affected_skus.append(decision["sku"])
        log.warning(
            "SKU %s has no website assignment. Missing website id(s): %s",
            decision["sku"], decision["missingWebsiteIds"],
        )

    if not affected_skus:
        log.info("Done. No products missing a website assignment out of %d checked.", len(candidates))
        return

    if not TARGET_WEBSITE_ID:
        log.info(
            "Done. %d SKU(s) missing a website assignment. Set TARGET_WEBSITE_ID and DRY_RUN=false "
            "to link them, only if this store has a single website.", len(affected_skus),
        )
        return

    if store_website_count() > 1:
        log.warning(
            "Store has more than one website. Skipping repair for all %d SKU(s), "
            "the correct assignment cannot be inferred safely.", len(affected_skus),
        )
        return

    website_id = int(TARGET_WEBSITE_ID)
    for sku in affected_skus:
        action = "would link" if DRY_RUN else "linking"
        log.info("SKU %s. %s website %d", sku, action, website_id)
        if not DRY_RUN:
            link_website(sku, website_id)
    log.info("Done. %d SKU(s) %s to website %d.", len(affected_skus), "to link" if DRY_RUN else "linked", website_id)


if __name__ == "__main__":
    run()
find-missing-website-assignment.js
/**
 * Find Magento products imported without a website assignment.
 *
 * A product is only visible on a storefront when catalog_product_website has a row
 * linking its entity id to that website's id. Neither the CSV importer, when the
 * product_websites column is blank or has a typo'd code, nor the REST product-create
 * endpoint, which has no plain website_ids field, is guaranteed to write that row.
 * The product still saves and indexes fine, it is just invisible everywhere on the
 * storefront.
 *
 * By default this script only reports affected SKUs. It repairs a SKU only when
 * TARGET_WEBSITE_ID is set, DRY_RUN is false, and the store has exactly one website,
 * since the correct assignment cannot be inferred safely when there is more than one.
 *
 * Guide: https://www.allanninal.dev/magento/imported-products-missing-website-assignment/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "dummy-token";
const UPDATED_SINCE = process.env.UPDATED_SINCE || "1970-01-01 00:00:00";
const EXPECTED_WEBSITE_IDS = (process.env.EXPECTED_WEBSITE_IDS || "1")
  .split(",")
  .map((w) => w.trim())
  .filter(Boolean)
  .map(Number);
const TARGET_WEBSITE_ID = (process.env.TARGET_WEBSITE_ID || "").trim();
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAGE_SIZE = 200;

export function isMissingWebsiteAssignment(product, expectedWebsiteIds = [1]) {
  const actual = product.extension_attributes?.website_ids ?? [];
  const missingWebsiteIds = expectedWebsiteIds.filter((id) => !actual.includes(id));
  const affected = actual.length === 0 || missingWebsiteIds.length > 0;
  return { sku: product.sku, affected, missingWebsiteIds };
}

async function get(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function post(path, body) {
  const res = await fetch(`${MAGENTO_URL}/rest/V1${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

async function recentProducts(since) {
  const products = [];
  let page = 1;
  while (true) {
    const params = {
      "searchCriteria[filterGroups][0][filters][0][field]": "updated_at",
      "searchCriteria[filterGroups][0][filters][0][value]": since,
      "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
      "searchCriteria[pageSize]": PAGE_SIZE,
      "searchCriteria[currentPage]": page,
    };
    const data = await get("/products", params);
    const items = data.items || [];
    products.push(...items);
    if (items.length < PAGE_SIZE) return products;
    page += 1;
  }
}

async function confirmedWebsiteIds(sku) {
  return get(`/products/${sku}/websites`);
}

async function storeWebsiteCount() {
  const websites = await get("/store/websites");
  return websites.length;
}

async function linkWebsite(sku, websiteId) {
  const body = { productWebsiteLink: { sku, website_id: websiteId } };
  await post(`/products/${sku}/websites`, body);
}

export async function run() {
  const candidates = await recentProducts(UPDATED_SINCE);
  const affectedSkus = [];
  for (const product of candidates) {
    const decision = isMissingWebsiteAssignment(product, EXPECTED_WEBSITE_IDS);
    if (!decision.affected) continue;
    const confirmed = await confirmedWebsiteIds(decision.sku);
    if (confirmed && confirmed.length) continue;
    affectedSkus.push(decision.sku);
    console.warn(`SKU ${decision.sku} has no website assignment. Missing website id(s): ${decision.missingWebsiteIds}`);
  }

  if (!affectedSkus.length) {
    console.log(`Done. No products missing a website assignment out of ${candidates.length} checked.`);
    return;
  }

  if (!TARGET_WEBSITE_ID) {
    console.log(
      `Done. ${affectedSkus.length} SKU(s) missing a website assignment. Set TARGET_WEBSITE_ID and DRY_RUN=false ` +
      `to link them, only if this store has a single website.`
    );
    return;
  }

  if ((await storeWebsiteCount()) > 1) {
    console.warn(
      `Store has more than one website. Skipping repair for all ${affectedSkus.length} SKU(s), ` +
      `the correct assignment cannot be inferred safely.`
    );
    return;
  }

  const websiteId = Number(TARGET_WEBSITE_ID);
  for (const sku of affectedSkus) {
    console.log(`SKU ${sku}. ${DRY_RUN ? `would link website ${websiteId}` : `linking website ${websiteId}`}`);
    if (!DRY_RUN) await linkWebsite(sku, websiteId);
  }
  console.log(`Done. ${affectedSkus.length} SKU(s) ${DRY_RUN ? "to link" : "linked"} to website ${websiteId}.`);
}

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

Add a test

is_missing_website_assignment is the part worth testing, because it decides which SKUs get reported or repaired. It is a pure comparison over data already fetched, so the test needs no network and no Magento store. It just feeds in plain objects and checks the answer.

test_website_assignment.py
from find_missing_website_assignment import is_missing_website_assignment


def product(**over):
    base = {"sku": "SKU-1", "extension_attributes": {"website_ids": [1]}}
    base.update(over)
    return base


def test_not_affected_when_website_ids_present():
    result = is_missing_website_assignment(product(), [1])
    assert result == {"sku": "SKU-1", "affected": False, "missingWebsiteIds": []}


def test_affected_when_website_ids_empty():
    result = is_missing_website_assignment(product(extension_attributes={"website_ids": []}), [1])
    assert result["affected"] is True
    assert result["missingWebsiteIds"] == [1]


def test_affected_when_extension_attributes_missing():
    result = is_missing_website_assignment({"sku": "SKU-2"}, [1])
    assert result == {"sku": "SKU-2", "affected": True, "missingWebsiteIds": [1]}


def test_affected_when_expected_website_id_not_in_actual():
    result = is_missing_website_assignment(product(extension_attributes={"website_ids": [2]}), [1])
    assert result["affected"] is True
    assert result["missingWebsiteIds"] == [1]


def test_not_affected_when_actual_has_extra_websites():
    result = is_missing_website_assignment(product(extension_attributes={"website_ids": [1, 2]}), [1])
    assert result["affected"] is False
    assert result["missingWebsiteIds"] == []


def test_supports_multiple_expected_website_ids():
    result = is_missing_website_assignment(product(extension_attributes={"website_ids": [1]}), [1, 2])
    assert result["affected"] is True
    assert result["missingWebsiteIds"] == [2]
website-assignment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isMissingWebsiteAssignment } from "./find-missing-website-assignment.js";

const product = (over = {}) => ({ sku: "SKU-1", extension_attributes: { website_ids: [1] }, ...over });

test("not affected when website ids present", () => {
  const result = isMissingWebsiteAssignment(product(), [1]);
  assert.deepEqual(result, { sku: "SKU-1", affected: false, missingWebsiteIds: [] });
});

test("affected when website ids empty", () => {
  const result = isMissingWebsiteAssignment(product({ extension_attributes: { website_ids: [] } }), [1]);
  assert.equal(result.affected, true);
  assert.deepEqual(result.missingWebsiteIds, [1]);
});

test("affected when extension_attributes missing", () => {
  const result = isMissingWebsiteAssignment({ sku: "SKU-2" }, [1]);
  assert.deepEqual(result, { sku: "SKU-2", affected: true, missingWebsiteIds: [1] });
});

test("affected when expected website id not in actual", () => {
  const result = isMissingWebsiteAssignment(product({ extension_attributes: { website_ids: [2] } }), [1]);
  assert.equal(result.affected, true);
  assert.deepEqual(result.missingWebsiteIds, [1]);
});

test("not affected when actual has extra websites", () => {
  const result = isMissingWebsiteAssignment(product({ extension_attributes: { website_ids: [1, 2] } }), [1]);
  assert.equal(result.affected, false);
  assert.deepEqual(result.missingWebsiteIds, []);
});

test("supports multiple expected website ids", () => {
  const result = isMissingWebsiteAssignment(product({ extension_attributes: { website_ids: [1] } }), [1, 2]);
  assert.equal(result.affected, true);
  assert.deepEqual(result.missingWebsiteIds, [2]);
});

Case studies

CSV import

A vendor feed exported an empty product_websites column

A distributor synced a daily CSV feed of new SKUs into Magento, and the feed's export template never populated the product_websites column at all. Every import ran clean, no errors, no partial rows, and the admin grid showed the new products as enabled and correctly priced.

Weeks later, customer support started getting messages about specific SKUs from the feed being "not found" on the site. Running the diagnostic script against the last month of updated_at found a batch of dozens of SKUs with an empty website_ids array. Fixing the feed template and running the repair path with the store's single website id closed the gap in one pass.

REST integration

A PIM integration never learned about extension_attributes

An in house integration pushed new products from a product information management system straight to POST /V1/products, built from a flat mapping of SKU, name, price, and attribute set. Nobody on the integration team knew website_ids needed to live under extension_attributes, so it was never sent.

Every product the integration created saved successfully and passed every internal QA check that only looked at the product record. The diagnostic script, run nightly against recently updated products, caught every affected SKU the same day it was created, well before a customer ever noticed one was missing.

What good looks like

Run on a schedule after every import or integration push, this script turns a silent, invisible-on-the-storefront gap into a short list of SKUs with the exact missing website ids named. When the store has a single website and a target id is explicitly supplied, the same run closes the gap with the same idempotent endpoint the Magento admin uses. When there is more than one website, the report is the safe stopping point, and a human picks the right one.

FAQ

Why does an imported product not show up on the storefront even though it saved fine?

A product only renders on a storefront when a row exists in catalog_product_website linking it to that website's id. The CSV importer only writes that row when the product_websites column contains a website code that exactly matches store_website.code, and the REST product-create endpoint does not accept a plain website_ids field at all. When either path is missed, the product saves and indexes normally with zero website rows, so it exists but is invisible everywhere on the storefront.

Why does the CSV importer not fail the row when product_websites is missing or wrong?

The importer treats product_websites as optional and best effort. If the column is blank or the code does not match an existing store_website.code exactly, it silently skips only that one link and continues saving the rest of the product's attributes, prices, and stock. There is no error, warning row, or partial failure flag, so the import report looks completely successful.

How do I assign a website to a product through the REST API after the fact?

Call POST {'{'}MAGENTO_URL{'}'}/rest/V1/products/{'{'}sku{'}'}/websites with a body of {'{'}"productWebsiteLink":{'{'}"sku":"<sku>","website_id":<id>{'}'}{'}'}. This is the same dedicated endpoint you use to detect the gap with a GET request, and it is idempotent, so re-adding a link the product already has is a safe no-op.

Related field notes

Citations

On the problem:

  1. Magento 2 GitHub issue: creating a product via REST API does not assign it to the website. github.com/magento/magento2/issues/8173
  2. Magento 2 GitHub issue: website is not returned during product GET and there is no website field for product POST. github.com/magento/magento2/issues/5773
  3. Magento Community forum: website id and categories are not being stored on a product created through the API. community.magento.com WebsiteId and categories are not being stored on product

On the solution:

  1. Adobe Commerce developer docs: the products/{'{'}sku{'}'}/websites endpoint for assigning a product to a website. developer.adobe.com/commerce/webapi/rest/tutorials/orders-and-quotes-with-multi-source-inventory
  2. Magento 2 developer docs: list of service names per module for the REST API. devdocs.magento.com/guides/v2.2/rest/rest_endpoints.html
  3. Adobe Commerce and Magento Open Source user guide: import and export product attributes, including product_websites. experienceleague.adobe.com/en/docs/commerce-admin/systems/data-transfer/import/product-schema

Stuck on a tricky one?

If you have a problem in Magento indexing, cron, MSI stock, or order grid sync 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 save you a "missing SKU" mystery?

If this saved you hours of chasing a silently invisible product, 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 Magento field notes