Reconciler Orders & Order States
Duplicate order history rows created for a single status change
You open an order's history and the same status is listed twice in a row, back to back, with two different history ids and two nearly identical timestamps. Sometimes the customer even got the same email twice. Nothing in the store changed the order's real status, it just got recorded more than once. Here is why PrestaShop lets one status change turn into two history rows, how to find every order it has happened to, and a safe way to clean it up without losing the audit trail.
Order::setCurrentState(), called directly in the back office or through Order::setWsCurrentState() when the webservice PUTs an order with current_state set, historically ran its full body every time it was invoked: insert into order_history, send the order state's email, fire the related hooks, all without first checking whether the order's current_state already matched the requested id_order_state. A retried webhook, a payment module firing its IPN handler twice, or a webservice client blindly PUTting the same state runs that body twice, so you end up with two identical order_history rows back to back, and possibly a duplicate customer email. PrestaShop fixed this for core back office calls in 1.7.7, but pre-1.7.7 stores and third party modules or webservice clients that call setCurrentState or POST order_histories directly can still reproduce it. Run a small Python or Node.js script that pulls each order's order_histories, sorts them, and flags any consecutive pair with the same id_order_state. Full code, tests, and a dry run guard are below.
The problem in plain words
Every time an order's status changes in PrestaShop, one function is supposed to run: Order::setCurrentState(). It writes a new row into order_history, it can send the customer an email tied to that order state, and it fires the hooks that other modules listen for, like actionOrderStatusUpdate and actionOrderStatusPostUpdate.
For a long time, that function did all of that unconditionally. It never asked "does this order already have this exact status?" before doing the work. So if the same call happened twice, because a webhook retried after a slow response, because a payment module's IPN handler fired more than once for the same transaction, or because a webservice client PUT the same current_state value it had already sent, PrestaShop happily inserted a second, identical order_history row, and could resend the same notification email to the customer. Nothing about the order's real status changed. It just got logged, and sometimes announced, twice.
Why it happens
This is not a one-off glitch, it is a gap in how setCurrentState() was written before it was patched. A few concrete ways stores still hit it:
- A retried webhook. A payment gateway's callback times out waiting for a response, retries automatically, and both deliveries reach the store and each triggers the same status transition.
- A payment module firing its own IPN handler twice for the same transaction, once from the redirect back to the store and once from the gateway's server to server notification, with no idempotency check on the module's side.
- A webservice client that blindly PUTs an order with
current_stateset to the value it thinks is correct, on every sync run, whether or not the order already has that state, routed throughOrder::setWsCurrentState(). - A pre-1.7.7 store, or a store on 1.7.7 and later running a third party module that calls
setCurrentState()directly or POSTs toorder_historieswithout first checking the order's existingcurrent_state, reintroducing the same no-guard pattern core itself no longer has.
PrestaShop core acknowledged this directly. GitHub issue #22011 describes the OrderHistory instance being duplicated when the order status is changed through the webservice, and issue #20623 tracks calling setCurrentState() with the same id_order_state the order already has. The fix landed in PR #20622, "Do not proceed setCurrentState if order already has the right state," in PrestaShop 1.7.7. See the citations at the end for the exact threads.
A legitimate order can revisit the same state more than once over its life, for example Awaiting payment, then Payment accepted, then Refunded, then Awaiting payment again. That is normal and should never be flagged. What is not normal is the same id_order_state appearing on two order_history rows back to back, with nothing in between. So the rule is about adjacency, not repetition: sort an order's history rows in the order they were written, and flag only a row whose id_order_state matches the row directly before it. Anything separated by a different state in between is a real business event, not a duplicate.
The fix, as a flow
We never touch an order's current_state field, and we never delete the first row of a run, only the repeats. The script pulls each order's full order_histories, sorts them by date_add then id, walks the list looking for consecutive rows with the same id_order_state, and reports every duplicate id it finds. Only with a human's confirmation and DRY_RUN=false does it delete those specific duplicate ids, one at a time, then re-checks that exactly one row remains per run.
Build it step by step
Enable the Webservice API and get a key
In the PrestaShop admin, go to Advanced Parameters, Webservice, and turn it on. Create a key with access to the orders, order_histories, and order_states resources. Keep the shop URL and the key in environment variables, never in the file.
pip install requests
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export PRESTASHOP_URL="https://your-shop.example.com"
export PRESTASHOP_WS_KEY="your webservice key"
export DRY_RUN="true" // start safe, change to false to write
Talk to the Webservice API
Every call goes to {PRESTASHOP_URL}/api/<resource> with the key sent as the HTTP Basic username and a blank password, plus ?output_format=JSON since PrestaShop replies in XML by default. A small helper wraps GET, POST, and DELETE and raises on a bad status.
import os, requests
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_delete(path):
r = requests.delete(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return True
const BASE_URL = (process.env.PRESTASHOP_URL || "").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY;
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiDelete(path) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "DELETE",
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return true;
}
Pull one order's full history timeline
Read every order_histories row for a given id_order with display=full so you get id, id_order_state, and date_add back for each row. To scan a whole store, page through orders to enumerate order ids first, then run this per order.
def order_history_rows(id_order):
data = api_get("order_histories", {
"filter[id_order]": id_order,
"display": "full",
})
rows = data.get("order_histories") or []
if isinstance(rows, dict):
rows = [rows]
return [
{"id": int(r["id"]), "id_order_state": int(r["id_order_state"]), "date_add": r["date_add"]}
for r in rows
]
def order_ids_to_check():
data = api_get("orders", {"display": "full", "limit": "0,200"})
orders = data.get("orders") or []
if isinstance(orders, dict):
orders = [orders]
return [int(o["id"]) for o in orders]
async function orderHistoryRows(idOrder) {
const data = await apiGet("order_histories", {
"filter[id_order]": idOrder,
display: "full",
});
let rows = data.order_histories || [];
if (!Array.isArray(rows)) rows = [rows];
return rows.map((r) => ({
id: Number(r.id),
id_order_state: Number(r.id_order_state),
date_add: r.date_add,
}));
}
async function orderIdsToCheck() {
const data = await apiGet("orders", { display: "full", limit: "0,200" });
let orders = data.orders || [];
if (!Array.isArray(orders)) orders = [orders];
return orders.map((o) => Number(o.id));
}
Decide, with one pure function
Keep the decision in its own function that takes the history rows for a single order and returns the ids of the duplicate rows. Sort by (date_add, id) ascending, walk the sorted list, and whenever a row's id_order_state equals the previous row's, flag that row's id. The earlier row in each run is always kept. A run longer than two repeats gets every repeat after the first flagged, because the tracker resets to the current row's state after every comparison.
def find_duplicate_history_ids(history_rows):
if not history_rows:
return []
ordered = sorted(history_rows, key=lambda r: (r["date_add"], r["id"]))
duplicate_ids = []
previous_state = None
for row in ordered:
if previous_state is not None and row["id_order_state"] == previous_state:
duplicate_ids.append(row["id"])
previous_state = row["id_order_state"]
return duplicate_ids
export function findDuplicateHistoryIds(historyRows) {
if (!historyRows || historyRows.length === 0) return [];
const ordered = [...historyRows].sort((a, b) => {
if (a.date_add < b.date_add) return -1;
if (a.date_add > b.date_add) return 1;
return a.id - b.id;
});
const duplicateIds = [];
let previousState = null;
for (const row of ordered) {
if (previousState !== null && row.id_order_state === previousState) {
duplicateIds.push(row.id);
}
previousState = row.id_order_state;
}
return duplicateIds;
}
Delete only the flagged duplicates, never the first row
When a duplicate id is confirmed, delete it with DELETE {PRESTASHOP_URL}/api/order_histories/[id]. Never delete the first occurrence in a run, and never touch the order's own current_state field, since that pointer is already correct, it is only the history log that has an extra row. After deleting, re-fetch order_histories for that order to confirm exactly one row remains per consecutive run.
def delete_duplicate_history(id_order_history):
return api_delete(f"order_histories/{id_order_history}")
async function deleteDuplicateHistory(idOrderHistory) {
return apiDelete(`order_histories/${idOrderHistory}`);
}
Wire it together with a dry run guard
The loop pulls every order id, reads that order's history, runs it through the pure decision function, and logs every duplicate id it finds. On the first runs, leave DRY_RUN on so the script only reports the duplicate ids and their id_order and id_order_state. Only with DRY_RUN=false does it delete the flagged ids, one at a time, then re-checks the order's history to confirm the cleanup worked.
Always start with DRY_RUN=true. order_history rows can be referenced by invoice and credit slip numbering, so hard-deleting is opt in, never the default. The script never deletes the first row of a run and never edits current_state. As a companion fix, if you maintain a custom module or run a pre-1.7.7 store, add the same guard PrestaShop core added in 1.7.7: compare (int) $this->current_state to (int) $id_order_state before inserting a new history row or sending an email.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and the only write it ever performs is deleting the specific duplicate order_history ids the pure function flagged, keeping the first row of every run.
"""Flag and clean up duplicate PrestaShop order_history rows for a single status change.
Order::setCurrentState() historically ran its full body, insert order_history,
send the email, fire the hooks, every time it was called, without checking whether
the order already had the requested state. A retried webhook, a duplicated IPN call,
or a webservice client blindly re-sending current_state can insert the same
order_history row twice. This script reports duplicate ids by default. Only with
DRY_RUN=false does it delete the flagged duplicate ids, never the first row of a
run and never the order's current_state field. 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("duplicate_history_cleanup")
BASE_URL = os.environ["PRESTASHOP_URL"].rstrip("/")
WS_KEY = os.environ["PRESTASHOP_WS_KEY"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def api_get(path, params=None):
params = dict(params or {})
params["output_format"] = "JSON"
r = requests.get(f"{BASE_URL}/api/{path}", params=params, auth=(WS_KEY, ""), timeout=30)
r.raise_for_status()
return r.json()
def api_delete(path):
r = requests.delete(
f"{BASE_URL}/api/{path}",
params={"output_format": "JSON"},
auth=(WS_KEY, ""),
timeout=30,
)
r.raise_for_status()
return True
def find_duplicate_history_ids(history_rows):
if not history_rows:
return []
ordered = sorted(history_rows, key=lambda r: (r["date_add"], r["id"]))
duplicate_ids = []
previous_state = None
for row in ordered:
if previous_state is not None and row["id_order_state"] == previous_state:
duplicate_ids.append(row["id"])
previous_state = row["id_order_state"]
return duplicate_ids
def order_history_rows(id_order):
data = api_get("order_histories", {
"filter[id_order]": id_order,
"display": "full",
})
rows = data.get("order_histories") or []
if isinstance(rows, dict):
rows = [rows]
return [
{"id": int(r["id"]), "id_order_state": int(r["id_order_state"]), "date_add": r["date_add"]}
for r in rows
]
def order_ids_to_check():
data = api_get("orders", {"display": "full", "limit": "0,200"})
orders = data.get("orders") or []
if isinstance(orders, dict):
orders = [orders]
return [int(o["id"]) for o in orders]
def delete_duplicate_history(id_order_history):
return api_delete(f"order_histories/{id_order_history}")
def run():
flagged = 0
for id_order in order_ids_to_check():
rows = order_history_rows(id_order)
duplicate_ids = find_duplicate_history_ids(rows)
if not duplicate_ids:
continue
flagged += len(duplicate_ids)
log.warning("Order %s has duplicate order_history ids: %s", id_order, duplicate_ids)
if DRY_RUN:
log.info("DRY RUN: would delete order_histories %s for order %s", duplicate_ids, id_order)
else:
for id_order_history in duplicate_ids:
delete_duplicate_history(id_order_history)
remaining = order_history_rows(id_order)
remaining_states = [r["id_order_state"] for r in remaining]
log.info("Order %s cleaned up. Remaining history states: %s", id_order, remaining_states)
log.info("Done. %d duplicate order_history row(s) %s.", flagged, "to delete" if DRY_RUN else "deleted")
if __name__ == "__main__":
run()
/**
* Flag and clean up duplicate PrestaShop order_history rows for a single status change.
*
* Order::setCurrentState() historically ran its full body, insert order_history,
* send the email, fire the hooks, every time it was called, without checking whether
* the order already had the requested state. A retried webhook, a duplicated IPN call,
* or a webservice client blindly re-sending current_state can insert the same
* order_history row twice. This script reports duplicate ids by default. Only with
* DRY_RUN=false does it delete the flagged duplicate ids, never the first row of a
* run and never the order's current_state field. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/prestashop/duplicate-order-history-rows/
*/
import { pathToFileURL } from "node:url";
const BASE_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/$/, "");
const WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function findDuplicateHistoryIds(historyRows) {
if (!historyRows || historyRows.length === 0) return [];
const ordered = [...historyRows].sort((a, b) => {
if (a.date_add < b.date_add) return -1;
if (a.date_add > b.date_add) return 1;
return a.id - b.id;
});
const duplicateIds = [];
let previousState = null;
for (const row of ordered) {
if (previousState !== null && row.id_order_state === previousState) {
duplicateIds.push(row.id);
}
previousState = row.id_order_state;
}
return duplicateIds;
}
function authHeader() {
return "Basic " + Buffer.from(`${WS_KEY}:`).toString("base64");
}
async function apiGet(path, params = {}) {
const qs = new URLSearchParams({ ...params, output_format: "JSON" });
const res = await fetch(`${BASE_URL}/api/${path}?${qs}`, {
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return res.json();
}
async function apiDelete(path) {
const res = await fetch(`${BASE_URL}/api/${path}?output_format=JSON`, {
method: "DELETE",
headers: { Authorization: authHeader() },
});
if (!res.ok) throw new Error(`PrestaShop ${res.status}`);
return true;
}
async function orderHistoryRows(idOrder) {
const data = await apiGet("order_histories", {
"filter[id_order]": idOrder,
display: "full",
});
let rows = data.order_histories || [];
if (!Array.isArray(rows)) rows = [rows];
return rows.map((r) => ({
id: Number(r.id),
id_order_state: Number(r.id_order_state),
date_add: r.date_add,
}));
}
async function orderIdsToCheck() {
const data = await apiGet("orders", { display: "full", limit: "0,200" });
let orders = data.orders || [];
if (!Array.isArray(orders)) orders = [orders];
return orders.map((o) => Number(o.id));
}
async function deleteDuplicateHistory(idOrderHistory) {
return apiDelete(`order_histories/${idOrderHistory}`);
}
export async function run() {
let flagged = 0;
const orderIds = await orderIdsToCheck();
for (const idOrder of orderIds) {
const rows = await orderHistoryRows(idOrder);
const duplicateIds = findDuplicateHistoryIds(rows);
if (duplicateIds.length === 0) continue;
flagged += duplicateIds.length;
console.warn(`Order ${idOrder} has duplicate order_history ids:`, duplicateIds);
if (DRY_RUN) {
console.log(`DRY RUN: would delete order_histories ${duplicateIds} for order ${idOrder}`);
} else {
for (const idOrderHistory of duplicateIds) {
await deleteDuplicateHistory(idOrderHistory);
}
const remaining = await orderHistoryRows(idOrder);
console.log(`Order ${idOrder} cleaned up. Remaining history states:`, remaining.map((r) => r.id_order_state));
}
}
console.log(`Done. ${flagged} duplicate order_history row(s) ${DRY_RUN ? "to delete" : "deleted"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which rows get deleted. Because find_duplicate_history_ids is pure, the test needs no network and no PrestaShop store. It just feeds in plain lists of history rows and checks the answer.
from duplicate_history_cleanup import find_duplicate_history_ids
def row(id, id_order_state, date_add):
return {"id": id, "id_order_state": id_order_state, "date_add": date_add}
def test_no_rows_no_duplicates():
assert find_duplicate_history_ids([]) == []
def test_no_duplicates_when_all_states_differ():
rows = [row(1, 1, "2026-07-10 10:00:00"), row(2, 2, "2026-07-10 10:05:00")]
assert find_duplicate_history_ids(rows) == []
def test_consecutive_same_state_flagged():
rows = [row(1, 2, "2026-07-10 10:00:00"), row(2, 2, "2026-07-10 10:00:05")]
assert find_duplicate_history_ids(rows) == [2]
def test_first_occurrence_never_flagged():
rows = [row(1, 2, "2026-07-10 10:00:00"), row(2, 2, "2026-07-10 10:00:05")]
duplicate_ids = find_duplicate_history_ids(rows)
assert 1 not in duplicate_ids
def test_revisiting_same_state_later_is_not_flagged():
# Awaiting payment -> Payment accepted -> Refunded -> Awaiting payment again.
rows = [
row(1, 1, "2026-07-10 09:00:00"),
row(2, 2, "2026-07-10 09:05:00"),
row(3, 3, "2026-07-10 09:10:00"),
row(4, 1, "2026-07-10 09:15:00"),
]
assert find_duplicate_history_ids(rows) == []
def test_run_longer_than_two_flags_all_but_first():
rows = [
row(1, 2, "2026-07-10 10:00:00"),
row(2, 2, "2026-07-10 10:00:01"),
row(3, 2, "2026-07-10 10:00:02"),
]
assert find_duplicate_history_ids(rows) == [2, 3]
def test_unsorted_input_is_sorted_before_comparing():
rows = [
row(2, 2, "2026-07-10 10:00:05"),
row(1, 2, "2026-07-10 10:00:00"),
]
assert find_duplicate_history_ids(rows) == [2]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateHistoryIds } from "./duplicate-history-cleanup.js";
const row = (id, id_order_state, date_add) => ({ id, id_order_state, date_add });
test("no rows means no duplicates", () => {
assert.deepEqual(findDuplicateHistoryIds([]), []);
});
test("no duplicates when all states differ", () => {
const rows = [row(1, 1, "2026-07-10 10:00:00"), row(2, 2, "2026-07-10 10:05:00")];
assert.deepEqual(findDuplicateHistoryIds(rows), []);
});
test("consecutive same state is flagged", () => {
const rows = [row(1, 2, "2026-07-10 10:00:00"), row(2, 2, "2026-07-10 10:00:05")];
assert.deepEqual(findDuplicateHistoryIds(rows), [2]);
});
test("first occurrence is never flagged", () => {
const rows = [row(1, 2, "2026-07-10 10:00:00"), row(2, 2, "2026-07-10 10:00:05")];
const duplicateIds = findDuplicateHistoryIds(rows);
assert.equal(duplicateIds.includes(1), false);
});
test("revisiting the same state later is not flagged", () => {
const rows = [
row(1, 1, "2026-07-10 09:00:00"),
row(2, 2, "2026-07-10 09:05:00"),
row(3, 3, "2026-07-10 09:10:00"),
row(4, 1, "2026-07-10 09:15:00"),
];
assert.deepEqual(findDuplicateHistoryIds(rows), []);
});
test("a run longer than two flags all but the first", () => {
const rows = [
row(1, 2, "2026-07-10 10:00:00"),
row(2, 2, "2026-07-10 10:00:01"),
row(3, 2, "2026-07-10 10:00:02"),
];
assert.deepEqual(findDuplicateHistoryIds(rows), [2, 3]);
});
test("unsorted input is sorted before comparing", () => {
const rows = [
row(2, 2, "2026-07-10 10:00:05"),
row(1, 2, "2026-07-10 10:00:00"),
];
assert.deepEqual(findDuplicateHistoryIds(rows), [2]);
});
Case studies
The same confirmation email landed twice
A merchant on a pre-1.7.7 store used a payment module that called setCurrentState() from both its redirect handler and its server to server notification for the same transaction. Both calls ran within seconds of each other, so the order got two identical "Payment accepted" rows, and the customer received the confirmation email twice, which generated a handful of confused support tickets asking if they had been charged twice.
The reconciler script found the duplicate order_history id on the affected orders in dry run, the merchant confirmed the payments were genuinely single charges by checking the gateway dashboard, and running with DRY_RUN=false removed only the later duplicate row on each order, leaving the first row and the invoice numbering untouched.
A nightly sync kept re-sending the same status
An external order management system PUT every order to PrestaShop nightly, including its current status, without checking whether the status had actually changed since the last sync. Every night, orders that had not moved got a fresh, identical order_history row through setWsCurrentState(), and after a few months some orders had a dozen duplicate rows for the same state.
Running the scan across the whole store in dry run surfaced exactly which orders had accumulated duplicates and how many. The team fixed the sync to only PUT when the status actually changed, then ran the cleanup once with DRY_RUN=false to remove the backlog of duplicate rows it had already created, keeping one row per real transition.
After a cleanup run, every order's history reads as one row per real status change, with no back to back repeats left over from a retried webhook or a blind sync. The order's current_state was never touched, the first row of every run is always preserved for the audit trail, and nothing was deleted until a human reviewed the dry run output and confirmed it. If you maintain a module or an older store, the same guard PrestaShop core now uses, comparing the current state to the requested one before proceeding, stops new duplicates from being created in the first place.
FAQ
Why does PrestaShop create two identical order_history rows for one status change?
Order::setCurrentState() historically ran its full body, insert an order_history row, send the order state's email, and fire the related hooks, every time it was called, without first checking whether the order's current_state already equalled the requested id_order_state. A retried webhook, a payment module firing its IPN handler twice, or a webservice client PUTting the same state runs that body twice, so the same status gets logged, and sometimes emailed, twice.
Is this fixed in current PrestaShop?
PrestaShop fixed it for core back office calls in 1.7.7, in PR 20622, which added a guard that compares the current state to the requested state before proceeding. Many merchants still run older 1.6 or early 1.7 stores, and third party modules or webservice integrations that call setCurrentState directly, or POST order_histories without checking the existing state first, can still reintroduce the same pattern.
Is it safe to delete the duplicate order_history rows?
Only the later duplicate in each consecutive run, never the first occurrence and never the order's current_state field. order_history rows can be referenced by invoice and credit slip numbering, so the safe default is to report the duplicate ids and only delete them with an explicit DRY_RUN=false flag, keeping the earliest row of every run intact.
Related field notes
Citations
On the problem:
- PrestaShop GitHub Issue #22011: When the order status is changed in the webservice, the OrderHistory instance is duplicated. github.com/PrestaShop/PrestaShop/issues/22011
- PrestaShop GitHub Issue #20623: setCurrentState to the same id_order_state. github.com/PrestaShop/PrestaShop/issues/20623
- PrestaShop GitHub Issue #15513: Update order state with Order::setCurrentState() add new line for invoice in order view. github.com/PrestaShop/PrestaShop/issues/15513
On the solution:
- PrestaShop Developer Documentation: the order_histories Webservice resource. devdocs.prestashop-project.org webservice resources order_histories
- PrestaShop Developer Documentation: the order_states Webservice resource. devdocs.prestashop-project.org webservice resources order_states
- PrestaShop Developer Documentation: the orders Webservice resource, the current_state field. devdocs.prestashop-project.org webservice resources orders
Stuck on a tricky one?
If you have a problem in PrestaShop stock, orders, order states, or the Webservice API that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this clean up your order history?
If this saved you from a confusing duplicate email or a messy audit trail, 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