Repair
Product has no default combination, causing price to display as zero
A product with variants looks fine in the list of products, but the price column reads 0.00, and the storefront shows the same. Nothing is wrong with the combinations themselves, they have real prices and real stock. The product row just lost track of which one is the default. Here is why that pointer goes missing and a small script that finds the products stuck like this and repairs the pointer safely.
Every product with combinations keeps an id_default_combination pointer on its own product row, and the storefront and back office read the price, the image, and the base attributes from whatever combination that pointer names. When the pointer is 0, blank, or points at a combination that was deleted, deactivated, or belongs to a different product, the price lookup has nothing valid to resolve and the product displays a price of zero even though every combination underneath it has a real price. Run a Python or Node.js script that lists products with combinations, checks whether id_default_combination resolves to an active combination that still belongs to that product, and repairs it by pointing at the cheapest active combination when one exists. Full code, tests, and citations are below.
The problem in plain words
A product that has variants, like a shirt in three sizes, does not carry one single price on its own product row in a meaningful way. Each combination carries its own price impact, its own stock, and sometimes its own image. To show one number in a product listing, PrestaShop needs to pick one combination to represent the product, and that choice is stored directly on the product as id_default_combination.
That pointer is only a number. Nothing in the database schema forces it to always point at something real, so anything that removes or invalidates the combination it names leaves the pointer dangling. The product still exists, its combinations still exist, but the one combination the product was leaning on for its headline price is gone, and the price falls back to zero because there is nothing left to read it from.
Why it happens
The safest way to spot this is to compare what the product row claims against what its combinations actually are, rather than trusting the stored price at face value. A few common ways a store ends up with a dangling id_default_combination:
- A combination is deleted from the back office or through the webservice, but the product row that was pointing at it as its default is never updated to point somewhere else.
- A combination is deactivated rather than deleted, and the code paths that pick a default silently keep pointing at an inactive row instead of choosing an active one.
- A product import or a bulk combination rebuild recreates combinations with new ids, and the product row is left referencing the old, now nonexistent id.
- A duplicated product carries over the source product's
id_default_combinationvalue, which points at a combination belonging to the original product, not the copy.
This shows up as a support ticket that says a product "used to have a price" or "shows 0 for no reason," and it is easy to misdiagnose as a pricing rule or a currency problem because the combinations themselves look completely normal when you open them directly. See the citations at the end for the exact threads and docs.
The fix is not "pick any combination and set it as default." It is "pick an active combination that still actually belongs to this product." A default combination that resolves to the wrong product's row, or to a deactivated variant, replaces one wrong number with another wrong number, just less obviously wrong.
The fix, as a flow
We do not touch combinations during checkout or storefront browsing. We add a job that lists products that have combinations, reads the product's own id_default_combination, checks whether that id resolves to a combination that is both active and actually attached to the product, and only when it does not, repairs the pointer to the cheapest eligible combination it can find. Anything with no eligible combination at all is flagged for a human.
Build it step by step
Enable the webservice and get a key
In the back office, go to Advanced Parameters, Webservice, and create a key with access to products and combinations. The key is sent as the HTTP Basic username with a blank password. Keep the shop URL and the key in environment variables, never in the file.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" // start safe, change to false to write
List products and read their default combination pointer
Call GET /api/products?output_format=JSON&display=full&limit=100 to page through the catalog. Read id, id_default_combination, and price off each product. A product with id_default_combination equal to 0 is the simplest sign something is wrong, but a nonzero id can still be stale, so we check it against the live combinations next.
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 list_products_with_combinations(limit=100):
data = api_get("products", params={"display": "full", "limit": limit})
products = data.get("products") or []
return [p for p in products if str(p.get("id_default_combination", "0")) != ""]
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 listProductsWithCombinations(limit = 100) {
const data = await apiGet("products", { display: "full", limit });
const products = data.products || [];
return products.filter((p) => String(p.id_default_combination ?? "0") !== "");
}
Fetch the product's live combinations
Call GET /api/combinations?filter[id_product]=<id>&display=full to get every combination that actually belongs to the product right now, with its own id, active flag, and price impact. This is the list we check the stored default id against, and the list we pick a replacement from if the default is stale.
def list_combinations(id_product):
data = api_get("combinations", params={
"filter[id_product]": id_product,
"display": "full",
})
return data.get("combinations") or []
async function listCombinations(idProduct) {
const data = await apiGet("combinations", {
"filter[id_product]": idProduct,
display: "full",
});
return data.combinations || [];
}
Decide, with one pure function
Keep the decision in its own function that takes only plain data, no I/O at all: a small function fed a product id, the stored default combination id, and the list of that product's live combinations, that returns whether the default is valid and, if not, which combination id should replace it. It only ever picks from combinations that are active and whose id_product matches the product being checked, and it prefers the cheapest eligible one so the storefront shows a real, comparable price.
def decide_default_combination(id_product, current_default_id, combinations):
def eligible(c):
return (
str(c.get("id_product")) == str(id_product)
and str(c.get("active", "0")) == "1"
)
live_ids = {str(c["id"]) for c in combinations if eligible(c)}
is_valid = str(current_default_id) not in ("", "0", "None") and str(current_default_id) in live_ids
if is_valid:
return {"action": "none", "reason": "default combination is active and belongs to the product",
"target_id": None}
eligible_combos = [c for c in combinations if eligible(c)]
if not eligible_combos:
return {"action": "flag", "reason": "no active combination belongs to this product",
"target_id": None}
cheapest = min(eligible_combos, key=lambda c: float(c.get("price", 0) or 0))
return {"action": "repair", "reason": "default combination missing or invalid, replacing with cheapest active one",
"target_id": cheapest["id"]}
export function decideDefaultCombination(idProduct, currentDefaultId, combinations) {
const eligible = (c) => String(c.id_product) === String(idProduct) && String(c.active ?? "0") === "1";
const liveIds = new Set(combinations.filter(eligible).map((c) => String(c.id)));
const isValid = !["", "0", "null", "undefined"].includes(String(currentDefaultId ?? "")) &&
liveIds.has(String(currentDefaultId));
if (isValid) {
return { action: "none", reason: "default combination is active and belongs to the product", targetId: null };
}
const eligibleCombos = combinations.filter(eligible);
if (eligibleCombos.length === 0) {
return { action: "flag", reason: "no active combination belongs to this product", targetId: null };
}
const cheapest = eligibleCombos.reduce((best, c) =>
Number(c.price || 0) < Number(best.price || 0) ? c : best
);
return { action: "repair", reason: "default combination missing or invalid, replacing with cheapest active one", targetId: cheapest.id };
}
Repair only the product's default combination pointer
When the decision says to repair, send PUT /api/products/{id}?output_format=JSON with the resource body carrying the product's existing fields plus the corrected id_default_combination. Never touch the combination rows themselves, the fix is entirely on the product's own pointer.
def api_put(path, resource_key, body):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"}, auth=AUTH,
json={resource_key: body}, timeout=30,
)
r.raise_for_status()
return r.json()
def repair_default_combination(product, target_combination_id):
body = dict(product)
body["id_default_combination"] = target_combination_id
return api_put(f"products/{product['id']}", "product", body)
async function apiPut(path, resourceKey, body) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ [resourceKey]: body }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
return res.json();
}
async function repairDefaultCombination(product, targetCombinationId) {
const body = { ...product, id_default_combination: targetCombinationId };
return apiPut(`products/${product.id}`, "product", body);
}
Wire it together with a dry run guard
The loop ties every piece together: list products, pull each one's live combinations, run decide_default_combination, log the product id, the stale default id, and the proposed replacement for anything flagged, and only PUT when the decision explicitly says to repair. Leave DRY_RUN on for the first runs and review the list before you let it write. Run it after any bulk combination edit, import, or product duplication.
Always start with DRY_RUN=true. If a product has no active combination at all, do not invent one. Report the product id for a human to add or restore a combination instead of writing a default pointer that has nothing valid to point at.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, only ever repairs the product's own id_default_combination field, respects the dry run flag, and is safe to run again and again because it never touches a combination that is inactive or belongs to a different product.
"""Find and repair PrestaShop products whose default combination pointer is stale.
A product with combinations shows its headline price by resolving id_default_combination
to one specific combination row. When that pointer is 0, blank, or names a combination
that was deleted, deactivated, or belongs to a different product, the price lookup has
nothing valid to read and the product displays a price of zero even though its other
combinations have real prices.
This script lists products, pulls each one's live combinations, and checks whether the
stored id_default_combination resolves to an active combination that still belongs to
that product. When it does not and an eligible combination exists, it repairs the
pointer to the cheapest eligible one. When no eligible combination exists at all, it
flags the product for a human instead of guessing. The only write is a PUT on the
product's own id_default_combination field; combination rows are never modified.
Run after bulk combination edits, imports, or product duplication. 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("fix_default_combination")
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 decide_default_combination(id_product, current_default_id, combinations):
"""Pure decision function, no I/O.
id_product: the product being checked.
current_default_id: the product's stored id_default_combination value.
combinations: the product's live combinations, each a dict with id, id_product,
active, and price.
Returns a decision dict describing what to do. All HTTP calls happen in the caller.
"""
def eligible(c):
return (
str(c.get("id_product")) == str(id_product)
and str(c.get("active", "0")) == "1"
)
live_ids = {str(c["id"]) for c in combinations if eligible(c)}
is_valid = str(current_default_id) not in ("", "0", "None") and str(current_default_id) in live_ids
if is_valid:
return {
"action": "none",
"reason": "default combination is active and belongs to the product",
"target_id": None,
}
eligible_combos = [c for c in combinations if eligible(c)]
if not eligible_combos:
return {
"action": "flag",
"reason": "no active combination belongs to this product",
"target_id": None,
}
cheapest = min(eligible_combos, key=lambda c: float(c.get("price", 0) or 0))
return {
"action": "repair",
"reason": "default combination missing or invalid, replacing with cheapest active one",
"target_id": cheapest["id"],
}
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def api_put(path, resource_key, body):
r = requests.put(
f"{PRESTASHOP_URL}/api/{path}",
params={"output_format": "JSON"},
auth=AUTH,
json={resource_key: body},
timeout=30,
)
r.raise_for_status()
return r.json()
def list_products_with_combinations(limit=100):
data = api_get("products", params={"display": "full", "limit": limit})
products = data.get("products") or []
return [p for p in products if str(p.get("id_default_combination", "0")) != ""]
def list_combinations(id_product):
data = api_get("combinations", params={
"filter[id_product]": id_product,
"display": "full",
})
return data.get("combinations") or []
def repair_default_combination(product, target_combination_id):
body = dict(product)
body["id_default_combination"] = target_combination_id
return api_put(f"products/{product['id']}", "product", body)
def run():
flagged = 0
repaired = 0
for product in list_products_with_combinations():
id_product = product.get("id")
current_default_id = product.get("id_default_combination")
combinations = list_combinations(id_product)
decision = decide_default_combination(id_product, current_default_id, combinations)
if decision["action"] == "none":
continue
flagged += 1
log.warning(
"Product %s current id_default_combination=%s action=%s reason=%s target_id=%s",
id_product, current_default_id, decision["action"], decision["reason"], decision["target_id"],
)
if decision["action"] == "repair" and not DRY_RUN:
repair_default_combination(product, decision["target_id"])
repaired += 1
log.info("Repaired product %s id_default_combination=%s.", id_product, decision["target_id"])
log.info("Done. %d product(s) flagged, %d repaired.", flagged, repaired)
if __name__ == "__main__":
run()
/**
* Find and repair PrestaShop products whose default combination pointer is stale.
*
* A product with combinations shows its headline price by resolving id_default_combination
* to one specific combination row. When that pointer is 0, blank, or names a combination
* that was deleted, deactivated, or belongs to a different product, the price lookup has
* nothing valid to read and the product displays a price of zero even though its other
* combinations have real prices.
*
* This script lists products, pulls each one's live combinations, and checks whether the
* stored id_default_combination resolves to an active combination that still belongs to
* that product. When it does not and an eligible combination exists, it repairs the
* pointer to the cheapest eligible one. When no eligible combination exists at all, it
* flags the product for a human instead of guessing. The only write is a PUT on the
* product's own id_default_combination field; combination rows are never modified.
*
* Guide: https://www.allanninal.dev/prestashop/missing-default-combination-zero-price/
*/
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.
*
* idProduct: the product being checked.
* currentDefaultId: the product's stored id_default_combination value.
* combinations: the product's live combinations, each an object with id, id_product,
* active, and price.
*
* Returns a decision object describing what to do. All HTTP calls happen in the caller.
*/
export function decideDefaultCombination(idProduct, currentDefaultId, combinations) {
const eligible = (c) => String(c.id_product) === String(idProduct) && String(c.active ?? "0") === "1";
const liveIds = new Set(combinations.filter(eligible).map((c) => String(c.id)));
const isValid = !["", "0", "null", "undefined"].includes(String(currentDefaultId ?? "")) &&
liveIds.has(String(currentDefaultId));
if (isValid) {
return {
action: "none",
reason: "default combination is active and belongs to the product",
targetId: null,
};
}
const eligibleCombos = combinations.filter(eligible);
if (eligibleCombos.length === 0) {
return {
action: "flag",
reason: "no active combination belongs to this product",
targetId: null,
};
}
const cheapest = eligibleCombos.reduce((best, c) =>
Number(c.price || 0) < Number(best.price || 0) ? c : best
);
return {
action: "repair",
reason: "default combination missing or invalid, replacing with cheapest active one",
targetId: cheapest.id,
};
}
async function apiGet(path, params = {}) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
return res.json();
}
async function apiPut(path, resourceKey, body) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
const res = await fetch(url, {
method: "PUT",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify({ [resourceKey]: body }),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT ${path}`);
return res.json();
}
async function listProductsWithCombinations(limit = 100) {
const data = await apiGet("products", { display: "full", limit });
const products = data.products || [];
return products.filter((p) => String(p.id_default_combination ?? "0") !== "");
}
async function listCombinations(idProduct) {
const data = await apiGet("combinations", {
"filter[id_product]": idProduct,
display: "full",
});
return data.combinations || [];
}
async function repairDefaultCombination(product, targetCombinationId) {
const body = { ...product, id_default_combination: targetCombinationId };
return apiPut(`products/${product.id}`, "product", body);
}
export async function run() {
let flagged = 0;
let repaired = 0;
for (const product of await listProductsWithCombinations()) {
const idProduct = product.id;
const currentDefaultId = product.id_default_combination;
const combinations = await listCombinations(idProduct);
const decision = decideDefaultCombination(idProduct, currentDefaultId, combinations);
if (decision.action === "none") continue;
flagged++;
console.warn(
`Product ${idProduct} current id_default_combination=${currentDefaultId} ` +
`action=${decision.action} reason=${decision.reason} target_id=${decision.targetId}`
);
if (decision.action === "repair" && !DRY_RUN) {
await repairDefaultCombination(product, decision.targetId);
repaired++;
console.log(`Repaired product ${idProduct} id_default_combination=${decision.targetId}.`);
}
}
console.log(`Done. ${flagged} product(s) flagged, ${repaired} repaired.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides whether the script ever writes to a product's price-defining pointer, and whether it picks a safe replacement. Because we kept decide_default_combination pure, the test needs no network and no PrestaShop store. It just feeds in plain objects and checks the answer.
from fix_default_combination import decide_default_combination
def combo(**over):
base = {"id": 5, "id_product": 10, "active": "1", "price": "12.00"}
base.update(over)
return base
def test_no_action_when_default_is_valid_and_active():
result = decide_default_combination(10, 5, [combo(id=5)])
assert result["action"] == "none"
def test_repairs_when_default_id_is_zero():
result = decide_default_combination(10, 0, [combo(id=5, price="9.00"), combo(id=6, price="15.00")])
assert result["action"] == "repair"
assert result["target_id"] == 5
def test_repairs_when_default_id_is_blank():
result = decide_default_combination(10, "", [combo(id=7)])
assert result["action"] == "repair"
assert result["target_id"] == 7
def test_repairs_when_default_points_at_deleted_combination():
result = decide_default_combination(10, 99, [combo(id=5)])
assert result["action"] == "repair"
assert result["target_id"] == 5
def test_repairs_when_default_points_at_inactive_combination():
result = decide_default_combination(10, 5, [combo(id=5, active="0"), combo(id=6, active="1")])
assert result["action"] == "repair"
assert result["target_id"] == 6
def test_ignores_combination_belonging_to_a_different_product():
result = decide_default_combination(10, 5, [combo(id=5, id_product=99)])
assert result["action"] == "flag"
def test_flags_when_no_eligible_combination_exists():
result = decide_default_combination(10, 0, [combo(id=5, active="0")])
assert result["action"] == "flag"
assert result["target_id"] is None
def test_picks_cheapest_among_multiple_eligible_combinations():
combos = [combo(id=1, price="20.00"), combo(id=2, price="8.50"), combo(id=3, price="14.00")]
result = decide_default_combination(10, 0, combos)
assert result["target_id"] == 2
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideDefaultCombination } from "./fix-default-combination.js";
const combo = (over = {}) => ({ id: 5, id_product: 10, active: "1", price: "12.00", ...over });
test("no action when default is valid and active", () => {
const result = decideDefaultCombination(10, 5, [combo({ id: 5 })]);
assert.equal(result.action, "none");
});
test("repairs when default id is zero", () => {
const result = decideDefaultCombination(10, 0, [combo({ id: 5, price: "9.00" }), combo({ id: 6, price: "15.00" })]);
assert.equal(result.action, "repair");
assert.equal(result.targetId, 5);
});
test("repairs when default id is blank", () => {
const result = decideDefaultCombination(10, "", [combo({ id: 7 })]);
assert.equal(result.action, "repair");
assert.equal(result.targetId, 7);
});
test("repairs when default points at deleted combination", () => {
const result = decideDefaultCombination(10, 99, [combo({ id: 5 })]);
assert.equal(result.action, "repair");
assert.equal(result.targetId, 5);
});
test("repairs when default points at inactive combination", () => {
const result = decideDefaultCombination(10, 5, [combo({ id: 5, active: "0" }), combo({ id: 6, active: "1" })]);
assert.equal(result.action, "repair");
assert.equal(result.targetId, 6);
});
test("ignores combination belonging to a different product", () => {
const result = decideDefaultCombination(10, 5, [combo({ id: 5, id_product: 99 })]);
assert.equal(result.action, "flag");
});
test("flags when no eligible combination exists", () => {
const result = decideDefaultCombination(10, 0, [combo({ id: 5, active: "0" })]);
assert.equal(result.action, "flag");
assert.equal(result.targetId, null);
});
test("picks cheapest among multiple eligible combinations", () => {
const combos = [combo({ id: 1, price: "20.00" }), combo({ id: 2, price: "8.50" }), combo({ id: 3, price: "14.00" })];
const result = decideDefaultCombination(10, 0, combos);
assert.equal(result.targetId, 2);
});
Case studies
The catalog that lost its prices overnight
A store rebuilt combinations for a batch of products after an attribute rename, dropping and recreating the combination rows with fresh ids. The rebuild worked for stock and images, but every product's id_default_combination still pointed at the old, now-deleted ids, so dozens of listings showed a price of 0.00 the next morning.
Running the script in dry run listed every affected product with its stale id next to the cheapest live combination it would use instead. The team reviewed the list, agreed it looked right, and let it write. Prices came back within minutes, without touching a single combination row.
The duplicated product that priced itself like the original
A merchandiser duplicated a variant product to start a new seasonal listing. The duplicate copied over the source product's id_default_combination value, which pointed at a combination that belonged to the original product, not the new one, so the price lookup found nothing valid on the copy and fell back to zero.
The script flagged the duplicate immediately, since the stored id did not match any combination whose id_product was the new product. Because the new product had its own valid combinations, it repaired the pointer to the cheapest one automatically, once the team confirmed the dry run output.
After this runs, every product's id_default_combination either points at a real, active combination it actually owns, or the product is sitting in a short flagged list waiting on a human to add a combination. No listing shows a price of zero because of a stale pointer, and nothing gets a made-up default that resolves to the wrong product's variant.
FAQ
Why does a PrestaShop product with variants show a price of zero?
The product row keeps an id_default_combination pointer that tells the storefront and the back office which variant row to read the price and image from. When that pointer is 0, blank, or points at a combination that was deleted or belongs to a different product, the price lookup has nothing valid to read and falls back to zero.
Can I just set any combination as the default to fix the zero price?
No. The default combination must be an active, non-deleted combination that actually belongs to that product. Pointing id_default_combination at a random or unrelated row can attach the wrong price, image, or stock to the product, which is worse than showing zero because it is wrong instead of obviously broken.
How do I fix a missing default combination through the PrestaShop webservice?
List the product's combinations with GET /api/combinations filtered by id_product, confirm one is both active and still attached to that product, then PUT the product resource with id_default_combination set to that combination's id. If no valid combination exists, flag the product for a human instead of guessing.
Related field notes
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.
Did this bring your prices back?
If this saved you a confusing zero-price listing or a wrong duplicate, 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