Reconciler
Order created via webservice with no current state set at all
An order comes in through the webservice, and it looks fine at a glance: a real id, a real reference, real order lines. But open it and current_state reads 0, or something that never actually happened, and the order_history timeline underneath is completely empty. Not stale, not mismatched, just missing from the start. Here is why POSTing to /api/orders can create an order that never really entered the state machine, and a script that finds every one of these and backfills it the safe way.
The authoritative way to change an order's status in PrestaShop is OrderHistory::changeIdOrderState(), which writes a row to order_history and updates the denormalized orders.current_state column together, while also firing emails and stock and invoice logic. The webservice orders resource, however, exposes current_state as a plain writable field on the order object itself, and a POST to /api/orders does not go through that service layer at all. Omit current_state or set it directly and PrestaShop stores the order with no matching order_history row, sometimes at current_state=0. Run a Python or Node.js script that lists orders with filter[current_state]=0, confirms each one is truly stateless with an empty GET /api/order_histories result, resolves a sane state from the order's own payment facts, and repairs it with a POST to order_histories, never a direct write to the order. Full code, tests, and citations are below.
The problem in plain words
In the PrestaShop back office, an order never changes status by itself. Something always calls OrderHistory::changeIdOrderState(), and that one call does three things at once: it inserts a row into order_history recording exactly what happened and when, it updates the fast-read current_state column on the order, and it fires whatever emails, stock adjustments, and invoice generation that state is supposed to trigger.
The webservice does not know about that contract. When it exposes the orders resource for reading and writing, current_state shows up as just another field on the order object, the same as reference or total_paid. A client building an order integration can reasonably assume that setting current_state on the order it POSTs is enough, the same way setting any other field is enough. It is not. The order gets created, but nothing runs the state machine, so order_history stays empty and current_state is whatever got stored, often 0, sometimes a value that was never actually applied through a real transition.
Why it happens
The webservice's orders resource is a generic CRUD mapping over the orders table's columns, not a wrapper around the order lifecycle. Documented and reported ways this gap shows up:
- A POST to
/api/orderswithcurrent_stateincluded in the body stores that value on the row, but never triggers the insert intoorder_historythat the back office always performs alongside it, as reported on the PrestaShop forums under "Create order via webservice won't set current state." - A POST to
/api/ordersthat omitscurrent_stateentirely leaves the order at whatever default the schema falls back to, commonly 0, with no history row explaining how it got there. - Integrators reasonably expect that setting the state at creation time should work the same way it does through the admin form, and are surprised to learn the webservice does not honor it, a question raised directly in "How set a state for an order at creation time?" on the PrestaShop forums.
- The inverse problem is also documented: updating an order through the webservice can add an unexpected new
order_historyrow rather than adjusting the existing state cleanly, tracked as GitHub issue #11154, which is more evidence that the webservice's order-state handling does not mirrorchangeIdOrderState()'s behavior.
Either way, the order exists in the database without ever entering the real state machine, so nothing about payment confirmation, stock reservation tied to a state, or transition emails ever ran for it. See the citations at the end for the exact threads and issues.
An order with no history at all is not a formatting problem, it is an order the state machine has never seen. You cannot fix that by writing a nicer value into current_state, because that repeats the same mistake that created the gap. The only correct fix is to give the order the state transition it was supposed to get in the first place, by POSTing to order_histories, the same call changeIdOrderState() makes internally, so the order finally gets a real history row and its current_state is refreshed as a side effect, not as a direct write.
The fix, as a flow
We never PUT current_state onto an order. We add a job that lists candidate orders at current_state=0, confirms each is truly stateless by checking that order_histories is empty for it, resolves a sane state from the order's own payment facts, and posts that resolution through order_histories, exactly the endpoint the back office uses.
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 read access to orders, order_histories, and order_states, plus write access to order_histories for the repair step. 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, only logs by default
// 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, only logs by default
List orders that look stateless
Call GET /api/orders?output_format=JSON&display=full&filter[current_state]=0&limit=200 to find candidates whose current_state reads 0. Treat this as a candidate list, not a final answer, since current_state can be denormalized and stale in other ways too.
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 candidate_orders():
data = api_get("orders", params={"display": "full", "filter[current_state]": "0", "limit": "200"})
return data.get("orders") or []
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 candidateOrders() {
const data = await apiGet("orders", { display: "full", "filter[current_state]": "0", limit: "200" });
return data.orders || [];
}
Confirm each candidate is truly stateless
For every candidate id, call GET /api/order_histories?output_format=JSON&display=full&filter[id_order]=[id]&limit=1. Only treat the order as confirmed stateless when this comes back empty. This matters because current_state alone can be misleading, an order can carry a stale or denormalized value while still having history rows behind it, which is a different problem with a different fix.
def is_stateless(id_order):
data = api_get("order_histories", params={
"display": "full",
"filter[id_order]": id_order,
"limit": "1",
})
rows = data.get("order_histories") or []
return len(rows) == 0
def order_states():
data = api_get("order_states", params={"display": "full"})
return data.get("order_states") or []
async function isStateless(idOrder) {
const data = await apiGet("order_histories", {
display: "full",
"filter[id_order]": idOrder,
limit: "1",
});
const rows = data.order_histories || [];
return rows.length === 0;
}
async function orderStates() {
const data = await apiGet("order_states", { display: "full" });
return data.order_states || [];
}
Decide, with one pure function
Keep the resolution in its own function that takes the order's plain fields and the list of available order states and returns the id to backfill, or None when it cannot decide safely. It never calls the network. If total_paid_real covers total_paid and there is a logable, non-hidden state that reads as a paid confirmation, use that. Otherwise fall back to the lowest-id logable, non-hidden state that reads as an awaiting-payment state. Anything ambiguous, such as no matching states at all, returns None so the order gets flagged for a human instead of guessed at.
PAID_HINTS = ("payment accepted", "paiement accepté", "paid")
AWAITING_HINTS = ("awaiting", "en attente")
def resolve_backfill_state(order, order_states):
if order.get("current_state") not in (0, None):
return None
def usable(s):
return str(s.get("logable", "0")) in ("1", "true", "True") and \
str(s.get("hidden", "0")) not in ("1", "true", "True")
candidates = [s for s in order_states if usable(s)]
if not candidates:
return None
total_paid = float(order.get("total_paid") or 0)
total_paid_real = float(order.get("total_paid_real") or 0)
if total_paid > 0 and total_paid_real >= total_paid:
paid_states = [s for s in candidates if any(h in str(s.get("name", "")).lower() for h in PAID_HINTS)]
if len(paid_states) == 1:
return int(paid_states[0]["id"])
return None
awaiting_states = [s for s in candidates if any(h in str(s.get("name", "")).lower() for h in AWAITING_HINTS)]
if not awaiting_states:
return None
return int(min(awaiting_states, key=lambda s: int(s["id"]))["id"])
const PAID_HINTS = ["payment accepted", "paiement accepté", "paid"];
const AWAITING_HINTS = ["awaiting", "en attente"];
function usable(s) {
const logable = String(s.logable ?? "0");
const hidden = String(s.hidden ?? "0");
return ["1", "true", "True"].includes(logable) && !["1", "true", "True"].includes(hidden);
}
export function resolveBackfillState(order, orderStates) {
if (order.current_state !== 0 && order.current_state != null) return null;
const candidates = orderStates.filter(usable);
if (candidates.length === 0) return null;
const totalPaid = Number(order.total_paid || 0);
const totalPaidReal = Number(order.total_paid_real || 0);
if (totalPaid > 0 && totalPaidReal >= totalPaid) {
const paidStates = candidates.filter((s) =>
PAID_HINTS.some((h) => String(s.name || "").toLowerCase().includes(h))
);
if (paidStates.length === 1) return Number(paidStates[0].id);
return null;
}
const awaitingStates = candidates.filter((s) =>
AWAITING_HINTS.some((h) => String(s.name || "").toLowerCase().includes(h))
);
if (awaitingStates.length === 0) return null;
return Number(awaitingStates.reduce((a, b) => (Number(b.id) < Number(a.id) ? b : a)).id);
}
Repair through order_histories, never the order directly
When a state resolves, POST a new resource to /api/order_histories with {"order_history": {"id_order": id, "id_order_state": resolved_state_id}}. This is the same call OrderHistory::changeIdOrderState() triggers internally, so it inserts the history row and refreshes current_state as a side effect, along with whatever emails and stock logic that state carries. Never PUT current_state straight onto /api/orders, that is the exact bug this fix exists for.
def backfill_via_history(id_order, resolved_state_id):
body = {"order_history": {"id_order": id_order, "id_order_state": resolved_state_id}}
r = requests.post(
f"{PRESTASHOP_URL}/api/order_histories",
params={"output_format": "JSON"},
json=body,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
async function backfillViaHistory(idOrder, resolvedStateId) {
const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
url.searchParams.set("output_format", "JSON");
const body = { order_history: { id_order: idOrder, id_order_state: resolvedStateId } };
const res = await fetch(url, {
method: "POST",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
return res.json();
}
Wire it together with a dry run guard
The loop ties every piece together: list candidates, confirm each is stateless, resolve a state from its payment facts and the shop's order states, and log the pair. DRY_RUN defaults to true, so the script only ever logs the (id_order, resolved_state_id) pairs it would create until you flip it off. Run it on a schedule that matches how often your integration creates orders, for example once an hour.
Always start with DRY_RUN=true. Never PUT current_state directly onto /api/orders, since that repeats the exact mistake this fix exists to correct. When resolve_backfill_state returns None, leave the order alone and let a human decide, rather than guessing at a state the order never actually reached.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, confirms every candidate is truly stateless before touching it, resolves a safe state from payment facts, respects the dry run flag, and only ever writes through order_histories.
"""Find and backfill PrestaShop orders created via webservice with no state at all.
OrderHistory::changeIdOrderState() is the only code path that both writes an
order_history row and updates the denormalized orders.current_state column, while
also firing the emails, stock, and invoice logic tied to that state. The webservice
orders resource exposes current_state as a plain writable field on the order object,
so a POST to /api/orders that omits it, or sets it directly, never runs the state
machine at all. The order is created with current_state at 0 (or an unapplied value)
and zero rows in order_history. This is documented on the PrestaShop forums under
"Create order via webservice won't set current state," and the reverse case, an
update adding an unexpected history row, is tracked as GitHub issue #11154.
This script only ever repairs through order_histories, the same call the back
office makes internally. It never writes current_state directly onto an order,
since that is the exact bug being fixed. Run on a schedule. Safe to run again
and again, because a repaired order will show up with a real history row on the
next pass and no longer match the stateless filter.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backfill_stateless_orders")
PRESTASHOP_URL = os.environ.get("PRESTASHOP_URL", "https://demo.example.com").rstrip("/")
PRESTASHOP_WS_KEY = os.environ.get("PRESTASHOP_WS_KEY", "WSKEYDUMMY")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")
PAID_HINTS = ("payment accepted", "paiement accepté", "paid")
AWAITING_HINTS = ("awaiting", "en attente")
def resolve_backfill_state(order, order_states):
"""Pure decision function, no I/O.
order: dict with id_order, current_state, total_paid, total_paid_real, payment, valid.
order_states: list of dicts, each with id, name, logable, hidden.
Returns the id_order_state to backfill, or None when no safe decision can be made.
"""
if order.get("current_state") not in (0, None):
return None
def usable(s):
return str(s.get("logable", "0")) in ("1", "true", "True") and \
str(s.get("hidden", "0")) not in ("1", "true", "True")
candidates = [s for s in order_states if usable(s)]
if not candidates:
return None
total_paid = float(order.get("total_paid") or 0)
total_paid_real = float(order.get("total_paid_real") or 0)
if total_paid > 0 and total_paid_real >= total_paid:
paid_states = [s for s in candidates if any(h in str(s.get("name", "")).lower() for h in PAID_HINTS)]
if len(paid_states) == 1:
return int(paid_states[0]["id"])
return None
awaiting_states = [s for s in candidates if any(h in str(s.get("name", "")).lower() for h in AWAITING_HINTS)]
if not awaiting_states:
return None
return int(min(awaiting_states, key=lambda s: int(s["id"]))["id"])
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{PRESTASHOP_URL}/api/{path}", params=params, auth=AUTH, timeout=30)
r.raise_for_status()
return r.json()
def candidate_orders():
data = api_get("orders", params={"display": "full", "filter[current_state]": "0", "limit": "200"})
return data.get("orders") or []
def is_stateless(id_order):
data = api_get("order_histories", params={
"display": "full",
"filter[id_order]": id_order,
"limit": "1",
})
rows = data.get("order_histories") or []
return len(rows) == 0
def order_states():
data = api_get("order_states", params={"display": "full"})
return data.get("order_states") or []
def backfill_via_history(id_order, resolved_state_id):
body = {"order_history": {"id_order": id_order, "id_order_state": resolved_state_id}}
r = requests.post(
f"{PRESTASHOP_URL}/api/order_histories",
params={"output_format": "JSON"},
json=body,
auth=AUTH,
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
states = order_states()
flagged = 0
repaired = 0
for order in candidate_orders():
id_order = order["id"]
if not is_stateless(id_order):
continue
flagged += 1
resolved = resolve_backfill_state(order, states)
if resolved is None:
log.warning("Order id_order=%s is stateless but could not be safely resolved. Flagging for review.", id_order)
continue
log.info("Order id_order=%s stateless. %s id_order_state=%s",
id_order, "would backfill to" if DRY_RUN else "backfilling to", resolved)
if not DRY_RUN:
backfill_via_history(id_order, resolved)
repaired += 1
log.info("Done. %d stateless order(s) found, %d repaired. DRY_RUN=%s", flagged, repaired, DRY_RUN)
if __name__ == "__main__":
run()
/**
* Find and backfill PrestaShop orders created via webservice with no state at all.
*
* OrderHistory::changeIdOrderState() is the only code path that both writes an
* order_history row and updates the denormalized orders.current_state column, while
* also firing the emails, stock, and invoice logic tied to that state. The webservice
* orders resource exposes current_state as a plain writable field on the order object,
* so a POST to /api/orders that omits it, or sets it directly, never runs the state
* machine at all. The order is created with current_state at 0 (or an unapplied value)
* and zero rows in order_history. This is documented on the PrestaShop forums under
* "Create order via webservice won't set current state," and the reverse case, an
* update adding an unexpected history row, is tracked as GitHub issue #11154.
*
* This script only ever repairs through order_histories, the same call the back
* office makes internally. It never writes current_state directly onto an order,
* since that is the exact bug being fixed. Run on a schedule. Safe to run again
* and again, because a repaired order will show up with a real history row on the
* next pass and no longer match the stateless filter.
*
* Guide: https://www.allanninal.dev/prestashop/webservice-order-created-without-state/
*/
import { pathToFileURL } from "node:url";
const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const PAID_HINTS = ["payment accepted", "paiement accepté", "paid"];
const AWAITING_HINTS = ["awaiting", "en attente"];
function basicAuthHeader() {
return "Basic " + Buffer.from(`${PRESTASHOP_WS_KEY}:`).toString("base64");
}
function usable(s) {
const logable = String(s.logable ?? "0");
const hidden = String(s.hidden ?? "0");
return ["1", "true", "True"].includes(logable) && !["1", "true", "True"].includes(hidden);
}
/**
* Pure decision function, no I/O.
*
* order: object with id_order, current_state, total_paid, total_paid_real, payment, valid.
* orderStates: array of objects, each with id, name, logable, hidden.
* Returns the id_order_state to backfill, or null when no safe decision can be made.
*/
export function resolveBackfillState(order, orderStates) {
if (order.current_state !== 0 && order.current_state != null) return null;
const candidates = orderStates.filter(usable);
if (candidates.length === 0) return null;
const totalPaid = Number(order.total_paid || 0);
const totalPaidReal = Number(order.total_paid_real || 0);
if (totalPaid > 0 && totalPaidReal >= totalPaid) {
const paidStates = candidates.filter((s) =>
PAID_HINTS.some((h) => String(s.name || "").toLowerCase().includes(h))
);
if (paidStates.length === 1) return Number(paidStates[0].id);
return null;
}
const awaitingStates = candidates.filter((s) =>
AWAITING_HINTS.some((h) => String(s.name || "").toLowerCase().includes(h))
);
if (awaitingStates.length === 0) return null;
return Number(awaitingStates.reduce((a, b) => (Number(b.id) < Number(a.id) ? b : a)).id);
}
async function apiGet(path, params = {}) {
const url = new URL(`${PRESTASHOP_URL}/api/${path}`);
url.searchParams.set("output_format", "JSON");
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: { Authorization: basicAuthHeader() } });
if (!res.ok) throw new Error(`PrestaShop ${res.status} on GET ${path}`);
return res.json();
}
async function candidateOrders() {
const data = await apiGet("orders", { display: "full", "filter[current_state]": "0", limit: "200" });
return data.orders || [];
}
async function isStateless(idOrder) {
const data = await apiGet("order_histories", {
display: "full",
"filter[id_order]": idOrder,
limit: "1",
});
const rows = data.order_histories || [];
return rows.length === 0;
}
async function orderStates() {
const data = await apiGet("order_states", { display: "full" });
return data.order_states || [];
}
async function backfillViaHistory(idOrder, resolvedStateId) {
const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
url.searchParams.set("output_format", "JSON");
const body = { order_history: { id_order: idOrder, id_order_state: resolvedStateId } };
const res = await fetch(url, {
method: "POST",
headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
return res.json();
}
export async function run() {
const states = await orderStates();
let flagged = 0;
let repaired = 0;
for (const order of await candidateOrders()) {
const idOrder = order.id;
if (!(await isStateless(idOrder))) continue;
flagged++;
const resolved = resolveBackfillState(order, states);
if (resolved === null) {
console.warn(`Order id_order=${idOrder} is stateless but could not be safely resolved. Flagging for review.`);
continue;
}
console.log(`Order id_order=${idOrder} stateless. ${DRY_RUN ? "would backfill to" : "backfilling to"} id_order_state=${resolved}`);
if (!DRY_RUN) {
await backfillViaHistory(idOrder, resolved);
repaired++;
}
}
console.log(`Done. ${flagged} stateless order(s) found, ${repaired} repaired. DRY_RUN=${DRY_RUN}`);
}
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 state gets written back to a real order. Because we kept resolve_backfill_state pure, the test needs no network and no PrestaShop store. It just feeds in plain dicts and lists and checks the answer.
from backfill_stateless_orders import resolve_backfill_state
STATES = [
{"id": "1", "name": "Awaiting check payment", "logable": "1", "hidden": "0"},
{"id": "2", "name": "Payment accepted", "logable": "1", "hidden": "0"},
{"id": "3", "name": "Awaiting bank wire payment", "logable": "1", "hidden": "0"},
{"id": "6", "name": "Canceled", "logable": "0", "hidden": "0"},
{"id": "7", "name": "Refunded", "logable": "0", "hidden": "1"},
]
def order(**over):
base = {"id_order": 42, "current_state": 0, "total_paid": 100.0, "total_paid_real": 0.0,
"payment": "Bank wire", "valid": False}
base.update(over)
return base
def test_returns_none_when_current_state_is_already_set():
assert resolve_backfill_state(order(current_state=2), STATES) is None
def test_resolves_paid_state_when_fully_paid():
assert resolve_backfill_state(order(total_paid_real=100.0), STATES) == 2
def test_resolves_paid_state_when_overpaid():
assert resolve_backfill_state(order(total_paid_real=105.0), STATES) == 2
def test_resolves_lowest_awaiting_state_when_unpaid():
assert resolve_backfill_state(order(), STATES) == 1
def test_returns_none_when_no_awaiting_states_exist():
no_awaiting = [s for s in STATES if "wire" not in s["name"].lower() and "check" not in s["name"].lower()]
assert resolve_backfill_state(order(), no_awaiting) is None
def test_returns_none_when_no_logable_states_exist():
hidden_only = [dict(s, logable="0") for s in STATES]
assert resolve_backfill_state(order(total_paid_real=100.0), hidden_only) is None
def test_returns_none_when_current_state_is_none():
assert resolve_backfill_state(order(current_state=0), []) is None
def test_zero_total_paid_falls_back_to_awaiting():
assert resolve_backfill_state(order(total_paid=0.0, total_paid_real=0.0), STATES) == 1
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveBackfillState } from "./backfill-stateless-orders.js";
const STATES = [
{ id: "1", name: "Awaiting check payment", logable: "1", hidden: "0" },
{ id: "2", name: "Payment accepted", logable: "1", hidden: "0" },
{ id: "3", name: "Awaiting bank wire payment", logable: "1", hidden: "0" },
{ id: "6", name: "Canceled", logable: "0", hidden: "0" },
{ id: "7", name: "Refunded", logable: "0", hidden: "1" },
];
const order = (over = {}) => ({
id_order: 42,
current_state: 0,
total_paid: 100.0,
total_paid_real: 0.0,
payment: "Bank wire",
valid: false,
...over,
});
test("returns null when current_state is already set", () => {
assert.equal(resolveBackfillState(order({ current_state: 2 }), STATES), null);
});
test("resolves paid state when fully paid", () => {
assert.equal(resolveBackfillState(order({ total_paid_real: 100.0 }), STATES), 2);
});
test("resolves paid state when overpaid", () => {
assert.equal(resolveBackfillState(order({ total_paid_real: 105.0 }), STATES), 2);
});
test("resolves lowest awaiting state when unpaid", () => {
assert.equal(resolveBackfillState(order(), STATES), 1);
});
test("returns null when no awaiting states exist", () => {
const noAwaiting = STATES.filter((s) => !/wire|check/i.test(s.name));
assert.equal(resolveBackfillState(order(), noAwaiting), null);
});
test("returns null when no logable states exist", () => {
const hiddenOnly = STATES.map((s) => ({ ...s, logable: "0" }));
assert.equal(resolveBackfillState(order({ total_paid_real: 100.0 }), hiddenOnly), null);
});
test("returns null when current_state is 0 and no states given", () => {
assert.equal(resolveBackfillState(order({ current_state: 0 }), []), null);
});
test("zero total paid falls back to awaiting", () => {
assert.equal(resolveBackfillState(order({ total_paid: 0.0, total_paid_real: 0.0 }), STATES), 1);
});
Case studies
The connector that only set current_state on the order
A marketplace integration pushed orders into PrestaShop through /api/orders, setting current_state in the same request body as everything else, the same way it worked on the platform it was ported from. Weeks later, finance noticed a batch of orders that back-office reports treated as unpaid even though the connector logs showed a state value had been sent.
Running the diagnostic against filter[current_state]=0 only caught part of it, since some of these orders had a non-zero value with still no history behind them, until the team also checked order_histories directly for every recent connector order. Once confirmed stateless, the resolver used each order's total_paid_real to backfill the correct state through order_histories, and the connector was fixed to POST a history record after creation instead of setting the field directly.
The bulk import that left current_state at 0
A migration script imported thousands of historical orders through the webservice, omitting current_state entirely because the source system did not track it the same way. Every imported order sat at current_state=0 with an empty history, and the back office's default order list quietly excluded them from the usual status filters, so nobody noticed until a customer asked about an order that "did not exist" in support's view.
The script ran in dry run first across the whole import batch, logging the resolved state for every order based on its recorded payment totals. The team reviewed the log, confirmed the payment-based resolution looked right, and ran it for real, giving every imported order the history entry the migration never wrote.
After this runs on a schedule, no order the webservice created can sit indefinitely with an empty history and a meaningless current_state. Every repair goes through order_histories, the same path the back office uses, so invoices, emails, and stock effects fire the way they would for any normal transition. Orders the resolver cannot confidently place are left alone for a human, so nothing gets a guessed state it never earned.
FAQ
Why does an order created through the PrestaShop webservice have no state at all?
The webservice orders resource exposes current_state as a plain writable field on the order object, but POSTing to /api/orders does not run the order through OrderHistory::changeIdOrderState(), which is the only code path that both sets current_state and writes the matching order_history row. A client that omits current_state or sets it directly gets an order stored with current_state at 0 or an unintended value and zero rows in order_history, because only a follow-up POST to order_histories actually triggers a real state transition.
Is it safe to fix these orders by setting current_state directly on the order?
No. PUTting current_state directly onto /api/orders is the exact bug being fixed, since it bypasses the invoice, email, and stock side effects that only run through OrderHistory::changeIdOrderState(). The safe repair is to POST a new resource to /api/order_histories with the order id and the resolved state id, the same call the back office makes internally, so the order gets a real history row and its current_state is refreshed as a side effect.
How do I detect orders that were created without a real state?
List orders with current_state=0 using GET /api/orders?filter[current_state]=0, then for each candidate call GET /api/order_histories?filter[id_order]=[id]&limit=1. If that returns an empty order_histories array, the order is confirmed stateless regardless of what current_state shows, since current_state can be stale or denormalized. Cross-check GET /api/order_states to resolve a sane default state to backfill.
Related field notes
Citations
On the problem:
- PrestaShop Forums: Create order via webservice won't set current state. forum.prestashop.com/topic/1023675-create-order-via-webservice-wont-set-current-state/
- PrestaShop Forums: How set a state for an order at creation time? forum.prestashop.com/topic/840953-how-set-a-state-for-an-order-at-creation-time/
- PrestaShop GitHub: Update order with Webservice adds new order's status line. Issue #11154. github.com/PrestaShop/PrestaShop/issues/11154
On the solution:
- PrestaShop Developer Documentation: Order histories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_histories/
- PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
- PrestaShop Developer Documentation: Order states webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_states/
Stuck on a tricky one?
If you have a problem in PrestaShop orders, order states, stock, 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 untangle a stateless order?
If this saved you a confusing support ticket or a manual audit of the order book, 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