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.
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.
Why it happens
- The CSV importer reads
product_websitesas a comma separated list of website codes and resolves each one againststore_website.code. If the column is omitted, left blank, or contains a code that does not match exactly, the importer skips only that link and still saves the rest of the row as a success. - The importer does not fail the row, log a warning row, or flag the import as partial when this happens. The import summary reports the same success count whether or not the website link was written.
POST /V1/productson the REST API has no top levelwebsite_idsfield. Assignment must be passed asextension_attributes.website_idsin the create payload, or done afterward withPOST /V1/products/{'{'}sku{'}'}/websites. A payload builder that only knows the flat product shape produces a fully valid product with zero website rows.- Because the product still passes indexing and shows correctly in the admin grid, nobody notices until a customer, or a QA pass, reports that a specific SKU simply cannot be found anywhere on the live site.
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.
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.
Build it step by step
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.
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"
// 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"
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.
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()
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();
}
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.
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
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;
}
}
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.
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,
}
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 };
}
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.
def confirmed_website_ids(sku):
return get(f"/products/{sku}/websites")
async function confirmedWebsiteIds(sku) {
return get(`/products/${sku}/websites`);
}
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.
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.
"""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 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.
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]
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
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.
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.
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:
- Magento 2 GitHub issue: creating a product via REST API does not assign it to the website. github.com/magento/magento2/issues/8173
- 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
- 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:
- 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
- Magento 2 developer docs: list of service names per module for the REST API. devdocs.magento.com/guides/v2.2/rest/rest_endpoints.html
- 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.
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