Skip to content

Reconciler Webservice API Data Sync

Stock update via webservice does not sync back to the product quantity field

You PUT a new quantity to stock_availables, the back office shows the right number, and then you GET the product back and its own quantity field is stale, or stuck at zero. Nothing is actually broken with your stock. Here is why PrestaShop keeps two numbers for the same thing, and a small script that finds the mismatch and repairs it the safe way.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A large factory
Photo by Arno Senoner on Unsplash
The short answer

Since PrestaShop 1.5, real stock lives in stock_available.quantity, while product.quantity on the products resource is a deprecated, denormalized column kept only for backward compatible SQL and exports. Writing to stock_availables updates the true stock but does not always refresh that cached column, so product.quantity can sit stale or stuck at zero even when stock is correct. Run a script that pulls both values per product and combination, flags any pair that disagrees, especially product.quantity == 0 with stock_available.quantity > 0, and repairs it by re-PUTting the stock_availables row with its own unchanged quantity to force PrestaShop to recompute the cache. Never write to the deprecated field directly. Full code, tests, and a dry run guard are below.

The problem in plain words

PrestaShop used to keep stock as a single number sitting on the product itself. Since version 1.5, that changed. Real, authoritative stock moved into its own entity, StockAvailable, backed by the stock_available table, with one row per product and combination. The old quantity column on ps_product stayed in the schema, but only as a cached, denormalized copy kept for legacy SQL joins and exports that still expect it.

The webservice's products resource reads and writes that legacy column directly. So when you correctly PUT a new quantity to stock_availables, the authoritative number updates, the back office reads StockAvailable and shows the right count, and everything looks fine there. But nothing guarantees that write also refreshes the cached column on ps_product. GET the product back through /api/products/{id} and its own quantity field can still show the old number, or zero, even though the real stock is correct.

PUT stock_availables real stock updates Back office reads StockAvailable, correct ps_product cache not refreshed product.quantity stale old value, or stuck at 0 Integration sees wrong qty product.quantity is a deprecated, denormalized column, not the source of truth
The stock write is correct. The problem is that the products resource still exposes a legacy cached column that a good stock_availables write does not reliably refresh.

Why it happens

This is not a one-off configuration mistake, it is a documented split between an old data model and a new one that PrestaShop core never fully reconciled at the API layer. A few recurring ways stores and integrations hit it:

PrestaShop's own core issue tracker has confirmed, major-severity reports of exactly this desync, and PrestaShop's developer documentation is direct about it: product.quantity is deprecated, and integrators should manage stock exclusively through StockAvailable and StockMovement rather than relying on it staying in sync. See the citations at the end for the exact reports and docs.

The key insight

product.quantity is not a live number you can trust, and it is not something you should write to fix this. That is the exact anti-pattern that caused the bug. stock_available.quantity is authoritative. The safe way to nudge the cached column back into agreement is to make PrestaShop recompute it itself, by re-saving the stock_availables row it already owns.

The fix, as a flow

We never write to the products resource to fix quantity. Instead the script pulls each product's own cached value and its real stock_available row, compares them, and for the ones that disagree, re-PUTs the stock_availables resource with its own current, unchanged quantity. That forces PrestaShop's internal Product::updateQuantity() hook to run again and refresh the cache on its own. Anything involving depends_on_stock being off, multi-shop splits, or a negative delta is flagged for a human instead of touched automatically.

GET product.quantity the cached column GET stock_available.quantity the real, authoritative row Diff the two per product/combination Safe to auto-resync? yes no, flag for review Re-PUT stock_availables same quantity, forces recompute product.quantity cache refreshes
The script only touches the display column, and only through PrestaShop's own StockAvailable write path. Anything risky is flagged instead of auto-repaired.

Build it step by step

1

Get a webservice key with the right permissions

In the backoffice, go to Advanced Parameters, Webservice, and create a key with read access to products and stock_availables, plus write access to stock_availables. 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 write
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 write
2

Read the product's cached quantity

Pull the product itself and read its own quantity field. This is the deprecated, denormalized column on ps_product that a webservice consumer sees when it reads /api/products/{id}, and the value most likely to lag behind reality.

step2.py
import os, requests

BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]

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

def product_cached_quantity(id_product):
    data = api_get(f"products/{id_product}", {})
    product = data["product"]
    return int(product.get("quantity") or 0)
step2.js
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "";

function authHeader() {
  return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}

async function apiGet(path, params) {
  const url = new URL(`${BASE_URL}/api/${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, { headers: { Authorization: authHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function productCachedQuantity(idProduct) {
  const data = await apiGet(`products/${idProduct}`, {});
  return Number(data.product.quantity || 0);
}
3

Read the real stock_available row

Filter stock_availables by id_product and id_product_attribute, using 0 for a product with no combinations, and read back the authoritative quantity, plus out_of_stock and depends_on_stock so you know whether a mismatch is purely cosmetic or actually affects sellability.

step3.py
def stock_available_row(id_product, id_product_attribute=0):
    data = api_get("stock_availables", {
        "filter[id_product]": id_product,
        "filter[id_product_attribute]": id_product_attribute,
        "display": "full",
    })
    rows = data.get("stock_availables") or []
    if not rows:
        return None
    row = rows[0]
    return {
        "id_stock_available": int(row["id"]),
        "id_product": int(row["id_product"]),
        "id_product_attribute": int(row.get("id_product_attribute") or 0),
        "quantity": int(row.get("quantity") or 0),
        "out_of_stock": int(row.get("out_of_stock") or 0),
        "depends_on_stock": int(row.get("depends_on_stock") or 0),
    }
step3.js
async function stockAvailableRow(idProduct, idProductAttribute = 0) {
  const data = await apiGet("stock_availables", {
    "filter[id_product]": idProduct,
    "filter[id_product_attribute]": idProductAttribute,
    display: "full",
  });
  const rows = data.stock_availables || [];
  if (!rows.length) return null;
  const row = rows[0];
  return {
    id_stock_available: Number(row.id),
    id_product: Number(row.id_product),
    id_product_attribute: Number(row.id_product_attribute || 0),
    quantity: Number(row.quantity || 0),
    out_of_stock: Number(row.out_of_stock || 0),
    depends_on_stock: Number(row.depends_on_stock || 0),
  };
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the two numbers and two flags in, and returns a status and an action out. It never talks to the network and never mutates anything, so it is simple to test with plain values. The rule is deliberately cautious: it only allows an automatic resync when the shop actually depends on StockAvailable for sellability, and it hands everything else to a human.

decide.py
def decide_reconciliation(product_qty, stock_avail_qty, out_of_stock, depends_on_stock):
    delta = stock_avail_qty - product_qty

    if delta == 0:
        return {"status": "in_sync", "action": "none", "delta": 0}

    if product_qty == 0 and stock_avail_qty > 0:
        action = "resync_display_only" if depends_on_stock == 1 else "flag_for_review"
        return {"status": "stuck_zero", "action": action, "delta": delta}

    action = "resync_display_only" if depends_on_stock == 1 else "flag_for_review"
    return {"status": "stale_product_field", "action": action, "delta": delta}
decide.js
export function decideReconciliation(productQty, stockAvailQty, outOfStock, dependsOnStock) {
  const delta = stockAvailQty - productQty;

  if (delta === 0) {
    return { status: "in_sync", action: "none", delta: 0 };
  }

  if (productQty === 0 && stockAvailQty > 0) {
    const action = dependsOnStock === 1 ? "resync_display_only" : "flag_for_review";
    return { status: "stuck_zero", action, delta };
  }

  const action = dependsOnStock === 1 ? "resync_display_only" : "flag_for_review";
  return { status: "stale_product_field", action, delta };
}
5

Repair through stock_availables, never through the products resource

For a row that comes back resync_display_only, do not write to /api/products/{id}. That is the anti-pattern that caused the bug. Instead, re-PUT the stock_availables/{id} resource with the same, unchanged quantity plus its id_product and id_product_attribute. That forces PrestaShop to run its own recompute path and refresh the cached column on its own.

apply.py
def resync_stock_available(row):
    body = {
        "stock_available": {
            "id": row["id_stock_available"],
            "id_product": row["id_product"],
            "id_product_attribute": row["id_product_attribute"],
            "quantity": row["quantity"],
        }
    }
    r = requests.put(
        f"{BASE_URL}/api/stock_availables/{row['id_stock_available']}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function resyncStockAvailable(row) {
  const url = new URL(`${BASE_URL}/api/stock_availables/${row.id_stock_available}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({
      stock_available: {
        id: row.id_stock_available,
        id_product: row.id_product,
        id_product_attribute: row.id_product_attribute,
        quantity: row.quantity,
      },
    }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}
6

Wire it together with a dry run guard

The run loop pulls a batch of products, reads each one's cached quantity and its real stock row, runs the pure decision function, and logs every mismatch with its status and planned action. Leave DRY_RUN on for the first few runs so it only reports. Once you trust the list, switch it off so it re-PUTs the flagged stock_availables rows and lets PrestaShop refresh the cache itself. Anything marked flag_for_review is never written automatically, no matter what DRY_RUN is set to.

Run it safe

Always start with DRY_RUN=true. This script never writes to /api/products/{id} to change quantity, it only reposts a stock_availables row's own existing quantity, which is the supported write path PrestaShop's own stock model uses.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only resyncs rows the pure decision function marks safe, and flags everything else for a human.

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.
webservice_stock_resync.py
"""Find and repair PrestaShop webservice stock updates that never reached product.quantity.

Since PrestaShop 1.5, real stock lives in stock_available.quantity, while product.quantity
on the products resource is a deprecated, denormalized column kept only for backward
compatible SQL and exports. A correct PUT to stock_availables updates the true stock but
does not always refresh that cached column, so product.quantity can sit stale or stuck at
zero. This pulls both values per product and combination, flags any pair that disagrees,
and repairs it by reposting the stock_availables row's own unchanged quantity, which forces
PrestaShop's internal Product::updateQuantity() hook to recompute the cache. Never writes
to the products resource to fix quantity. 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("webservice_stock_resync")

BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


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


def product_ids(limit="0,50"):
    data = api_get("products", {"display": "[id]", "limit": limit})
    products = data.get("products") or []
    return [int(p["id"]) for p in products]


def product_cached_quantity(id_product):
    data = api_get(f"products/{id_product}", {})
    product = data["product"]
    return int(product.get("quantity") or 0)


def stock_available_row(id_product, id_product_attribute=0):
    data = api_get("stock_availables", {
        "filter[id_product]": id_product,
        "filter[id_product_attribute]": id_product_attribute,
        "display": "full",
    })
    rows = data.get("stock_availables") or []
    if not rows:
        return None
    row = rows[0]
    return {
        "id_stock_available": int(row["id"]),
        "id_product": int(row["id_product"]),
        "id_product_attribute": int(row.get("id_product_attribute") or 0),
        "quantity": int(row.get("quantity") or 0),
        "out_of_stock": int(row.get("out_of_stock") or 0),
        "depends_on_stock": int(row.get("depends_on_stock") or 0),
    }


def decide_reconciliation(product_qty, stock_avail_qty, out_of_stock, depends_on_stock):
    delta = stock_avail_qty - product_qty

    if delta == 0:
        return {"status": "in_sync", "action": "none", "delta": 0}

    if product_qty == 0 and stock_avail_qty > 0:
        action = "resync_display_only" if depends_on_stock == 1 else "flag_for_review"
        return {"status": "stuck_zero", "action": action, "delta": delta}

    action = "resync_display_only" if depends_on_stock == 1 else "flag_for_review"
    return {"status": "stale_product_field", "action": action, "delta": delta}


def resync_stock_available(row):
    body = {
        "stock_available": {
            "id": row["id_stock_available"],
            "id_product": row["id_product"],
            "id_product_attribute": row["id_product_attribute"],
            "quantity": row["quantity"],
        }
    }
    r = requests.put(
        f"{BASE_URL}/api/stock_availables/{row['id_stock_available']}",
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    checked = 0
    resynced = 0
    flagged = 0

    for id_product in product_ids():
        row = stock_available_row(id_product, 0)
        if row is None:
            continue
        product_qty = product_cached_quantity(id_product)
        checked += 1

        decision = decide_reconciliation(
            product_qty, row["quantity"], row["out_of_stock"], row["depends_on_stock"]
        )
        if decision["status"] == "in_sync":
            continue

        log.warning(
            "Product %s: product.quantity=%s stock_available.quantity=%s status=%s action=%s",
            id_product, product_qty, row["quantity"], decision["status"], decision["action"],
        )

        if decision["action"] == "resync_display_only":
            if not DRY_RUN:
                resync_stock_available(row)
            resynced += 1
        elif decision["action"] == "flag_for_review":
            flagged += 1

    log.info(
        "Done. %d product(s) checked, %d %s, %d flagged for manual review.",
        checked, resynced, "to resync" if DRY_RUN else "resynced", flagged,
    )


if __name__ == "__main__":
    run()
webservice-stock-resync.js
/**
 * Find and repair PrestaShop webservice stock updates that never reached product.quantity.
 *
 * Since PrestaShop 1.5, real stock lives in stock_available.quantity, while product.quantity
 * on the products resource is a deprecated, denormalized column kept only for backward
 * compatible SQL and exports. A correct PUT to stock_availables updates the true stock but
 * does not always refresh that cached column, so product.quantity can sit stale or stuck at
 * zero. This pulls both values per product and combination, flags any pair that disagrees,
 * and repairs it by reposting the stock_availables row's own unchanged quantity, which forces
 * PrestaShop's internal Product::updateQuantity() hook to recompute the cache. Never writes
 * to the products resource to fix quantity. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/webservice-stock-update-not-synced-to-product/
 */
import { pathToFileURL } from "node:url";

const BASE_URL = (process.env.PRESTASHOP_URL || "https://example.test").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "dummy_key";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

function authHeader() {
  return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}

async function apiGet(path, params) {
  const url = new URL(`${BASE_URL}/api/${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, { headers: { Authorization: authHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function productIds(limit = "0,50") {
  const data = await apiGet("products", { display: "[id]", limit });
  const products = data.products || [];
  return products.map((p) => Number(p.id));
}

async function productCachedQuantity(idProduct) {
  const data = await apiGet(`products/${idProduct}`, {});
  return Number(data.product.quantity || 0);
}

async function stockAvailableRow(idProduct, idProductAttribute = 0) {
  const data = await apiGet("stock_availables", {
    "filter[id_product]": idProduct,
    "filter[id_product_attribute]": idProductAttribute,
    display: "full",
  });
  const rows = data.stock_availables || [];
  if (!rows.length) return null;
  const row = rows[0];
  return {
    id_stock_available: Number(row.id),
    id_product: Number(row.id_product),
    id_product_attribute: Number(row.id_product_attribute || 0),
    quantity: Number(row.quantity || 0),
    out_of_stock: Number(row.out_of_stock || 0),
    depends_on_stock: Number(row.depends_on_stock || 0),
  };
}

export function decideReconciliation(productQty, stockAvailQty, outOfStock, dependsOnStock) {
  const delta = stockAvailQty - productQty;

  if (delta === 0) {
    return { status: "in_sync", action: "none", delta: 0 };
  }

  if (productQty === 0 && stockAvailQty > 0) {
    const action = dependsOnStock === 1 ? "resync_display_only" : "flag_for_review";
    return { status: "stuck_zero", action, delta };
  }

  const action = dependsOnStock === 1 ? "resync_display_only" : "flag_for_review";
  return { status: "stale_product_field", action, delta };
}

async function resyncStockAvailable(row) {
  const url = new URL(`${BASE_URL}/api/stock_availables/${row.id_stock_available}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({
      stock_available: {
        id: row.id_stock_available,
        id_product: row.id_product,
        id_product_attribute: row.id_product_attribute,
        quantity: row.quantity,
      },
    }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

export async function run() {
  let checked = 0;
  let resynced = 0;
  let flagged = 0;

  for (const idProduct of await productIds()) {
    const row = await stockAvailableRow(idProduct, 0);
    if (!row) continue;
    const productQty = await productCachedQuantity(idProduct);
    checked++;

    const decision = decideReconciliation(productQty, row.quantity, row.out_of_stock, row.depends_on_stock);
    if (decision.status === "in_sync") continue;

    console.warn(
      `Product ${idProduct}: product.quantity=${productQty} stock_available.quantity=${row.quantity} status=${decision.status} action=${decision.action}`
    );

    if (decision.action === "resync_display_only") {
      if (!DRY_RUN) await resyncStockAvailable(row);
      resynced++;
    } else if (decision.action === "flag_for_review") {
      flagged++;
    }
  }

  console.log(
    `Done. ${checked} product(s) checked, ${resynced} ${DRY_RUN ? "to resync" : "resynced"}, ${flagged} flagged for manual review.`
  );
}

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 which mismatches get an automatic resync versus a flag for a human. Because decide_reconciliation is pure, the test needs no PrestaShop instance and no network. It just feeds in plain numbers and checks the answer.

test_webservice_reconciliation.py
from webservice_stock_resync import decide_reconciliation


def test_in_sync_when_values_match():
    result = decide_reconciliation(10, 10, 0, 1)
    assert result == {"status": "in_sync", "action": "none", "delta": 0}


def test_stuck_zero_resyncs_when_depends_on_stock():
    result = decide_reconciliation(0, 25, 0, 1)
    assert result == {"status": "stuck_zero", "action": "resync_display_only", "delta": 25}


def test_stuck_zero_flags_when_not_depends_on_stock():
    result = decide_reconciliation(0, 25, 0, 0)
    assert result == {"status": "stuck_zero", "action": "flag_for_review", "delta": 25}


def test_stale_product_field_resyncs_when_depends_on_stock():
    result = decide_reconciliation(8, 12, 0, 1)
    assert result == {"status": "stale_product_field", "action": "resync_display_only", "delta": 4}


def test_stale_product_field_flags_when_not_depends_on_stock():
    result = decide_reconciliation(8, 12, 0, 0)
    assert result == {"status": "stale_product_field", "action": "flag_for_review", "delta": 4}


def test_negative_delta_is_stale_product_field_not_stuck_zero():
    result = decide_reconciliation(20, 5, 0, 1)
    assert result == {"status": "stale_product_field", "action": "resync_display_only", "delta": -15}


def test_zero_and_zero_is_in_sync():
    result = decide_reconciliation(0, 0, 0, 1)
    assert result == {"status": "in_sync", "action": "none", "delta": 0}
webservice-stock-resync.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideReconciliation } from "./webservice-stock-resync.js";

test("in sync when values match", () => {
  assert.deepEqual(decideReconciliation(10, 10, 0, 1), { status: "in_sync", action: "none", delta: 0 });
});

test("stuck zero resyncs when depends on stock", () => {
  assert.deepEqual(decideReconciliation(0, 25, 0, 1), { status: "stuck_zero", action: "resync_display_only", delta: 25 });
});

test("stuck zero flags when not depends on stock", () => {
  assert.deepEqual(decideReconciliation(0, 25, 0, 0), { status: "stuck_zero", action: "flag_for_review", delta: 25 });
});

test("stale product field resyncs when depends on stock", () => {
  assert.deepEqual(decideReconciliation(8, 12, 0, 1), { status: "stale_product_field", action: "resync_display_only", delta: 4 });
});

test("stale product field flags when not depends on stock", () => {
  assert.deepEqual(decideReconciliation(8, 12, 0, 0), { status: "stale_product_field", action: "flag_for_review", delta: 4 });
});

test("negative delta is stale product field not stuck zero", () => {
  assert.deepEqual(decideReconciliation(20, 5, 0, 1), { status: "stale_product_field", action: "resync_display_only", delta: -15 });
});

test("zero and zero is in sync", () => {
  assert.deepEqual(decideReconciliation(0, 0, 0, 1), { status: "in_sync", action: "none", delta: 0 });
});

Case studies

Marketplace feed

The channel that thought everything was out of stock

A homeware store fed live availability to a marketplace by reading product.quantity from the webservice after every stock update. The store's own warehouse team was updating real stock correctly through stock_availables, but the marketplace kept delisting items as out of stock because the cached field it was reading never moved off zero.

Switching the feed to read stock_available.quantity fixed the read side immediately. Running the reconciler once cleaned up the existing stuck-at-zero rows so any other integration reading the old field would see correct numbers too.

Legacy import script

The nightly script still writing to the wrong place

A store's nightly stock import, written years earlier, PUT its quantities to /api/products/{id} because that was the field the original developer found first. Real stock on the storefront and back office silently drifted from what the import script believed it had set, and nobody noticed until a bestseller oversold.

Moving the import to write stock_availables instead stopped new drift. The reconciler then found and safely resynced every product where the deprecated column and the real stock had already diverged, without ever touching the products resource again.

What good looks like

After this runs on a schedule, product.quantity stops being a trap for integrations that read it out of habit. Every write goes through stock_availables, the one supported path, and any mismatch that is safe to touch gets nudged back into agreement automatically, while anything involving multi-shop splits, negative deltas, or depends_on_stock being off waits for a human to look at it. Real stock was never wrong, only the legacy mirror of it.

FAQ

Why does product.quantity stay at zero after I update stock through the webservice?

Since PrestaShop 1.5, real stock lives in stock_available.quantity, while product.quantity on the products resource is a deprecated, denormalized column kept only for backward compatibility. A correct PUT to stock_availables updates the true stock but does not always refresh that cached column, so product.quantity can sit stale or at zero even though the back office shows the right number.

Should I fix this by writing directly to the products resource quantity field?

No. Writing to product.quantity on the products resource is the anti-pattern that caused this bug in the first place. PrestaShop's own Stock FAQ says to treat product.quantity as deprecated and manage stock exclusively through StockAvailable, so all writes should go to stock_availables instead.

How do I actually resync product.quantity once I find a mismatch?

Re-PUT the same stock_availables row with its own current, unchanged quantity. That forces PrestaShop's internal Product::updateQuantity() hook to fire and recompute the cached product.quantity column, without you ever writing to the deprecated field directly.

Related field notes

Citations

On the problem:

  1. WS - Products - you can't get or set the product quantity (always zero). github.com/PrestaShop/PrestaShop/issues/18953
  2. Bug Web Service Api Prestashop when create or update the quantity of a product. github.com/PrestaShop/PrestaShop/issues/24520
  3. WebService Prestashop: unable to update attributes product quantities. prestashop.com/forums/topic/1018596-webservice-prestashop-unable-to-update-attributes-product-quantities

On the solution:

  1. PrestaShop Developer Documentation: Stock FAQ. devdocs.prestashop-project.org/9/faq/stock/
  2. PrestaShop Developer Documentation: Create a product from start to finish with Webservices. devdocs.prestashop-project.org/9/webservice/tutorials/create-product-az/
  3. PrestaShop Developer Documentation: Webservice reference, resources, filters, and output_format. devdocs.prestashop-project.org/9/webservice/

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 settle your stock numbers?

If this saved you a confusing "why is it zero" support ticket or a wrong out of stock listing, 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