Diagnostic Webservice API Data Sync
Stock hooks and back in stock alerts never fire on webservice quantity updates
You restock a product through the webservice, the quantity in stock_availables updates correctly, and everything looks fine in the database. Then a customer who asked to be notified when it came back never gets an email, and any module or custom code that reacts to a restock never runs. Nothing errored, nothing logged a failure. Here is why writing to the webservice never runs PrestaShop's stock hooks, and a small script that catches the restock yourself and drives the notification safely.
PrestaShop's back in stock alert emails and any custom code listening on stock hooks such as actionUpdateQuantity are wired to the admin save path, not to the database row. When you update stock_availables.quantity through the webservice with a PATCH or PUT, the resource controller does a plain ORM save straight to the table. It never calls the business logic in StockAvailable or the product controller, so no hook runs and no alert module ever gets a chance to fire, even though the quantity is now correct. Run a Python or Node.js script that tracks each product's last known quantity itself, detects the specific zero to positive transition after a webservice write, and only then flags or drives the notification for products that are active and visible. Full code, tests, and citations are below.
The problem in plain words
When a store manager restocks a product from the PrestaShop back office, saving the product runs through a controller that updates the quantity and also calls out to a series of hooks, the same mechanism modules use to react to almost everything that happens in the store. One of those hooks is how the back in stock alert module knows to look at its subscriber list and start sending emails. Another is how any custom module you or a developer wrote reacts to inventory changing.
The webservice does not go through that controller. Writing to the stock_availables resource updates the row directly through the ORM, which is exactly what makes the webservice fast and predictable for pure data sync, but it also means none of that surrounding hook logic runs. The quantity is correct. The alert never fires. Nothing in the response tells you it did not fire, because from the API's point of view the write succeeded exactly as asked.
Why it happens
This is a structural gap between how the webservice is built and how the rest of PrestaShop reacts to change, not a misconfiguration on a single store. A few things make it easy to miss:
- The webservice resource controllers, including the one behind
stock_availables, callObjectModel::update()on the underlying object. That is a database save, and it is deliberately thin so the API stays fast and predictable, but it skips the hooks that the admin product save and order flows trigger around stock changes. - The back in stock alert feature reads the mail alert subscriber list and sends its emails from logic attached to a stock hook fired inside that thicker admin path, not from a database trigger on the
stock_availablestable itself. If that hook is never called, the module has no signal that anything changed. - Any custom module a developer wrote that listens for
actionUpdateQuantity, or a similar stock hook, to sync a warehouse system, recalculate a bundle, or notify a channel, is silently skipped in exactly the same way, and it usually surfaces first as a discrepancy or a missing notification somewhere downstream, not as an error near the write itself. - Because the HTTP response from the
PATCHorPUTreports success, and the quantity really is correct when you check it, the natural assumption is that everything worked. The gap only shows up once someone notices the alert or the downstream side effect never happened.
Integrators who sync stock from a warehouse system or a POS run into this constantly, usually phrased as "the number is right but the alert never went out" or "my module's hook stopped firing." See the citations at the end for the exact docs on the webservice architecture and the stock hooks it bypasses.
The webservice can change the number, but it cannot run PrestaShop's PHP hooks from outside the PHP process. There is no request parameter that makes a PATCH also fire actionUpdateQuantity. So the safe pattern is not to expect the API to notify anyone. It is to watch the number yourself, from outside, and only when you can prove a product went from zero or less to a positive quantity for a product that is actually active and visible, treat that as a genuine restock worth flagging or notifying about.
The fix, as a flow
We do not try to make the webservice call a hook it was never built to call. We add a job that keeps its own record of the last quantity it saw for each product, reads the current real quantity from stock_availables after any update, and compares the two. Only a true zero-or-below to positive transition on an active, visible product is treated as a restock. Everything else is left alone, and every genuine restock is reported so a human or a downstream system can act on it, standing in for the hook that never ran.
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 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.
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
Read the real quantity and the product's status
Call GET /api/stock_availables?output_format=JSON&filter[id_product]=<id>&filter[id_product_attribute]=0&display=full to get the current quantity for a product. Pair it with GET /api/products/{id}?output_format=JSON&display=full to read active and visibility, since a restock nobody could see does not deserve a notification.
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 current_quantity(id_product, id_product_attribute=0):
data = api_get("stock_availables", params={
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
"display": "full",
})
rows = data.get("stock_availables") or []
return int(rows[0]["quantity"]) if rows else None
def product_status(id_product):
data = api_get(f"products/{id_product}", params={"display": "full"})
product = data.get("product") or {}
return str(product.get("active", "0")) == "1", product.get("visibility", "both")
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 currentQuantity(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 || [];
return rows.length ? Number(rows[0].quantity) : null;
}
async function productStatus(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
const product = data.product || {};
return { isActive: String(product.active) === "1", visibility: product.visibility || "both" };
}
Keep your own record of the last quantity seen
Since PrestaShop never tells you a hook would have fired, you have to remember the previous quantity yourself, per product, in your own store. A simple key-value map keyed by id_product is enough. Load it before the run, save it after.
import json
def load_last_seen(path):
try:
with open(path) as f:
return {int(k): v for k, v in json.load(f).items()}
except FileNotFoundError:
return {}
def save_last_seen(path, last_seen):
with open(path, "w") as f:
json.dump(last_seen, f)
import { readFileSync, writeFileSync } from "node:fs";
function loadLastSeen(path) {
try {
const raw = JSON.parse(readFileSync(path, "utf8"));
return Object.fromEntries(Object.entries(raw).map(([k, v]) => [Number(k), v]));
} catch {
return {};
}
}
function saveLastSeen(path, lastSeen) {
writeFileSync(path, JSON.stringify(lastSeen));
}
Decide, with one pure function
Keep the decision in its own function that takes only primitive inputs, no I/O at all. It compares the previous quantity you stored to the current real quantity. It only calls something a genuine restock worth flagging when the product moved from zero or below to a positive number and the product is active and visible. Anything else, including quantity dropping, staying flat, or moving between two positive numbers, is not a restock signal and is ignored.
def decide_restock_alert(previous_quantity, current_quantity, is_active, visibility):
if previous_quantity is None:
return {"action": "record_only", "reason": "no prior quantity on file yet"}
if current_quantity is None:
return {"action": "record_only", "reason": "no stock_availables row to compare"}
became_positive = previous_quantity <= 0 and current_quantity > 0
if not became_positive:
return {"action": "record_only", "reason": "not a zero to positive transition"}
if not is_active or visibility == "none":
return {"action": "record_only", "reason": "product is inactive or not visible"}
return {"action": "flag_restock_alert", "reason": "active, visible product went from zero to positive stock"}
export function decideRestockAlert(previousQuantity, currentQuantity, isActive, visibility) {
if (previousQuantity == null) {
return { action: "record_only", reason: "no prior quantity on file yet" };
}
if (currentQuantity == null) {
return { action: "record_only", reason: "no stock_availables row to compare" };
}
const becamePositive = previousQuantity <= 0 && currentQuantity > 0;
if (!becamePositive) {
return { action: "record_only", reason: "not a zero to positive transition" };
}
if (!isActive || visibility === "none") {
return { action: "record_only", reason: "product is inactive or not visible" };
}
return { action: "flag_restock_alert", reason: "active, visible product went from zero to positive stock" };
}
Drive the notification the hook would have triggered
When the decision flags a genuine restock, this is where you stand in for PrestaShop's own back in stock alert module or your own custom hook listener. Send the notification through your own mailer, queue, or admin task list. This script never sends an email itself, since the exact wording, subscriber list, and unsubscribe handling belong to your notification system, but it hands that system the one fact it needs, the id_product that is now genuinely back in stock.
def notify_restock(id_product, current_quantity):
# Plug in your own mailer, queue, or task tracker here.
# This keeps the script honest about not owning your notification content.
log.info("Restock alert needed for product %s, quantity now %s.", id_product, current_quantity)
function notifyRestock(idProduct, currentQty) {
// Plug in your own mailer, queue, or task tracker here.
// This keeps the script honest about not owning your notification content.
console.log(`Restock alert needed for product ${idProduct}, quantity now ${currentQty}.`);
}
Wire it together with a dry run guard
The loop ties every piece together: load the last known quantities, read the current quantity and status for each product you track, run it through decide_restock_alert, log anything flagged, and always save the fresh quantities back to the file so the next run compares against the right baseline. With DRY_RUN on, the script only logs what it would notify. Run it right after your stock sync job, so it sees the same quantity your webservice write just produced.
Always start with DRY_RUN=true and read the log before wiring in a real mailer. If the last-seen file is ever lost or reset, the very next run will look like every current in-stock product just restocked, so the first decision on missing history is deliberately record_only, never an automatic flood of alerts.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, keeps its own memory of the last quantity per product, respects the dry run flag, and only ever flags a restock notification for a transition it can prove happened.
"""Detect restocks that a PrestaShop webservice write will never announce on its own.
A webservice PATCH or PUT to stock_availables updates the quantity through a plain ORM
save. It never calls the admin product controller or StockAvailable business logic that
core hooks like actionUpdateQuantity are wired to, so the back in stock alert module,
and any custom module listening on that hook, never runs. The number in the database is
correct; nothing downstream of the hook ever finds out.
This script keeps its own record of the last quantity seen per product, reads the real
current quantity from stock_availables after any update, and flags a genuine restock
notification only when an active, visible product moves from zero or below to a positive
quantity. It never sends the alert itself, it hands the id_product to your own mailer,
queue, or task tracker, since content and subscriber handling belong to your system.
Run right after your stock sync job. Safe to run again and again.
"""
import os
import json
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_restock_alerts")
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"
LAST_SEEN_PATH = os.environ.get("LAST_SEEN_PATH", "last_seen_quantities.json")
AUTH = (PRESTASHOP_WS_KEY, "")
def decide_restock_alert(previous_quantity, current_quantity, is_active, visibility):
"""Pure decision function, no I/O.
previous_quantity: the quantity this script last recorded for the product, or None
if this is the first time it has seen the product.
current_quantity: the real quantity read from stock_availables right now, or None
if no stock_availables row was found.
is_active, visibility: the product's active flag and visibility ("both"/"catalog"/
"search"/"none").
Returns a decision dict. The caller is responsible for driving any actual
notification; this function only ever decides whether one is warranted.
"""
if previous_quantity is None:
return {"action": "record_only", "reason": "no prior quantity on file yet"}
if current_quantity is None:
return {"action": "record_only", "reason": "no stock_availables row to compare"}
became_positive = previous_quantity <= 0 and current_quantity > 0
if not became_positive:
return {"action": "record_only", "reason": "not a zero to positive transition"}
if not is_active or visibility == "none":
return {"action": "record_only", "reason": "product is inactive or not visible"}
return {"action": "flag_restock_alert", "reason": "active, visible product went from zero to positive stock"}
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 current_quantity(id_product, id_product_attribute=0):
data = api_get("stock_availables", params={
"filter[id_product]": id_product,
"filter[id_product_attribute]": id_product_attribute,
"display": "full",
})
rows = data.get("stock_availables") or []
return int(rows[0]["quantity"]) if rows else None
def product_status(id_product):
data = api_get(f"products/{id_product}", params={"display": "full"})
product = data.get("product") or {}
return str(product.get("active", "0")) == "1", product.get("visibility", "both")
def load_last_seen(path):
try:
with open(path) as f:
return {int(k): v for k, v in json.load(f).items()}
except FileNotFoundError:
return {}
def save_last_seen(path, last_seen):
with open(path, "w") as f:
json.dump(last_seen, f)
def notify_restock(id_product, qty):
# Plug in your own mailer, queue, or task tracker here.
# This keeps the script honest about not owning your notification content.
log.info("Restock alert needed for product %s, quantity now %s.", id_product, qty)
def run(tracked_product_ids):
last_seen = load_last_seen(LAST_SEEN_PATH)
flagged = 0
for id_product in tracked_product_ids:
previous_quantity = last_seen.get(id_product)
quantity = current_quantity(id_product)
is_active, visibility = product_status(id_product)
decision = decide_restock_alert(previous_quantity, quantity, is_active, visibility)
if decision["action"] == "flag_restock_alert":
flagged += 1
log.warning("Product %s: %s", id_product, decision["reason"])
if not DRY_RUN:
notify_restock(id_product, quantity)
if quantity is not None:
last_seen[id_product] = quantity
save_last_seen(LAST_SEEN_PATH, last_seen)
log.info("Done. %d restock(s) %s.", flagged, "to notify" if DRY_RUN else "notified")
if __name__ == "__main__":
tracked = [int(x) for x in os.environ.get("TRACKED_PRODUCT_IDS", "").split(",") if x.strip()]
run(tracked)
/**
* Detect restocks that a PrestaShop webservice write will never announce on its own.
*
* A webservice PATCH or PUT to stock_availables updates the quantity through a plain ORM
* save. It never calls the admin product controller or StockAvailable business logic that
* core hooks like actionUpdateQuantity are wired to, so the back in stock alert module,
* and any custom module listening on that hook, never runs. The number in the database is
* correct; nothing downstream of the hook ever finds out.
*
* This script keeps its own record of the last quantity seen per product, reads the real
* current quantity from stock_availables after any update, and flags a genuine restock
* notification only when an active, visible product moves from zero or below to a positive
* quantity. It never sends the alert itself, it hands the id_product to your own mailer,
* queue, or task tracker, since content and subscriber handling belong to your system.
*
* Guide: https://www.allanninal.dev/prestashop/webservice-stock-update-skips-hooks-and-alerts/
*/
import { pathToFileURL } from "node:url";
import { readFileSync, writeFileSync } from "node:fs";
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";
const LAST_SEEN_PATH = process.env.LAST_SEEN_PATH || "last_seen_quantities.json";
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
/**
* Pure decision function, no I/O.
*
* previousQuantity: the quantity this script last recorded for the product, or null
* if this is the first time it has seen the product.
* currentQuantity: the real quantity read from stock_availables right now, or null
* if no stock_availables row was found.
* isActive, visibility: the product's active flag and visibility ("both"/"catalog"/
* "search"/"none").
*
* Returns a decision object. The caller is responsible for driving any actual
* notification; this function only ever decides whether one is warranted.
*/
export function decideRestockAlert(previousQuantity, currentQuantity, isActive, visibility) {
if (previousQuantity == null) {
return { action: "record_only", reason: "no prior quantity on file yet" };
}
if (currentQuantity == null) {
return { action: "record_only", reason: "no stock_availables row to compare" };
}
const becamePositive = previousQuantity <= 0 && currentQuantity > 0;
if (!becamePositive) {
return { action: "record_only", reason: "not a zero to positive transition" };
}
if (!isActive || visibility === "none") {
return { action: "record_only", reason: "product is inactive or not visible" };
}
return { action: "flag_restock_alert", reason: "active, visible product went from zero to positive stock" };
}
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 currentQuantity(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 || [];
return rows.length ? Number(rows[0].quantity) : null;
}
async function productStatus(idProduct) {
const data = await apiGet(`products/${idProduct}`, { display: "full" });
const product = data.product || {};
return { isActive: String(product.active) === "1", visibility: product.visibility || "both" };
}
function loadLastSeen(path) {
try {
const raw = JSON.parse(readFileSync(path, "utf8"));
return Object.fromEntries(Object.entries(raw).map(([k, v]) => [Number(k), v]));
} catch {
return {};
}
}
function saveLastSeen(path, lastSeen) {
writeFileSync(path, JSON.stringify(lastSeen));
}
function notifyRestock(idProduct, qty) {
// Plug in your own mailer, queue, or task tracker here.
// This keeps the script honest about not owning your notification content.
console.log(`Restock alert needed for product ${idProduct}, quantity now ${qty}.`);
}
export async function run(trackedProductIds) {
const lastSeen = loadLastSeen(LAST_SEEN_PATH);
let flagged = 0;
for (const idProduct of trackedProductIds) {
const previousQuantity = idProduct in lastSeen ? lastSeen[idProduct] : null;
const quantity = await currentQuantity(idProduct);
const { isActive, visibility } = await productStatus(idProduct);
const decision = decideRestockAlert(previousQuantity, quantity, isActive, visibility);
if (decision.action === "flag_restock_alert") {
flagged++;
console.warn(`Product ${idProduct}: ${decision.reason}`);
if (!DRY_RUN) notifyRestock(idProduct, quantity);
}
if (quantity !== null) lastSeen[idProduct] = quantity;
}
saveLastSeen(LAST_SEEN_PATH, lastSeen);
console.log(`Done. ${flagged} restock(s) ${DRY_RUN ? "to notify" : "notified"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const tracked = (process.env.TRACKED_PRODUCT_IDS || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.map(Number);
run(tracked).catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides whether a customer ever gets told a product came back. Because we kept decide_restock_alert pure, the test needs no network and no PrestaShop store. It just feeds in plain values and checks the answer.
from detect_restock_alerts import decide_restock_alert
def test_flags_when_zero_to_positive_on_active_visible_product():
result = decide_restock_alert(0, 5, True, "both")
assert result["action"] == "flag_restock_alert"
def test_flags_when_negative_to_positive_on_active_visible_product():
result = decide_restock_alert(-2, 3, True, "both")
assert result["action"] == "flag_restock_alert"
def test_record_only_when_no_prior_quantity():
result = decide_restock_alert(None, 5, True, "both")
assert result["action"] == "record_only"
assert "no prior quantity" in result["reason"]
def test_record_only_when_no_current_row():
result = decide_restock_alert(0, None, True, "both")
assert result["action"] == "record_only"
assert "no stock_availables row" in result["reason"]
def test_record_only_when_quantity_stays_positive():
result = decide_restock_alert(5, 8, True, "both")
assert result["action"] == "record_only"
def test_record_only_when_quantity_drops_to_zero():
result = decide_restock_alert(5, 0, True, "both")
assert result["action"] == "record_only"
def test_record_only_when_product_inactive():
result = decide_restock_alert(0, 5, False, "both")
assert result["action"] == "record_only"
def test_record_only_when_visibility_none():
result = decide_restock_alert(0, 5, True, "none")
assert result["action"] == "record_only"
def test_record_only_when_quantity_stays_at_or_below_zero():
result = decide_restock_alert(0, 0, True, "both")
assert result["action"] == "record_only"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideRestockAlert } from "./detect-restock-alerts.js";
test("flags when zero to positive on active visible product", () => {
const result = decideRestockAlert(0, 5, true, "both");
assert.equal(result.action, "flag_restock_alert");
});
test("flags when negative to positive on active visible product", () => {
const result = decideRestockAlert(-2, 3, true, "both");
assert.equal(result.action, "flag_restock_alert");
});
test("record only when no prior quantity", () => {
const result = decideRestockAlert(null, 5, true, "both");
assert.equal(result.action, "record_only");
assert.match(result.reason, /no prior quantity/);
});
test("record only when no current row", () => {
const result = decideRestockAlert(0, null, true, "both");
assert.equal(result.action, "record_only");
assert.match(result.reason, /no stock_availables row/);
});
test("record only when quantity stays positive", () => {
const result = decideRestockAlert(5, 8, true, "both");
assert.equal(result.action, "record_only");
});
test("record only when quantity drops to zero", () => {
const result = decideRestockAlert(5, 0, true, "both");
assert.equal(result.action, "record_only");
});
test("record only when product inactive", () => {
const result = decideRestockAlert(0, 5, false, "both");
assert.equal(result.action, "record_only");
});
test("record only when visibility none", () => {
const result = decideRestockAlert(0, 5, true, "none");
assert.equal(result.action, "record_only");
});
test("record only when quantity stays at or below zero", () => {
const result = decideRestockAlert(0, 0, true, "both");
assert.equal(result.action, "record_only");
});
Case studies
The nightly stock sync that went quiet on restocks
A store synced quantities from a warehouse management system to PrestaShop every night through the webservice. Customers who had signed up for back in stock alerts on popular items stopped getting emails entirely, even though the storefront correctly showed the item as available again the next morning.
The team had assumed the alert module watched the database. Once they understood it only reacts to a hook fired from the admin save path, they added this script right after the nightly sync, comparing quantities themselves and handing genuine restocks to their existing mailer. The emails started going out again, and the fix needed no changes to the sync job itself.
The custom module that silently stopped updating a bundle count
A developer had written a small module that recalculated a bundle's available quantity whenever a component product's stock changed, hooked to actionUpdateQuantity. It worked perfectly for admin restocks and broke silently the moment the store moved its point-of-sale stock updates to the webservice.
Rather than trying to make the webservice call a hook it was never designed to call, the team ran this same detection script against the component products and called the bundle recalculation function directly from the flagged restock, restoring the behavior without touching the webservice at all.
After this runs alongside your webservice stock sync, a genuine restock on an active, visible product always produces a notification, whether it is a customer's back in stock email or a custom module's own logic, even though the API call that changed the quantity never touched PrestaShop's hooks. The script only ever reports transitions it can prove from the numbers, so it never floods anyone with false restock alerts.
FAQ
Why do back in stock alert emails never send after I update quantity through the webservice?
The back in stock alert email is sent by a module listening for a core stock hook, usually when a product moves from zero to a positive quantity through the normal admin save path. A webservice PATCH or PUT to the stock_availables resource writes the new quantity straight to the database and never calls that code path, so the hook never runs and the module never sends anything, even though the number in the database is correct.
Can I make the webservice call the stock hooks directly?
No. The webservice resource controllers perform a plain ORM save on the object, they do not call the business logic in StockAvailable or the product controller that triggers hooks like actionUpdateQuantity. There is no parameter or header that turns this on. If you need the hook side effects, you have to reproduce them yourself outside the API call.
What is the safe way to trigger a back in stock alert from an external stock update?
Track the last known quantity for each product yourself, outside PrestaShop. After every webservice update, compare the new quantity to that stored value. Only when a product moves from zero or less to a positive number do you treat it as a genuine restock and flag it for a notification, and even then only if the product is active and visible, so you never fire an alert for a product nobody could have been waiting on.
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 fix your missing alerts?
If this saved you a pile of customers who never heard their item was back, 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