Skip to content

Diagnostic Webservice API Data Sync

PATCH request to the stock endpoint is silently redirected and dropped

You PATCH a new quantity to /api/stock_availables/{id}, the server answers with a plain 200, and your integration marks the write a success. Then you look at the actual stock and it never moved. Nothing threw an error. Here is why PrestaShop quietly turns that PATCH into a GET before it reaches the stock handler, and a small script that catches the dropped write and repairs it the safe way.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A forklift loading a truck
Photo by metin erkut bayrak on Unsplash
The short answer

PrestaShop's webservice sits behind Apache .htaccess and mod_rewrite, and often a reverse proxy or CDN in front of that. A PATCH to /api/stock_availables/{id} that does not match the exact URL PrestaShop expects, for example a missing or extra trailing slash, can trigger a 301 or 302 redirect instead of reaching the resource. Per HTTP semantics, most clients replay a redirected non-idempotent request as a GET and drop the body, so your quantity update never arrives, and the server's 200 on the resulting GET gets mistaken for a successful write. This exact failure is confirmed against PrestaShop 8.0.4 in issue 37109. Run a script that reads the quantity before the write, issues the PATCH while watching for a redirect and a changed method, re-reads immediately after, and only if the write did not persist, falls back to a full PUT rather than retrying the same PATCH blindly. Full code, tests, and a dry run guard are below.

The problem in plain words

PrestaShop's webservice API is not a bare application listening on its own port. Requests first pass through Apache's .htaccess rewrite rules, and in most production setups, through a reverse proxy or CDN in front of that. Those layers exist to route /api/... paths to the right controller, but they are strict about the exact shape of the URL they expect.

When a PATCH request to /api/stock_availables/{id} does not match that exact shape, whether from a missing trailing slash, an extra one, or an intermediate proxy hop rewriting the path, the server answers with a 301 or 302 redirect rather than serving the resource directly. That would be harmless for a GET. But HTTP clients are only required to safely replay a redirect for GET and HEAD. For a PATCH, most clients, including common HTTP libraries in Python and Node, will follow that redirect by reissuing the request as a GET and quietly dropping the original body. The new quantity you sent never reaches WebserviceRequest, and the GET that actually runs returns a normal 200. Your integration sees status 200 and calls it done.

PATCH stock_availables new quantity in body 301/302 redirect Apache or proxy, URL mismatch client replays as GET, body dropped GET executes quantity never written Caller sees 200 assumes success the 200 is for a GET, not for the PATCH that was actually sent
The server never refuses the write. It quietly stops being a write at all once the client follows the redirect as a GET.

Why it happens

This is a routing and HTTP semantics problem, not a payload problem, and it recurs for a specific set of reasons:

This exact failure mode is confirmed in PrestaShop 8.0.4: a PATCH to /api/stock_availables/{id} intended to update quantity is automatically redirected, and the query ends up executed with the GET method instead, per issue 37109. It is compounded by older, independent reports of stock_availables PUT and PATCH writes silently not persisting. See the citations at the end for the exact reports.

The key insight

A 200 response is not proof of a write. The only way to know a PATCH actually changed stock is to read the quantity before, send the PATCH while watching whether a redirect happened and whether the method changed, and read the quantity again right after. If the pre and post values are the same and a redirect occurred, the write was dropped, not applied, no matter what status code came back.

The fix, as a flow

We do not retry the same PATCH blindly, because the problem is routing, not the payload, and resending an ambiguous request is not safe until you know the URL problem is understood. Instead the script reads the quantity, sends the PATCH, checks for a redirect and a changed method, re-reads to confirm, and only then, guarded by a dry run flag, falls back to a full PUT with the complete resource body against the exact URL the initial GET returned.

GET quantity before record pre_qty PATCH new quantity watch redirect, method GET quantity after record post_qty post_qty == attempted? yes no, dropped: fall back Full PUT fallback complete resource body re-verify with a third GET
The script never retries the same PATCH. It confirms the write first, and only repairs through a full PUT that PrestaShop supports unambiguously.

Build it step by step

1

Get a webservice key with read and write access

In the backoffice, go to Advanced Parameters, Webservice, and create a key with read and 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 quantity before the write

GET /api/stock_availables/{id} and record the current quantity. This is the baseline you compare against after the PATCH runs, and it is also where you capture the exact URL PrestaShop returns, so a later PUT fallback never trips the same trailing slash mismatch.

step2.py
import os, requests

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

def stock_available_url(id_stock_available):
    return f"{BASE_URL}/api/stock_availables/{id_stock_available}"

def read_quantity(id_stock_available):
    r = requests.get(
        stock_available_url(id_stock_available),
        params={"output_format": "JSON"},
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    row = r.json()["stock_available"]
    return int(row.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");
}

function stockAvailableUrl(idStockAvailable) {
  return `${BASE_URL}/api/stock_availables/${idStockAvailable}`;
}

async function readQuantity(idStockAvailable) {
  const url = new URL(stockAvailableUrl(idStockAvailable));
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, { headers: { Authorization: authHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  const body = await res.json();
  return Number(body.stock_available.quantity || 0);
}
3

Send the PATCH and watch for a redirect

Issue the PATCH with allow_redirects=False in Python, or by inspecting res.redirected in Node's fetch, so you can tell whether a 301 or 302 happened instead of silently following it. Record whether a redirect occurred and, if your client did follow one, what method actually ran.

step3.py
def patch_quantity(id_stock_available, new_qty):
    body = {"stock_available": {"id": id_stock_available, "quantity": str(new_qty)}}
    r = requests.patch(
        stock_available_url(id_stock_available),
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
        allow_redirects=False,
    )
    redirected = r.status_code in (301, 302, 303, 307, 308)
    final_method = "GET" if redirected else "PATCH"
    return {"status_code": r.status_code, "redirected": redirected, "final_method": final_method}
step3.js
async function patchQuantity(idStockAvailable, newQty) {
  const url = new URL(stockAvailableUrl(idStockAvailable));
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PATCH",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ stock_available: { id: idStockAvailable, quantity: String(newQty) } }),
  });
  const redirected = res.redirected || [301, 302, 303, 307, 308].includes(res.status);
  const finalMethod = redirected ? "GET" : "PATCH";
  return { statusCode: res.status, redirected, finalMethod };
}
4

Decide, with one pure function

Keep the verdict in its own function that takes the quantities and the redirect facts in, and returns one of four plain statuses out. It never talks to the network, so it is simple to test with plain values. It treats the write as dropped only when the post value did not move, and blames the redirect specifically when a redirect happened and the client ended up running a GET.

decide.py
def decide_write_status(pre_qty, attempted_qty, post_qty, redirected, final_method):
    if attempted_qty == pre_qty:
        return "no_op"
    if post_qty == attempted_qty:
        return "applied"
    if redirected and final_method.upper() == "GET":
        return "silently_dropped_redirect"
    return "silently_dropped_other"
decide.js
export function decideWriteStatus(preQty, attemptedQty, postQty, redirected, finalMethod) {
  if (attemptedQty === preQty) return "no_op";
  if (postQty === attemptedQty) return "applied";
  if (redirected && finalMethod.toUpperCase() === "GET") return "silently_dropped_redirect";
  return "silently_dropped_other";
}
5

Repair with a full PUT, never a repeated PATCH

When the verdict is silently_dropped_redirect, do not resend the same PATCH. The problem is the URL or the routing in front of it, not the payload, so retrying is not safe until you know that is fixed. Instead send a full PUT with the complete stock_available fields to the exact URL host and path the first GET returned, and re-verify with a third read.

apply.py
def put_fallback(row, new_qty):
    body = {
        "stock_available": {
            "id": row["id_stock_available"],
            "id_product": row["id_product"],
            "id_product_attribute": row["id_product_attribute"],
            "id_shop": row["id_shop"],
            "quantity": str(new_qty),
            "depends_on_stock": row["depends_on_stock"],
            "out_of_stock": row["out_of_stock"],
        }
    }
    r = requests.put(
        stock_available_url(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 putFallback(row, newQty) {
  const url = new URL(stockAvailableUrl(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,
        id_shop: row.id_shop,
        quantity: String(newQty),
        depends_on_stock: row.depends_on_stock,
        out_of_stock: row.out_of_stock,
      },
    }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}
6

Wire it together with a dry run guard

The run loop reads the quantity, sends the PATCH, re-reads, and runs the pure decision function. When the verdict is silently_dropped_redirect or silently_dropped_other, it only logs the record as flagged while DRY_RUN is true. Once you trust the list, switch DRY_RUN off so it performs the PUT fallback and re-verifies with a third read before marking the id resolved.

Run it safe

Always start with DRY_RUN=true. This script never resends the same PATCH. It only falls back to a full PUT once it has confirmed the earlier write did not persist, and it re-reads a third time before calling anything resolved.

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 repairs a write once it has proven, with a real read, that the write did not land.

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.
stock_patch_guard.py
"""Detect and repair PrestaShop PATCH writes to stock_availables that get silently dropped.

PrestaShop's webservice sits behind Apache mod_rewrite and often a reverse proxy or CDN.
A PATCH to /api/stock_availables/{id} that does not match the exact expected URL can
trigger a 301 or 302 redirect, and most HTTP clients replay that redirect as a GET and
drop the body. The server then returns 200 for a read, not your write, so the quantity
never actually changes even though nothing errored. This reads the quantity before the
write, sends the PATCH while watching for a redirect and a method change, re-reads right
after, and only falls back to a full PUT once a drop is confirmed. It never blindly
retries the same PATCH. 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("stock_patch_guard")

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

REDIRECT_CODES = (301, 302, 303, 307, 308)


def stock_available_url(id_stock_available):
    return f"{BASE_URL}/api/stock_availables/{id_stock_available}"


def read_stock_available(id_stock_available):
    r = requests.get(
        stock_available_url(id_stock_available),
        params={"output_format": "JSON"},
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    row = r.json()["stock_available"]
    return {
        "id_stock_available": int(row["id"]),
        "id_product": int(row.get("id_product") or 0),
        "id_product_attribute": int(row.get("id_product_attribute") or 0),
        "id_shop": int(row.get("id_shop") or 1),
        "quantity": int(row.get("quantity") or 0),
        "depends_on_stock": int(row.get("depends_on_stock") or 0),
        "out_of_stock": int(row.get("out_of_stock") or 0),
    }


def patch_quantity(id_stock_available, new_qty):
    body = {"stock_available": {"id": id_stock_available, "quantity": str(new_qty)}}
    r = requests.patch(
        stock_available_url(id_stock_available),
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
        allow_redirects=False,
    )
    redirected = r.status_code in REDIRECT_CODES
    final_method = "GET" if redirected else "PATCH"
    return {"status_code": r.status_code, "redirected": redirected, "final_method": final_method}


def decide_write_status(pre_qty, attempted_qty, post_qty, redirected, final_method):
    if attempted_qty == pre_qty:
        return "no_op"
    if post_qty == attempted_qty:
        return "applied"
    if redirected and final_method.upper() == "GET":
        return "silently_dropped_redirect"
    return "silently_dropped_other"


def put_fallback(row, new_qty):
    body = {
        "stock_available": {
            "id": row["id_stock_available"],
            "id_product": row["id_product"],
            "id_product_attribute": row["id_product_attribute"],
            "id_shop": row["id_shop"],
            "quantity": str(new_qty),
            "depends_on_stock": row["depends_on_stock"],
            "out_of_stock": row["out_of_stock"],
        }
    }
    r = requests.put(
        stock_available_url(row["id_stock_available"]),
        params={"output_format": "JSON"},
        json=body,
        auth=(WS_KEY, ""),
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def guard_write(id_stock_available, new_qty):
    pre_row = read_stock_available(id_stock_available)
    pre_qty = pre_row["quantity"]

    patch_result = patch_quantity(id_stock_available, new_qty)
    post_row = read_stock_available(id_stock_available)
    post_qty = post_row["quantity"]

    status = decide_write_status(
        pre_qty, new_qty, post_qty, patch_result["redirected"], patch_result["final_method"]
    )

    record = {
        "id_stock_available": id_stock_available,
        "id_product": pre_row["id_product"],
        "id_product_attribute": pre_row["id_product_attribute"],
        "id_shop": pre_row["id_shop"],
        "old_qty": pre_qty,
        "attempted_new_qty": new_qty,
        "status": status,
    }

    if status in ("applied", "no_op"):
        log.info("Stock %s: %s (qty %s to %s)", id_stock_available, status, pre_qty, post_qty)
        return record

    log.warning(
        "Stock %s: PATCH %s (qty stayed %s, wanted %s). %s",
        id_stock_available, status, post_qty, new_qty,
        "flagged, needs manual PUT retry" if DRY_RUN else "falling back to PUT",
    )

    if not DRY_RUN:
        put_fallback(post_row, new_qty)
        verify_row = read_stock_available(id_stock_available)
        record["status"] = "applied" if verify_row["quantity"] == new_qty else "still_dropped"
        record["post_qty"] = verify_row["quantity"]

    return record


def run(writes):
    applied = 0
    flagged = 0
    for id_stock_available, new_qty in writes:
        record = guard_write(id_stock_available, new_qty)
        if record["status"] == "applied":
            applied += 1
        elif record["status"] in ("silently_dropped_redirect", "silently_dropped_other", "still_dropped"):
            flagged += 1
    log.info(
        "Done. %d write(s) applied, %d %s.",
        applied, flagged, "flagged: PATCH silently dropped, needs manual PUT retry" if DRY_RUN else "still needing review",
    )


if __name__ == "__main__":
    # Example: guard_write a single stock_available id against a target quantity.
    # Replace with your own source of (id_stock_available, new_qty) pairs.
    run([])
stock-patch-guard.js
/**
 * Detect and repair PrestaShop PATCH writes to stock_availables that get silently dropped.
 *
 * PrestaShop's webservice sits behind Apache mod_rewrite and often a reverse proxy or CDN.
 * A PATCH to /api/stock_availables/{id} that does not match the exact expected URL can
 * trigger a 301 or 302 redirect, and most HTTP clients replay that redirect as a GET and
 * drop the body. The server then returns 200 for a read, not your write, so the quantity
 * never actually changes even though nothing errored. This reads the quantity before the
 * write, sends the PATCH while watching for a redirect and a method change, re-reads right
 * after, and only falls back to a full PUT once a drop is confirmed. It never blindly
 * retries the same PATCH. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/stock-patch-silently-dropped/
 */
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";

const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);

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

function stockAvailableUrl(idStockAvailable) {
  return `${BASE_URL}/api/stock_availables/${idStockAvailable}`;
}

async function readStockAvailable(idStockAvailable) {
  const url = new URL(stockAvailableUrl(idStockAvailable));
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, { headers: { Authorization: authHeader() } });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  const body = await res.json();
  const row = body.stock_available;
  return {
    id_stock_available: Number(row.id),
    id_product: Number(row.id_product || 0),
    id_product_attribute: Number(row.id_product_attribute || 0),
    id_shop: Number(row.id_shop || 1),
    quantity: Number(row.quantity || 0),
    depends_on_stock: Number(row.depends_on_stock || 0),
    out_of_stock: Number(row.out_of_stock || 0),
  };
}

async function patchQuantity(idStockAvailable, newQty) {
  const url = new URL(stockAvailableUrl(idStockAvailable));
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PATCH",
    redirect: "manual",
    headers: { Authorization: authHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ stock_available: { id: idStockAvailable, quantity: String(newQty) } }),
  });
  const redirected = res.type === "opaqueredirect" || REDIRECT_CODES.has(res.status);
  const finalMethod = redirected ? "GET" : "PATCH";
  return { statusCode: res.status, redirected, finalMethod };
}

export function decideWriteStatus(preQty, attemptedQty, postQty, redirected, finalMethod) {
  if (attemptedQty === preQty) return "no_op";
  if (postQty === attemptedQty) return "applied";
  if (redirected && finalMethod.toUpperCase() === "GET") return "silently_dropped_redirect";
  return "silently_dropped_other";
}

async function putFallback(row, newQty) {
  const url = new URL(stockAvailableUrl(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,
        id_shop: row.id_shop,
        quantity: String(newQty),
        depends_on_stock: row.depends_on_stock,
        out_of_stock: row.out_of_stock,
      },
    }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
  return res.json();
}

async function guardWrite(idStockAvailable, newQty) {
  const preRow = await readStockAvailable(idStockAvailable);
  const preQty = preRow.quantity;

  const patchResult = await patchQuantity(idStockAvailable, newQty);
  const postRow = await readStockAvailable(idStockAvailable);
  const postQty = postRow.quantity;

  const status = decideWriteStatus(preQty, newQty, postQty, patchResult.redirected, patchResult.finalMethod);

  const record = {
    id_stock_available: idStockAvailable,
    id_product: preRow.id_product,
    id_product_attribute: preRow.id_product_attribute,
    id_shop: preRow.id_shop,
    old_qty: preQty,
    attempted_new_qty: newQty,
    status,
  };

  if (status === "applied" || status === "no_op") {
    console.log(`Stock ${idStockAvailable}: ${status} (qty ${preQty} to ${postQty})`);
    return record;
  }

  console.warn(
    `Stock ${idStockAvailable}: PATCH ${status} (qty stayed ${postQty}, wanted ${newQty}). ${DRY_RUN ? "flagged, needs manual PUT retry" : "falling back to PUT"}`
  );

  if (!DRY_RUN) {
    await putFallback(postRow, newQty);
    const verifyRow = await readStockAvailable(idStockAvailable);
    record.status = verifyRow.quantity === newQty ? "applied" : "still_dropped";
    record.post_qty = verifyRow.quantity;
  }

  return record;
}

export async function run(writes) {
  let applied = 0;
  let flagged = 0;
  for (const [idStockAvailable, newQty] of writes) {
    const record = await guardWrite(idStockAvailable, newQty);
    if (record.status === "applied") applied++;
    else if (["silently_dropped_redirect", "silently_dropped_other", "still_dropped"].includes(record.status)) flagged++;
  }
  console.log(
    `Done. ${applied} write(s) applied, ${flagged} ${DRY_RUN ? "flagged: PATCH silently dropped, needs manual PUT retry" : "still needing review"}.`
  );
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  // Example: guardWrite a single stock_available id against a target quantity.
  // Replace with your own source of [idStockAvailable, newQty] pairs.
  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 a write is treated as applied, a no-op, or silently dropped by a redirect. Because decide_write_status is pure, the test needs no PrestaShop instance and no network. It just feeds in plain values and checks the answer.

test_stock_write_status.py
from stock_patch_guard import decide_write_status


def test_applied_when_post_matches_attempted():
    assert decide_write_status(10, 25, 25, False, "PATCH") == "applied"


def test_no_op_when_attempted_equals_pre():
    assert decide_write_status(10, 10, 10, False, "PATCH") == "no_op"


def test_silently_dropped_redirect_when_redirected_and_final_get():
    assert decide_write_status(10, 25, 10, True, "GET") == "silently_dropped_redirect"


def test_silently_dropped_other_when_not_redirected_but_unchanged():
    assert decide_write_status(10, 25, 10, False, "PATCH") == "silently_dropped_other"


def test_silently_dropped_other_when_redirected_but_final_method_not_get():
    assert decide_write_status(10, 25, 10, True, "PATCH") == "silently_dropped_other"


def test_applied_takes_priority_over_redirected_flag():
    assert decide_write_status(10, 25, 25, True, "GET") == "applied"


def test_no_op_takes_priority_even_if_redirected():
    assert decide_write_status(10, 10, 10, True, "GET") == "no_op"
stock-write-status.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideWriteStatus } from "./stock-patch-guard.js";

test("applied when post matches attempted", () => {
  assert.equal(decideWriteStatus(10, 25, 25, false, "PATCH"), "applied");
});

test("no_op when attempted equals pre", () => {
  assert.equal(decideWriteStatus(10, 10, 10, false, "PATCH"), "no_op");
});

test("silently_dropped_redirect when redirected and final GET", () => {
  assert.equal(decideWriteStatus(10, 25, 10, true, "GET"), "silently_dropped_redirect");
});

test("silently_dropped_other when not redirected but unchanged", () => {
  assert.equal(decideWriteStatus(10, 25, 10, false, "PATCH"), "silently_dropped_other");
});

test("silently_dropped_other when redirected but final method not GET", () => {
  assert.equal(decideWriteStatus(10, 25, 10, true, "PATCH"), "silently_dropped_other");
});

test("applied takes priority over redirected flag", () => {
  assert.equal(decideWriteStatus(10, 25, 25, true, "GET"), "applied");
});

test("no_op takes priority even if redirected", () => {
  assert.equal(decideWriteStatus(10, 10, 10, true, "GET"), "no_op");
});

Case studies

Warehouse sync

The integration that trusted every 200

A warehouse management system PATCHed stock levels into PrestaShop every fifteen minutes and logged the run as clean because every request came back 200. Weeks later, a bestselling SKU sold out in the warehouse while PrestaShop kept advertising forty units, because the trailing slash the integration used never matched the rewrite rule and every PATCH quietly became a GET.

Adding the read-before, read-after check surfaced the mismatch on the very first run. Switching the write path to the full PUT fallback fixed the SKU immediately, and the same guard kept catching any store whose proxy still had the old rewrite in front of it.

CDN in front of the shop

The store behind a CDN that rewrote the path

A mid-size store put a CDN in front of PrestaShop for caching and DDoS protection. The CDN's own routing rules normalized the API path in a way that triggered a 302 on every PATCH to stock_availables, so a nightly reconciliation job had been quietly failing to correct drift for months, with no errors anywhere in its logs.

The team ran the guard in dry run first and saw every single stock write flagged as silently_dropped_redirect. That pointed straight at the CDN config rather than the reconciliation code. They fixed the CDN's rewrite rule for the exact API path, and the guard's PUT fallback cleared the existing backlog safely in the meantime.

What good looks like

After this runs, a 200 status code stops being trusted on its own. Every stock write is checked against a real before-and-after read, redirects and method changes are visible instead of silent, and a confirmed drop is repaired through the one write path PrestaShop supports unambiguously, a full PUT, rather than a blind retry of the same PATCH. The routing problem behind the redirect is still worth fixing at its source, but stock stays correct while you get to it.

FAQ

Why does my PATCH to stock_availables return 200 but the quantity never changes?

PrestaShop's webservice sits behind Apache mod_rewrite and often a reverse proxy or CDN. A PATCH that does not match the exact expected URL, such as a missing or extra trailing slash, can trigger a 301 or 302 redirect. Most HTTP clients then replay that redirect as a GET and drop the request body, so the server returns 200 for a read, not your write, and the quantity never actually changes.

Is this a bug in my code or in PrestaShop?

It is a known routing and PATCH-support gap in PrestaShop's webservice layer, confirmed in issue 37109 against PrestaShop 8.0.4 and compounded by older PATCH handling issues. It is not something you fix by changing your JSON body, it is a redirect that silently changes your request method before it ever reaches the stock handler.

Should I just keep retrying the PATCH until it works?

No. Retrying blindly does not fix a routing problem and can mask it further. The safe pattern is to detect the redirect and the unchanged quantity, then fall back to a full PUT with the complete stock_available fields, which PrestaShop supports unambiguously, and verify the write with a third read before marking it resolved.

Related field notes

Citations

On the problem:

  1. Updating available quantities of a product, method PATCH. github.com/PrestaShop/PrestaShop/issues/37109
  2. Trying to update stock via webservice. github.com/PrestaShop/PrestaShop/issues/17857
  3. Bug Web Service Api Prestashop when create or update the quantity of a product. github.com/PrestaShop/PrestaShop/issues/24520

On the solution:

  1. PrestaShop Developer Documentation: Stock availables resource reference. devdocs.prestashop-project.org/8/webservice/resources/stock_availables
  2. PrestaShop Developer Documentation: Getting started with the webservice. devdocs.prestashop-project.org/8/webservice/getting-started
  3. PrestaShop Developer Documentation: The Webservice API reference. devdocs.prestashop-project.org/8/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 catch a dropped write?

If this saved you a confusing "it returned 200 but nothing changed" ticket, 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