Skip to content

Reconciler

Order history record is missing for the order's current state

Open an order in PrestaShop and its current_state column says one thing, while the order_history timeline underneath tells a different story, or no story at all. PrestaShop keeps these two records in sync by convention, not by a database constraint, so a crash, a module that writes the state directly, or a broken upgrade can leave an order pointing at a state that never got its own history row. Here is why that gap opens up and a script that finds every order where the two disagree and reports it for a human to review.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
Handing over a delivery box
Photo by RoseBox on Unsplash
The short answer

PrestaShop stores an order's status twice: the denormalized orders.current_state column, and the append-only order_history (ps_order_history) audit trail that is supposed to gain a row every time the state changes. Nothing in the database enforces that they agree. When OrderHistory::changeIdOrderState() or addWithemail() is interrupted, a crash mid-order-creation, a module or webservice call that writes current_state directly, or a broken insert like the id_employee mismatch seen after the 8.1.0 upgrade, the order ends up pointing at a state with no matching history row. Run a Python or Node.js script that pulls every order's current_state with GET /api/orders, pulls its history rows with GET /api/order_histories, and flags any order where history is empty or the latest row's id_order_state does not match. Full code, tests, and citations are below.

The problem in plain words

Every PrestaShop order carries a field called current_state, right there on the orders table. It is the fast answer to "what state is this order in right now," and the back office, the front office, and most reports read it directly.

Next to it sits a second, separate record: the order_history table, an append-only log meant to capture every state transition the order ever went through, in order, with a timestamp and the employee who made the change. In a healthy order, the newest row in that log always points at the same state as current_state. But nothing in the schema forces that to be true. The column and the log are kept in sync by convention, by the code always calling OrderHistory::changeIdOrderState() whenever the state changes, and convention breaks under real-world conditions: a crash, a direct write, a bad upgrade. When it breaks, the order sits at a state that its own history never recorded.

State change begins changeIdOrderState() current_state updated orders table, right away Interrupted crash, direct write, bad insert order_history row never written ps_order_history stays behind current_state points at a state that has no matching order_history row
current_state and order_history are meant to move together. When the process that updates both is interrupted, current_state moves on and order_history is left behind.

Why it happens

The column and the log are two representations of the same fact, updated by two different writes that are only supposed to happen together, never guaranteed to happen together. Documented and reported ways the gap opens up:

Any one of these leaves an order whose front-office and back-office history views do not reconcile with the actual current_state, which is confusing for support staff trying to explain to a customer why the order is in the state it is in, and confusing for anyone building reports off the history table. See the citations at the end for the exact issues and docs.

The key insight

A missing or mismatched history row is not something you can safely reconstruct with full confidence. You do not actually know who changed the state, or exactly when, only that it happened. So the safe pattern is not "backfill every gap automatically." It is "flag every gap for a human," and only insert a synthetic order_history row after someone explicitly confirms it, tagged as a system-generated backfill so it is never confused with a real, attributed state change.

The fix, as a flow

We do not touch orders.current_state directly, ever. We add a job that walks every order, reads its current state and its history rows, and runs them through one pure check. Anything that fails the check becomes a report row for staff, and only a confirmed, explicit repair posts a synthetic history row that mirrors what the normal code path would have inserted.

List orders GET orders, current_state Read history rows GET order_histories needs_history_backfill empty history, or latest state mismatch Affected? yes no, move on Report for staff Only after explicit confirmation: POST order_histories, id_employee=0
The job only ever reads and reports by default. A synthetic order_history row is only posted after DRY_RUN is off and an operator explicitly confirms the repair, and current_state itself is never edited directly.

Build it step by step

1

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 if you plan to run repairs. 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, only reports by default
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, only reports by default
2

List every order and its current_state

Call GET /api/orders?display=[id,current_state,reference]&output_format=JSON&limit=0 to pull every order's id, reference, and current state. Paginate as needed for large stores by adding limit=offset,count and looping until you get an empty page.

step2.py
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 all_orders():
    data = api_get("orders", params={"display": "[id,current_state,reference]", "limit": "0"})
    return data.get("orders") or []
step2.js
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 allOrders() {
  const data = await apiGet("orders", { display: "[id,current_state,reference]", limit: "0" });
  return data.orders || [];
}
3

Read each order's history rows

For every order, call GET /api/order_histories?filter[id_order]=[ORDER_ID]&display=[id,id_order,id_order_state,date_add]&sort=id_DESC&output_format=JSON to get its history, newest first. Cross-check the state ids you see against GET /api/order_states?output_format=JSON so a deleted, orphaned state does not get treated as a fresh problem.

step3.py
def order_history_rows(id_order):
    data = api_get("order_histories", params={
        "filter[id_order]": id_order,
        "display": "[id,id_order,id_order_state,date_add]",
        "sort": "id_DESC",
    })
    return data.get("order_histories") or []

def valid_order_state_ids():
    data = api_get("order_states", params={})
    states = data.get("order_states") or []
    return {int(s["id"]) for s in states}
step3.js
async function orderHistoryRows(idOrder) {
  const data = await apiGet("order_histories", {
    "filter[id_order]": idOrder,
    display: "[id,id_order,id_order_state,date_add]",
    sort: "id_DESC",
  });
  return data.order_histories || [];
}

async function validOrderStateIds() {
  const data = await apiGet("order_states", {});
  const states = data.order_states || [];
  return new Set(states.map((s) => Number(s.id)));
}
4

Decide, with one pure function

Keep the comparison in its own function that takes current_state and the order's history rows and returns a plain result, nothing else. It sorts by date_add descending internally so the caller can pass the rows in any order. If history is empty, that is a no_history case. If the latest row's state does not match current_state, that is a state_mismatch case. Otherwise the order is consistent and the function returns nothing. No network calls happen inside it, which is what makes it easy to test on its own.

decide.py
def needs_history_backfill(current_state, history_states):
    if not history_states:
        return {"reason": "no_history", "expected_state": current_state}
    latest = max(history_states, key=lambda row: row[1])
    if latest[0] != current_state:
        return {
            "reason": "state_mismatch",
            "expected_state": current_state,
            "last_recorded_state": latest[0],
            "last_recorded_date": latest[1],
        }
    return None
decide.js
export function needsHistoryBackfill(currentState, historyStates) {
  if (!historyStates || historyStates.length === 0) {
    return { reason: "no_history", expected_state: currentState };
  }
  const latest = historyStates.reduce((a, b) => (b[1] > a[1] ? b : a));
  if (latest[0] !== currentState) {
    return {
      reason: "state_mismatch",
      expected_state: currentState,
      last_recorded_state: latest[0],
      last_recorded_date: latest[1],
    };
  }
  return null;
}
5

Report by default, repair only on explicit confirmation

When an order is affected, the script always logs a report row with id_order, reference, current_state, last_history_state, and last_history_date. It never edits orders.current_state. Only when DRY_RUN=false and the operator has explicitly confirmed the repair does it POST a synthetic order_history row, mirroring what OrderHistory::addWithemail() would have inserted, tagged with id_employee=0 to mark it as a system-generated backfill.

repair.py
def backfill_order_history(id_order, expected_state):
    body = {"order_history": {"id_order": id_order, "id_order_state": expected_state, "id_employee": 0}}
    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()
repair.js
async function backfillOrderHistory(idOrder, expectedState) {
  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: expectedState, id_employee: 0 } };
  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();
}
6

Wire it together with a dry run guard

The loop ties every piece together: list every order, read its history, run both through needs_history_backfill, and log a report row for anything affected. DRY_RUN defaults to true, so the script only ever reports unless you flip it off and pass an explicit confirmation flag for the repair step. Run it on a schedule that matches how often you want fresh eyes on the order book, for example once a day.

Run it safe

Always start with DRY_RUN=true. Never edit orders.current_state directly, since that column is only supposed to change as a side effect of an order_histories insert. Treat every report row as a lead for staff to confirm, not a queue to auto-repair, because a state-history backfill risks the wrong id_employee or date_add and can create duplicate-looking consecutive states.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, walks every order and its history, reports every mismatch, respects the dry run flag, and only ever writes a synthetic history row when explicitly told to.

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.
check_order_history.py
"""Detect PrestaShop orders whose order_history does not match their current_state.

PrestaShop keeps two representations of an order's status in sync by convention, not
by a database constraint: the denormalized orders.current_state column, and the
append-only order_history (ps_order_history) audit trail that is supposed to gain a
new row every time the state changes. When OrderHistory::changeIdOrderState() or
addWithemail() is interrupted, a crash during order creation, a module or webservice
call that writes current_state directly, or a broken insert like the id_employee
mismatch seen after the 8.1.0 upgrade (GitHub #33238), the order ends up pointing at a
state that has no matching history record. Related reports (#21502, #27967) show this
happening intermittently on payment-confirmation transitions and after upgrades.

This script flags affected orders by default. It never edits orders.current_state
directly, since that column must only change as a side effect of an order_histories
insert. A confirmed repair posts a synthetic order_history row tagged id_employee=0,
mirroring what OrderHistory::addWithemail() would have inserted, only when DRY_RUN is
false and the operator has explicitly confirmed it.

Run on a schedule. 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("check_order_history")

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"
CONFIRM_REPAIR = os.environ.get("CONFIRM_REPAIR", "false").lower() == "true"
AUTH = (PRESTASHOP_WS_KEY, "")


def needs_history_backfill(current_state, history_states):
    """Pure decision function, no I/O.

    history_states is a list of (id_order_state, date_add) tuples, in any order; the
    function sorts by date_add descending internally. Returns a dict describing the
    problem, or None when the order's history already matches current_state.
    """
    if not history_states:
        return {"reason": "no_history", "expected_state": current_state}
    latest = max(history_states, key=lambda row: row[1])
    if latest[0] != current_state:
        return {
            "reason": "state_mismatch",
            "expected_state": current_state,
            "last_recorded_state": latest[0],
            "last_recorded_date": latest[1],
        }
    return None


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 all_orders():
    data = api_get("orders", params={"display": "[id,current_state,reference]", "limit": "0"})
    return data.get("orders") or []


def order_history_rows(id_order):
    data = api_get("order_histories", params={
        "filter[id_order]": id_order,
        "display": "[id,id_order,id_order_state,date_add]",
        "sort": "id_DESC",
    })
    return data.get("order_histories") or []


def valid_order_state_ids():
    data = api_get("order_states", params={})
    states = data.get("order_states") or []
    return {int(s["id"]) for s in states}


def backfill_order_history(id_order, expected_state):
    body = {"order_history": {"id_order": id_order, "id_order_state": expected_state, "id_employee": 0}}
    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():
    valid_states = valid_order_state_ids()
    flagged = 0
    repaired = 0
    for order in all_orders():
        id_order = order["id"]
        current_state = int(order["current_state"])
        rows = order_history_rows(id_order)
        history_states = [(int(row["id_order_state"]), row["date_add"]) for row in rows]
        problem = needs_history_backfill(current_state, history_states)
        if problem is None:
            continue
        flagged += 1
        orphaned_note = ""
        if problem["reason"] == "state_mismatch" and problem["last_recorded_state"] not in valid_states:
            orphaned_note = " (last recorded state id is orphaned, no longer a valid order_state)"
        log.warning(
            "Order needs history backfill. id_order=%s reference=%s current_state=%s reason=%s "
            "last_history_state=%s last_history_date=%s%s",
            id_order, order.get("reference"), current_state, problem["reason"],
            problem.get("last_recorded_state"), problem.get("last_recorded_date"), orphaned_note,
        )
        if not DRY_RUN and CONFIRM_REPAIR:
            backfill_order_history(id_order, current_state)
            repaired += 1
            log.info("Backfilled order_history for id_order=%s to state=%s (id_employee=0).", id_order, current_state)
    log.info(
        "Done. %d order(s) flagged for review, %d repaired. DRY_RUN=%s CONFIRM_REPAIR=%s",
        flagged, repaired, DRY_RUN, CONFIRM_REPAIR,
    )


if __name__ == "__main__":
    run()
check-order-history.js
/**
 * Detect PrestaShop orders whose order_history does not match their current_state.
 *
 * PrestaShop keeps two representations of an order's status in sync by convention, not
 * by a database constraint: the denormalized orders.current_state column, and the
 * append-only order_history (ps_order_history) audit trail that is supposed to gain a
 * new row every time the state changes. When OrderHistory::changeIdOrderState() or
 * addWithemail() is interrupted, a crash during order creation, a module or webservice
 * call that writes current_state directly, or a broken insert like the id_employee
 * mismatch seen after the 8.1.0 upgrade (GitHub #33238), the order ends up pointing at a
 * state that has no matching history record. Related reports (#21502, #27967) show this
 * happening intermittently on payment-confirmation transitions and after upgrades.
 *
 * This script flags affected orders by default. It never edits orders.current_state
 * directly, since that column must only change as a side effect of an order_histories
 * insert. A confirmed repair posts a synthetic order_history row tagged id_employee=0,
 * mirroring what OrderHistory::addWithemail() would have inserted, only when DRY_RUN is
 * false and the operator has explicitly confirmed it.
 *
 * Guide: https://www.allanninal.dev/prestashop/order-history-missing-for-current-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 CONFIRM_REPAIR = (process.env.CONFIRM_REPAIR || "false").toLowerCase() === "true";

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

/**
 * Pure decision function, no I/O.
 *
 * historyStates is an array of [idOrderState, dateAdd] tuples, in any order; the
 * function sorts by dateAdd descending internally. Returns an object describing the
 * problem, or null when the order's history already matches currentState.
 */
export function needsHistoryBackfill(currentState, historyStates) {
  if (!historyStates || historyStates.length === 0) {
    return { reason: "no_history", expected_state: currentState };
  }
  const latest = historyStates.reduce((a, b) => (b[1] > a[1] ? b : a));
  if (latest[0] !== currentState) {
    return {
      reason: "state_mismatch",
      expected_state: currentState,
      last_recorded_state: latest[0],
      last_recorded_date: latest[1],
    };
  }
  return null;
}

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 allOrders() {
  const data = await apiGet("orders", { display: "[id,current_state,reference]", limit: "0" });
  return data.orders || [];
}

async function orderHistoryRows(idOrder) {
  const data = await apiGet("order_histories", {
    "filter[id_order]": idOrder,
    display: "[id,id_order,id_order_state,date_add]",
    sort: "id_DESC",
  });
  return data.order_histories || [];
}

async function validOrderStateIds() {
  const data = await apiGet("order_states", {});
  const states = data.order_states || [];
  return new Set(states.map((s) => Number(s.id)));
}

async function backfillOrderHistory(idOrder, expectedState) {
  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: expectedState, id_employee: 0 } };
  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 validStates = await validOrderStateIds();
  let flagged = 0;
  let repaired = 0;
  for (const order of await allOrders()) {
    const idOrder = order.id;
    const currentState = Number(order.current_state);
    const rows = await orderHistoryRows(idOrder);
    const historyStates = rows.map((row) => [Number(row.id_order_state), row.date_add]);
    const problem = needsHistoryBackfill(currentState, historyStates);
    if (problem === null) continue;
    flagged++;
    let orphanedNote = "";
    if (problem.reason === "state_mismatch" && !validStates.has(problem.last_recorded_state)) {
      orphanedNote = " (last recorded state id is orphaned, no longer a valid order_state)";
    }
    console.warn(
      `Order needs history backfill. id_order=${idOrder} reference=${order.reference} ` +
        `current_state=${currentState} reason=${problem.reason} ` +
        `last_history_state=${problem.last_recorded_state ?? ""} last_history_date=${problem.last_recorded_date ?? ""}${orphanedNote}`
    );
    if (!DRY_RUN && CONFIRM_REPAIR) {
      await backfillOrderHistory(idOrder, currentState);
      repaired++;
      console.log(`Backfilled order_history for id_order=${idOrder} to state=${currentState} (id_employee=0).`);
    }
  }
  console.log(
    `Done. ${flagged} order(s) flagged for review, ${repaired} repaired. DRY_RUN=${DRY_RUN} CONFIRM_REPAIR=${CONFIRM_REPAIR}`
  );
}

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 orders get flagged. Because we kept needs_history_backfill pure, the test needs no network and no PrestaShop store. It just feeds in plain ints and tuples and checks the answer.

test_history_backfill.py
from check_order_history import needs_history_backfill


def test_empty_history_needs_backfill():
    result = needs_history_backfill(2, [])
    assert result == {"reason": "no_history", "expected_state": 2}


def test_matching_latest_state_is_consistent():
    history = [(1, "2026-07-01 10:00:00"), (2, "2026-07-02 10:00:00")]
    assert needs_history_backfill(2, history) is None


def test_mismatched_latest_state_needs_backfill():
    history = [(1, "2026-07-01 10:00:00"), (2, "2026-07-02 10:00:00")]
    result = needs_history_backfill(3, history)
    assert result == {
        "reason": "state_mismatch",
        "expected_state": 3,
        "last_recorded_state": 2,
        "last_recorded_date": "2026-07-02 10:00:00",
    }


def test_uses_latest_by_date_regardless_of_input_order():
    history = [(2, "2026-07-02 10:00:00"), (1, "2026-07-01 10:00:00"), (5, "2026-07-05 10:00:00")]
    result = needs_history_backfill(5, history)
    assert result is None


def test_single_history_row_matching_is_consistent():
    assert needs_history_backfill(1, [(1, "2026-07-01 10:00:00")]) is None


def test_single_history_row_mismatched_needs_backfill():
    result = needs_history_backfill(4, [(1, "2026-07-01 10:00:00")])
    assert result["reason"] == "state_mismatch"
    assert result["last_recorded_state"] == 1
history-backfill.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { needsHistoryBackfill } from "./check-order-history.js";

test("empty history needs backfill", () => {
  const result = needsHistoryBackfill(2, []);
  assert.deepEqual(result, { reason: "no_history", expected_state: 2 });
});

test("matching latest state is consistent", () => {
  const history = [[1, "2026-07-01 10:00:00"], [2, "2026-07-02 10:00:00"]];
  assert.equal(needsHistoryBackfill(2, history), null);
});

test("mismatched latest state needs backfill", () => {
  const history = [[1, "2026-07-01 10:00:00"], [2, "2026-07-02 10:00:00"]];
  const result = needsHistoryBackfill(3, history);
  assert.deepEqual(result, {
    reason: "state_mismatch",
    expected_state: 3,
    last_recorded_state: 2,
    last_recorded_date: "2026-07-02 10:00:00",
  });
});

test("uses latest by date regardless of input order", () => {
  const history = [[2, "2026-07-02 10:00:00"], [1, "2026-07-01 10:00:00"], [5, "2026-07-05 10:00:00"]];
  assert.equal(needsHistoryBackfill(5, history), null);
});

test("single history row matching is consistent", () => {
  assert.equal(needsHistoryBackfill(1, [[1, "2026-07-01 10:00:00"]]), null);
});

test("single history row mismatched needs backfill", () => {
  const result = needsHistoryBackfill(4, [[1, "2026-07-01 10:00:00"]]);
  assert.equal(result.reason, "state_mismatch");
  assert.equal(result.last_recorded_state, 1);
});

Case studies

Post-upgrade audit

The 8.1.0 upgrade that broke silent inserts

A store upgraded to 8.1.0 and support started getting occasional complaints that an order's history in the back office did not explain how it reached its current status. Nothing crashed visibly, so nobody connected it to the upgrade until someone found the matching GitHub issue about a broken insert caused by an id_employee column mismatch.

Running the diagnostic across the full order book found a cluster of affected orders, all dated right around the upgrade window. The team reviewed the list, confirmed each one against payment records, and ran the confirmed backfill so the audit trail finally matched what actually happened.

Payment webhook

The webhook that wrote the state directly

A custom payment module updated current_state directly on payment confirmation instead of calling the order history API, because it was faster to write and nobody noticed the audit trail gap in testing. Weeks later, a customer disputed a charge and support could not produce a history entry showing when the order was actually marked paid.

The diagnostic flagged every order the module had touched as a state_mismatch. Staff reviewed each one, cross-checked payment timestamps, and confirmed backfills that finally gave support a defensible timeline, while the module itself was fixed to go through order_histories going forward.

What good looks like

After this runs on a schedule, every order's current_state has an independently verified history row backing it up. Nothing gets silently rewritten. Instead you get a clear, dated report of exactly which orders have a gap, so staff can confirm the real story before any backfill happens, and the audit trail stays trustworthy either way.

FAQ

Why does an order's current_state have no matching order_history row?

PrestaShop keeps orders.current_state and the order_history audit trail in sync only by convention, not by a database constraint. When OrderHistory::changeIdOrderState() or addWithemail() is interrupted, for example by a crash during order creation, a module or webservice call that writes current_state directly, or a broken insert like the id_employee mismatch seen after the 8.1.0 upgrade, the order ends up pointing at a state that never got its own history row.

Is it safe to backfill order_history automatically?

Not automatically. A state-history backfill risks the wrong id_employee or date_add and can create duplicate-looking consecutive states, which PrestaShop itself tries to avoid. The safe pattern is to flag affected orders for staff review first, and only insert a synthetic order_history row after an operator explicitly confirms it, tagged with id_employee=0 to mark it as a system-generated backfill.

How do I detect orders with a missing or mismatched order_history record?

Pull each order's current_state with GET orders, then pull its history rows ordered by date with GET order_histories filtered by id_order. An order is affected when order_histories returns zero rows, or when the most recent row's id_order_state does not equal the order's current_state. Cross-check the state id against GET order_states to rule out an orphaned, deleted state.

Related field notes

Citations

On the problem:

  1. PrestaShop GitHub: Order History not updated. Issue #33238. github.com/PrestaShop/PrestaShop/issues/33238
  2. PrestaShop GitHub: Order_history record to state id=2 not created (sometimes). Issue #21502. github.com/PrestaShop/PrestaShop/issues/21502
  3. PrestaShop GitHub: Customer Account / Order History ContextErrorException when it crashed during order creation. Issue #27967. github.com/PrestaShop/PrestaShop/issues/27967

On the solution:

  1. PrestaShop Developer Documentation: Order histories webservice resource. devdocs.prestashop-project.org/9/webservice/resources/order_histories/
  2. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/9/webservice/resources/orders/
  3. 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.

Contact me on LinkedIn

Did this untangle your order history?

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

Back to all PrestaShop field notes