Skip to content

Repair

Order created via webservice drops the carrier and shipping cost fields

You POST a new order to the webservice with a real id_carrier and a real total_shipping, the call returns 201, and everything looks fine. Then you fetch the order back and the carrier is 0, or it is the shop's default carrier instead of the one the customer chose, and the shipping cost is recalculated to something else, or it is 0.00 outright. The invoice under- or overstates shipping, and sometimes the order even lands in a payment error state because the totals no longer add up. Here is why PrestaShop's order creation quietly ignores what you submitted and a script that finds and repairs every order this happened to.

Python and Node.js PrestaShop Webservice API Safe by default (dry run)
A courier beside a van
Photo by Kazem Hussein on Unsplash
The short answer

The orders webservice resource exposes id_carrier, total_shipping, total_shipping_tax_incl, total_shipping_tax_excl, and carrier_tax_rate as writable fields, but Order::add() does not trust them from the POST body. The order is actually built from the referenced cart (id_cart), and the carrier and shipping amounts get recalculated from the cart's own stored delivery option instead. The result is a created order whose id_carrier reverts to 0 or the shop's default carrier, and whose shipping totals are recalculated or dropped to 0, while the matching order_carriers row may or may not even exist. Run a Python or Node.js script that, for every recently created order, compares the stored id_carrier and shipping totals against what you actually submitted at creation time, and flags or repairs the ones that drifted. Full code, tests, and citations are below.

The problem in plain words

When you create an order through POST /api/orders, the request body can carry id_carrier, total_shipping, total_shipping_tax_incl, total_shipping_tax_excl, and carrier_tax_rate right alongside the customer, address, and product fields. It reads like the webservice is asking you to hand it the final carrier and shipping cost, and it will accept the values without complaint.

But under the hood, Order::add() does not build the order from your POST body. It builds it from the cart referenced by id_cart, and the carrier and shipping amounts are pulled from that cart's own stored delivery option using Cart::getDeliveryOption() and Cart::getPackageShippingCost(), not from what you just submitted. So the values you carefully set on the request are silently discarded the moment PrestaShop rebuilds the order from the cart. The order you get back can have id_carrier at 0, or reverted to the shop's default carrier, and its shipping totals recalculated to whatever the cart's delivery option says, or dropped to 0.00 if the cart never had one properly attached.

POST /api/orders id_carrier, total_shipping Order::add() rebuilds from id_cart submitted fields dropped id_carrier = 0 or shop default Shipping recalc or dropped to 0
The POST body carries the carrier and shipping cost, but Order::add rebuilds the order from the cart's own delivery option instead of trusting what you sent, so both fields drift or disappear.

Why it happens

This is not a one-off misconfiguration on your shop. It is how PrestaShop core builds an order from the webservice today:

The downstream effect is real money: invoices under- or overstate shipping, and when total_paid no longer reconciles with total_products + total_shipping, the order can even trip a false payment error state. This is a long-standing, still-open core bug, tracked as PrestaShop/PrestaShop issue 19906 and duplicated by issue 32622, acknowledged by maintainers as a known backlog item. See the citations at the end for the exact reports and docs.

The key insight

You cannot prevent Order::add() from recalculating the carrier and shipping cost from the cart, so the fix is not a different way to POST the order. It is to keep a record of what you actually submitted at creation time, keyed by id_cart or a reference field, and compare that against what got stored once the order exists. Anything that drifted gets repaired with a full PUT, exactly as the webservice requires, never a partial patch.

The fix, as a flow

We do not change how orders are created. We add a job that, for each recently created order, reads back the stored id_carrier and shipping totals plus the matching order_carriers row, compares them against what your own queue or log recorded as submitted at creation time, and flags anything that disagrees. A guarded repair then adds the missing order_carriers row if needed and PUTs the corrected carrier and shipping fields back onto the order.

Read stored order GET orders/{id}, order_carriers Compare to submitted from the local order log Mismatched? or missing row? yes no, move on Add/fix order_carriers row, DRY_RUN guarded PUT full order corrected fields + order_histories
The job only compares by default. A repair adds a missing order_carriers row and PUTs the full order back with the corrected carrier and shipping fields, always guarded by DRY_RUN.

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 and order_carriers, plus write access to both if you plan to run confirmed repairs, and to order_histories if you want the optional state correction. 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 logs the planned diff 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 logs the planned diff by default
2

Read back the stored order and its order_carriers row

Call GET /api/orders/{id}?output_format=JSON for the stored id_carrier, total_shipping_tax_incl, and total_shipping_tax_excl, and GET /api/order_carriers?filter[id_order]={id}&display=full&output_format=JSON to see whether a matching order_carriers row exists at all.

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 get_order(id_order):
    return api_get(f"orders/{id_order}")["order"]

def get_order_carrier_rows(id_order):
    data = api_get("order_carriers", params={"filter[id_order]": id_order, "display": "full"})
    return data.get("order_carriers") 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 getOrder(idOrder) {
  const data = await apiGet(`orders/${idOrder}`);
  return data.order;
}

async function getOrderCarrierRows(idOrder) {
  const data = await apiGet("order_carriers", { "filter[id_order]": idOrder, display: "full" });
  return data.order_carriers || [];
}
3

Keep the originally submitted values

This detection only works if you kept a record of what you actually sent when the order was created. Log the submitted id_carrier, total_shipping_tax_incl, and total_shipping_tax_excl at POST time, keyed by id_cart or an idempotency reference. Load that log back before comparing.

step3.py
import json

def load_submitted_log(path):
    """Returns {id_order: {"idCarrier": int, "totalShippingTaxIncl": float,
    "totalShippingTaxExcl": float}} keyed by id_order, from your own order queue."""
    with open(path) as f:
        return json.load(f)
step3.js
import { readFile } from "node:fs/promises";

async function loadSubmittedLog(path) {
  // Returns { [idOrder]: { idCarrier, totalShippingTaxIncl, totalShippingTaxExcl } }
  // keyed by id_order, from your own order queue.
  const raw = await readFile(path, "utf8");
  return JSON.parse(raw);
}
4

Decide, with one pure function

Keep the comparison in its own function that takes the submitted values, the stored values, and whether an order_carriers row exists, and returns whether a repair is needed, why, and the exact patch to apply. It touches no network, so it is easy to test on its own.

decide.py
def decide_carrier_shipping_repair(submitted, stored, epsilon=0.01):
    reasons = []
    if stored["idCarrier"] == 0 or stored["idCarrier"] != submitted["idCarrier"]:
        reasons.append("carrier_mismatch")
    if abs(stored["totalShippingTaxIncl"] - submitted["totalShippingTaxIncl"]) > epsilon:
        reasons.append("shipping_amount_mismatch")
    if abs(stored["totalShippingTaxExcl"] - submitted["totalShippingTaxExcl"]) > epsilon:
        if "shipping_amount_mismatch" not in reasons:
            reasons.append("shipping_amount_mismatch")
    if not stored["hasOrderCarrierRow"]:
        reasons.append("missing_order_carrier_row")

    if not reasons:
        return {"needsRepair": False, "reasons": [], "patch": None}

    patch = {
        "id_carrier": submitted["idCarrier"],
        "total_shipping": submitted["totalShippingTaxIncl"],
        "total_shipping_tax_incl": submitted["totalShippingTaxIncl"],
        "total_shipping_tax_excl": submitted["totalShippingTaxExcl"],
    }
    return {"needsRepair": True, "reasons": reasons, "patch": patch}
decide.js
export function decideCarrierShippingRepair(submitted, stored, epsilon = 0.01) {
  const reasons = [];
  if (stored.idCarrier === 0 || stored.idCarrier !== submitted.idCarrier) {
    reasons.push("carrier_mismatch");
  }
  const inclOff = Math.abs(stored.totalShippingTaxIncl - submitted.totalShippingTaxIncl) > epsilon;
  const exclOff = Math.abs(stored.totalShippingTaxExcl - submitted.totalShippingTaxExcl) > epsilon;
  if ((inclOff || exclOff) && !reasons.includes("shipping_amount_mismatch")) {
    reasons.push("shipping_amount_mismatch");
  }
  if (!stored.hasOrderCarrierRow) {
    reasons.push("missing_order_carrier_row");
  }

  if (reasons.length === 0) {
    return { needsRepair: false, reasons: [], patch: null };
  }

  const patch = {
    id_carrier: submitted.idCarrier,
    total_shipping: submitted.totalShippingTaxIncl,
    total_shipping_tax_incl: submitted.totalShippingTaxIncl,
    total_shipping_tax_excl: submitted.totalShippingTaxExcl,
  };
  return { needsRepair: true, reasons, patch };
}
5

Repair with a full PUT, guarded by DRY_RUN

PrestaShop's webservice PUT requires the complete resource representation, not a partial patch, so fetch the full order first, apply the patch fields on top, then PUT it back. If order_carriers was missing entirely, POST a new row with the submitted carrier and shipping cost before touching the order. Only move the order out of a payment error state through order_histories once the totals reconcile, and only if that mismatch is what pushed it there in the first place.

repair.py
def add_order_carrier_row(id_order, submitted):
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_carriers",
        params={"output_format": "JSON"},
        json={"order_carrier": {
            "id_order": id_order,
            "id_carrier": submitted["idCarrier"],
            "shipping_cost_tax_excl": f"{submitted['totalShippingTaxExcl']:.6f}",
            "shipping_cost_tax_incl": f"{submitted['totalShippingTaxIncl']:.6f}",
        }},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def apply_order_patch(id_order, patch):
    order = get_order(id_order)
    order.update(patch)
    r = requests.put(
        f"{PRESTASHOP_URL}/api/orders/{id_order}",
        params={"output_format": "JSON"},
        json={"order": order},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def reapply_current_state(id_order, id_order_state):
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json={"order_history": {"id_order": id_order, "id_order_state": id_order_state}},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
repair.js
async function addOrderCarrierRow(idOrder, submitted) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_carriers`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({
      order_carrier: {
        id_order: idOrder,
        id_carrier: submitted.idCarrier,
        shipping_cost_tax_excl: submitted.totalShippingTaxExcl.toFixed(6),
        shipping_cost_tax_incl: submitted.totalShippingTaxIncl.toFixed(6),
      },
    }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_carriers`);
  return res.json();
}

async function applyOrderPatch(idOrder, patch) {
  const order = await getOrder(idOrder);
  Object.assign(order, patch);
  const url = new URL(`${PRESTASHOP_URL}/api/orders/${idOrder}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
  return res.json();
}

async function reapplyCurrentState(idOrder, idOrderState) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order_history: { id_order: idOrder, id_order_state: idOrderState } }),
  });
  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: load the submitted log, read back each order and its order_carriers rows, run decideCarrierShippingRepair, and log the planned before and after diff for every flagged order. DRY_RUN defaults to true, so the script only ever logs the diff unless flipped off. Run it against the orders you created recently, or on a schedule right after your integration submits new orders.

Run it safe

Always start with DRY_RUN=true and read the logged diffs before writing anything. Only move an order out of a payment error state through order_histories once the carrier and shipping totals genuinely reconcile, never as a way to force a status change on its own.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, compares each order against your logged submission, reports every mismatch, respects the dry run flag, and only writes when a real disagreement is found.

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.
repair_carrier_shipping.py
"""Detect and repair PrestaShop orders that dropped their carrier or shipping cost.

The orders webservice resource exposes id_carrier, total_shipping,
total_shipping_tax_incl/excl, and carrier_tax_rate as writable fields, but
Order::add() does not persist them as submitted. The order is actually built
from the referenced cart (id_cart), and the carrier and shipping amounts are
recalculated from the cart's own stored delivery option instead of trusted from
the POST body. The result is a created order whose id_carrier reverts to 0 or
the shop's default carrier, and whose total_shipping is recalculated or dropped
to 0, while a correctly-linked order_carriers row may or may not exist.

This is a long-standing, still-open core bug (PrestaShop/PrestaShop#19906,
duplicated by #32622), acknowledged by maintainers as a known backlog item.

This script only logs the planned diff by default. It never writes to an order
or order_carriers unless DRY_RUN is explicitly false. Run against orders you
created recently, keyed by id_order in a local submitted-values log. Safe to
run again and again.
"""
import os
import json
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("repair_carrier_shipping")

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"
SUBMITTED_LOG_PATH = os.environ.get("SUBMITTED_LOG_PATH", "submitted_orders.json")
AUTH = (PRESTASHOP_WS_KEY, "")

EPSILON = 0.01


def decide_carrier_shipping_repair(submitted, stored, epsilon=EPSILON):
    """Pure decision logic, no I/O.

    submitted: {idCarrier, totalShippingTaxIncl, totalShippingTaxExcl}
    stored: {idCarrier, totalShippingTaxIncl, totalShippingTaxExcl, hasOrderCarrierRow}

    Returns {needsRepair, reasons, patch}. patch is None unless needsRepair is True,
    in which case it carries only the corrected order fields.
    """
    reasons = []
    if stored["idCarrier"] == 0 or stored["idCarrier"] != submitted["idCarrier"]:
        reasons.append("carrier_mismatch")
    incl_off = abs(stored["totalShippingTaxIncl"] - submitted["totalShippingTaxIncl"]) > epsilon
    excl_off = abs(stored["totalShippingTaxExcl"] - submitted["totalShippingTaxExcl"]) > epsilon
    if (incl_off or excl_off) and "shipping_amount_mismatch" not in reasons:
        reasons.append("shipping_amount_mismatch")
    if not stored["hasOrderCarrierRow"]:
        reasons.append("missing_order_carrier_row")

    if not reasons:
        return {"needsRepair": False, "reasons": [], "patch": None}

    patch = {
        "id_carrier": submitted["idCarrier"],
        "total_shipping": submitted["totalShippingTaxIncl"],
        "total_shipping_tax_incl": submitted["totalShippingTaxIncl"],
        "total_shipping_tax_excl": submitted["totalShippingTaxExcl"],
    }
    return {"needsRepair": True, "reasons": reasons, "patch": patch}


def load_submitted_log(path):
    if not os.path.exists(path):
        return {}
    with open(path) as f:
        return json.load(f)


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 get_order(id_order):
    return api_get(f"orders/{id_order}")["order"]


def get_order_carrier_rows(id_order):
    data = api_get("order_carriers", params={"filter[id_order]": id_order, "display": "full"})
    return data.get("order_carriers") or []


def stored_values_from(order, carrier_rows):
    row = carrier_rows[0] if carrier_rows else None
    return {
        "idCarrier": int(order.get("id_carrier") or 0),
        "totalShippingTaxIncl": float(order.get("total_shipping_tax_incl") or 0),
        "totalShippingTaxExcl": float(order.get("total_shipping_tax_excl") or 0),
        "hasOrderCarrierRow": row is not None,
    }


def add_order_carrier_row(id_order, submitted):
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_carriers",
        params={"output_format": "JSON"},
        json={"order_carrier": {
            "id_order": id_order,
            "id_carrier": submitted["idCarrier"],
            "shipping_cost_tax_excl": f"{submitted['totalShippingTaxExcl']:.6f}",
            "shipping_cost_tax_incl": f"{submitted['totalShippingTaxIncl']:.6f}",
        }},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def apply_order_patch(id_order, patch):
    order = get_order(id_order)
    order.update(patch)
    r = requests.put(
        f"{PRESTASHOP_URL}/api/orders/{id_order}",
        params={"output_format": "JSON"},
        json={"order": order},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def reapply_current_state(id_order, id_order_state):
    r = requests.post(
        f"{PRESTASHOP_URL}/api/order_histories",
        params={"output_format": "JSON"},
        json={"order_history": {"id_order": id_order, "id_order_state": id_order_state}},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    submitted_log = load_submitted_log(SUBMITTED_LOG_PATH)
    flagged = 0
    repaired = 0

    for id_order_str, submitted in submitted_log.items():
        id_order = int(id_order_str)
        order = get_order(id_order)
        carrier_rows = get_order_carrier_rows(id_order)
        stored = stored_values_from(order, carrier_rows)

        decision = decide_carrier_shipping_repair(submitted, stored)
        if not decision["needsRepair"]:
            continue

        flagged += 1
        log.warning(
            "Order %s carrier/shipping mismatch. reasons=%s before=%s after=%s",
            id_order, decision["reasons"], stored, decision["patch"],
        )

        if DRY_RUN:
            continue

        if not stored["hasOrderCarrierRow"]:
            add_order_carrier_row(id_order, submitted)
        apply_order_patch(id_order, decision["patch"])

        if order.get("current_state") and str(order.get("current_state")) == os.environ.get("PS_OS_ERROR", ""):
            reapply_current_state(id_order, order["current_state"])

        repaired += 1
        log.info("Repaired carrier/shipping on id_order=%s.", id_order)

    log.info("Done. %d order(s) flagged, %d repaired. DRY_RUN=%s", flagged, repaired, DRY_RUN)


if __name__ == "__main__":
    run()
repair-carrier-shipping.js
/**
 * Detect and repair PrestaShop orders that dropped their carrier or shipping cost.
 *
 * The orders webservice resource exposes id_carrier, total_shipping,
 * total_shipping_tax_incl/excl, and carrier_tax_rate as writable fields, but
 * Order::add() does not persist them as submitted. The order is actually built
 * from the referenced cart (id_cart), and the carrier and shipping amounts are
 * recalculated from the cart's own stored delivery option instead of trusted
 * from the POST body. The result is a created order whose id_carrier reverts
 * to 0 or the shop's default carrier, and whose total_shipping is recalculated
 * or dropped to 0, while a correctly-linked order_carriers row may or may not
 * exist.
 *
 * This is a long-standing, still-open core bug (PrestaShop/PrestaShop#19906,
 * duplicated by #32622), acknowledged by maintainers as a known backlog item.
 *
 * This script only logs the planned diff by default. It never writes to an
 * order or order_carriers unless DRY_RUN is explicitly false. Run against
 * orders you created recently, keyed by id_order in a local submitted-values
 * log. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/prestashop/webservice-order-drops-carrier-shipping/
 */
import { pathToFileURL } from "node:url";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";

const PRESTASHOP_URL = (process.env.PRESTASHOP_URL || "https://demo.example.com").replace(/\/+$/, "");
const PRESTASHOP_WS_KEY = process.env.PRESTASHOP_WS_KEY || "WSKEYDUMMY";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const SUBMITTED_LOG_PATH = process.env.SUBMITTED_LOG_PATH || "submitted_orders.json";
const PS_OS_ERROR = process.env.PS_OS_ERROR || "";

const EPSILON = 0.01;

/**
 * Pure decision logic, no I/O.
 *
 * submitted: { idCarrier, totalShippingTaxIncl, totalShippingTaxExcl }
 * stored: { idCarrier, totalShippingTaxIncl, totalShippingTaxExcl, hasOrderCarrierRow }
 *
 * Returns { needsRepair, reasons, patch }. patch is null unless needsRepair is
 * true, in which case it carries only the corrected order fields.
 */
export function decideCarrierShippingRepair(submitted, stored, epsilon = EPSILON) {
  const reasons = [];
  if (stored.idCarrier === 0 || stored.idCarrier !== submitted.idCarrier) {
    reasons.push("carrier_mismatch");
  }
  const inclOff = Math.abs(stored.totalShippingTaxIncl - submitted.totalShippingTaxIncl) > epsilon;
  const exclOff = Math.abs(stored.totalShippingTaxExcl - submitted.totalShippingTaxExcl) > epsilon;
  if ((inclOff || exclOff) && !reasons.includes("shipping_amount_mismatch")) {
    reasons.push("shipping_amount_mismatch");
  }
  if (!stored.hasOrderCarrierRow) {
    reasons.push("missing_order_carrier_row");
  }

  if (reasons.length === 0) {
    return { needsRepair: false, reasons: [], patch: null };
  }

  const patch = {
    id_carrier: submitted.idCarrier,
    total_shipping: submitted.totalShippingTaxIncl,
    total_shipping_tax_incl: submitted.totalShippingTaxIncl,
    total_shipping_tax_excl: submitted.totalShippingTaxExcl,
  };
  return { needsRepair: true, reasons, patch };
}

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

async function loadSubmittedLog(path) {
  if (!existsSync(path)) return {};
  const raw = await readFile(path, "utf8");
  return JSON.parse(raw);
}

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 getOrder(idOrder) {
  const data = await apiGet(`orders/${idOrder}`);
  return data.order;
}

async function getOrderCarrierRows(idOrder) {
  const data = await apiGet("order_carriers", { "filter[id_order]": idOrder, display: "full" });
  return data.order_carriers || [];
}

function storedValuesFrom(order, carrierRows) {
  const row = carrierRows.length > 0 ? carrierRows[0] : null;
  return {
    idCarrier: Number(order.id_carrier || 0),
    totalShippingTaxIncl: Number(order.total_shipping_tax_incl || 0),
    totalShippingTaxExcl: Number(order.total_shipping_tax_excl || 0),
    hasOrderCarrierRow: row !== null,
  };
}

async function addOrderCarrierRow(idOrder, submitted) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_carriers`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({
      order_carrier: {
        id_order: idOrder,
        id_carrier: submitted.idCarrier,
        shipping_cost_tax_excl: submitted.totalShippingTaxExcl.toFixed(6),
        shipping_cost_tax_incl: submitted.totalShippingTaxIncl.toFixed(6),
      },
    }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_carriers`);
  return res.json();
}

async function applyOrderPatch(idOrder, patch) {
  const order = await getOrder(idOrder);
  Object.assign(order, patch);
  const url = new URL(`${PRESTASHOP_URL}/api/orders/${idOrder}`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "PUT",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on PUT orders/${idOrder}`);
  return res.json();
}

async function reapplyCurrentState(idOrder, idOrderState) {
  const url = new URL(`${PRESTASHOP_URL}/api/order_histories`);
  url.searchParams.set("output_format", "JSON");
  const res = await fetch(url, {
    method: "POST",
    headers: { Authorization: basicAuthHeader(), "Content-Type": "application/json" },
    body: JSON.stringify({ order_history: { id_order: idOrder, id_order_state: idOrderState } }),
  });
  if (!res.ok) throw new Error(`PrestaShop ${res.status} on POST order_histories`);
  return res.json();
}

export async function run() {
  const submittedLog = await loadSubmittedLog(SUBMITTED_LOG_PATH);
  let flagged = 0;
  let repaired = 0;

  for (const [idOrderStr, submitted] of Object.entries(submittedLog)) {
    const idOrder = Number(idOrderStr);
    const order = await getOrder(idOrder);
    const carrierRows = await getOrderCarrierRows(idOrder);
    const stored = storedValuesFrom(order, carrierRows);

    const decision = decideCarrierShippingRepair(submitted, stored);
    if (!decision.needsRepair) continue;

    flagged++;
    console.warn(
      `Order ${idOrder} carrier/shipping mismatch. reasons=${decision.reasons.join(",")} before=${JSON.stringify(stored)} after=${JSON.stringify(decision.patch)}`
    );

    if (DRY_RUN) continue;

    if (!stored.hasOrderCarrierRow) {
      await addOrderCarrierRow(idOrder, submitted);
    }
    await applyOrderPatch(idOrder, decision.patch);

    if (order.current_state && String(order.current_state) === PS_OS_ERROR) {
      await reapplyCurrentState(idOrder, order.current_state);
    }

    repaired++;
    console.log(`Repaired carrier/shipping on id_order=${idOrder}.`);
  }

  console.log(`Done. ${flagged} order(s) flagged, ${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 orders get repaired and exactly what gets written back. Because we kept decide_carrier_shipping_repair pure, the tests need no network and no PrestaShop store. They just feed in plain dicts and check the answer.

test_webservice_carrier_repair.py
from repair_carrier_shipping import decide_carrier_shipping_repair


def submitted(**over):
    base = {"idCarrier": 3, "totalShippingTaxIncl": 5.00, "totalShippingTaxExcl": 4.17}
    base.update(over)
    return base


def stored(**over):
    base = {"idCarrier": 3, "totalShippingTaxIncl": 5.00, "totalShippingTaxExcl": 4.17,
            "hasOrderCarrierRow": True}
    base.update(over)
    return base


def test_no_repair_when_everything_agrees():
    result = decide_carrier_shipping_repair(submitted(), stored())
    assert result == {"needsRepair": False, "reasons": [], "patch": None}


def test_carrier_dropped_to_zero_is_flagged():
    result = decide_carrier_shipping_repair(submitted(), stored(idCarrier=0))
    assert result["needsRepair"] is True
    assert "carrier_mismatch" in result["reasons"]
    assert result["patch"]["id_carrier"] == 3


def test_carrier_reverted_to_different_id_is_flagged():
    result = decide_carrier_shipping_repair(submitted(idCarrier=7), stored(idCarrier=2))
    assert result["needsRepair"] is True
    assert "carrier_mismatch" in result["reasons"]
    assert result["patch"]["id_carrier"] == 7


def test_shipping_amount_recalculated_is_flagged():
    result = decide_carrier_shipping_repair(submitted(), stored(totalShippingTaxIncl=8.40))
    assert result["needsRepair"] is True
    assert result["reasons"] == ["shipping_amount_mismatch"]
    assert result["patch"]["total_shipping_tax_incl"] == 5.00


def test_missing_order_carrier_row_is_flagged():
    result = decide_carrier_shipping_repair(submitted(), stored(hasOrderCarrierRow=False))
    assert result["needsRepair"] is True
    assert result["reasons"] == ["missing_order_carrier_row"]


def test_multiple_reasons_can_combine():
    result = decide_carrier_shipping_repair(
        submitted(),
        stored(idCarrier=0, totalShippingTaxIncl=0.0, totalShippingTaxExcl=0.0, hasOrderCarrierRow=False),
    )
    assert set(result["reasons"]) == {"carrier_mismatch", "shipping_amount_mismatch", "missing_order_carrier_row"}


def test_small_rounding_difference_is_not_flagged():
    result = decide_carrier_shipping_repair(submitted(), stored(totalShippingTaxIncl=5.004))
    assert result["needsRepair"] is False


def test_patch_contains_only_corrected_fields():
    result = decide_carrier_shipping_repair(submitted(), stored(idCarrier=0))
    assert set(result["patch"].keys()) == {
        "id_carrier", "total_shipping", "total_shipping_tax_incl", "total_shipping_tax_excl",
    }
carrier-shipping-repair.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideCarrierShippingRepair } from "./repair-carrier-shipping.js";

const submitted = (over = {}) => ({
  idCarrier: 3, totalShippingTaxIncl: 5.00, totalShippingTaxExcl: 4.17,
  ...over,
});

const stored = (over = {}) => ({
  idCarrier: 3, totalShippingTaxIncl: 5.00, totalShippingTaxExcl: 4.17,
  hasOrderCarrierRow: true,
  ...over,
});

test("no repair when everything agrees", () => {
  const result = decideCarrierShippingRepair(submitted(), stored());
  assert.deepEqual(result, { needsRepair: false, reasons: [], patch: null });
});

test("carrier dropped to zero is flagged", () => {
  const result = decideCarrierShippingRepair(submitted(), stored({ idCarrier: 0 }));
  assert.equal(result.needsRepair, true);
  assert.ok(result.reasons.includes("carrier_mismatch"));
  assert.equal(result.patch.id_carrier, 3);
});

test("carrier reverted to a different id is flagged", () => {
  const result = decideCarrierShippingRepair(submitted({ idCarrier: 7 }), stored({ idCarrier: 2 }));
  assert.equal(result.needsRepair, true);
  assert.ok(result.reasons.includes("carrier_mismatch"));
  assert.equal(result.patch.id_carrier, 7);
});

test("shipping amount recalculated is flagged", () => {
  const result = decideCarrierShippingRepair(submitted(), stored({ totalShippingTaxIncl: 8.40 }));
  assert.equal(result.needsRepair, true);
  assert.deepEqual(result.reasons, ["shipping_amount_mismatch"]);
  assert.equal(result.patch.total_shipping_tax_incl, 5.00);
});

test("missing order_carrier row is flagged", () => {
  const result = decideCarrierShippingRepair(submitted(), stored({ hasOrderCarrierRow: false }));
  assert.equal(result.needsRepair, true);
  assert.deepEqual(result.reasons, ["missing_order_carrier_row"]);
});

test("multiple reasons can combine", () => {
  const result = decideCarrierShippingRepair(
    submitted(),
    stored({ idCarrier: 0, totalShippingTaxIncl: 0, totalShippingTaxExcl: 0, hasOrderCarrierRow: false })
  );
  assert.deepEqual(
    new Set(result.reasons),
    new Set(["carrier_mismatch", "shipping_amount_mismatch", "missing_order_carrier_row"])
  );
});

test("small rounding difference is not flagged", () => {
  const result = decideCarrierShippingRepair(submitted(), stored({ totalShippingTaxIncl: 5.004 }));
  assert.equal(result.needsRepair, false);
});

test("patch contains only corrected fields", () => {
  const result = decideCarrierShippingRepair(submitted(), stored({ idCarrier: 0 }));
  assert.deepEqual(
    new Set(Object.keys(result.patch)),
    new Set(["id_carrier", "total_shipping", "total_shipping_tax_incl", "total_shipping_tax_excl"])
  );
});

Case studies

ERP integration

The warehouse system that submitted a real carrier every time

A merchant's ERP created orders through the webservice for phone orders, always sending the correct id_carrier and the exact shipping quote given to the customer. Weeks later, finance noticed the shipping revenue on those orders never matched what had been quoted, and some invoices showed the shop's default flat-rate carrier instead of the express courier the customer actually paid for.

Running the detection script against the ERP's own submission log turned up dozens of orders with carrier_mismatch and shipping_amount_mismatch. The repair added the missing order_carriers rows and PUT the correct carrier and shipping cost back, and the invoices finally matched what was quoted.

Payment error state

The order that failed for no visible reason

A mobile app created an order via the webservice with a nonzero shipping cost, but the created order came back with total_shipping_tax_incl at 0.00. Because total_paid no longer reconciled with total_products + total_shipping, the order tripped into a payment error state and support had no obvious explanation for the customer.

The script flagged the order with shipping_amount_mismatch and missing_order_carrier_row, repaired both, and only then re-applied the order's current state through order_histories now that the totals genuinely reconciled, moving it out of the false error state.

What good looks like

After this runs against orders you create through the webservice, a dropped or recalculated carrier and shipping cost gets caught immediately instead of surfacing weeks later in a finance reconciliation. Every flagged order gets an exact before and after diff, the missing order_carriers row is filled in, the order's own fields match what was actually charged, and false payment error states clear only once the totals genuinely agree.

FAQ

Why does an order created through the webservice lose its carrier and shipping cost?

The orders resource lets you submit id_carrier and total_shipping fields, but PrestaShop's Order::add() does not persist them as submitted. It builds the order from the referenced cart instead, and recalculates the carrier and shipping amounts from the cart's own stored delivery option, so the values you posted are overwritten or dropped.

Is this a known PrestaShop bug or a configuration mistake?

It is a known, still-open core bug, tracked as PrestaShop/PrestaShop issue 19906 and duplicated by issue 32622. Maintainers have acknowledged it as a backlog item. It is not something you can fix by changing webservice settings or permissions.

How do I safely repair an order whose carrier or shipping cost was dropped?

Compare the stored id_carrier and total_shipping_tax_incl and total_shipping_tax_excl against the values you originally submitted when creating the order. If they disagree, or the order_carriers row for that order is missing, add or correct the order_carriers row and PUT the full order representation back with the corrected carrier and shipping fields, then only touch order_histories if the mismatch had pushed the order into a payment error state.

Related field notes

Citations

On the problem:

  1. PrestaShop/PrestaShop GitHub issue #19906: WebService API, create new order, id_carrier and total_shipping problem. github.com/PrestaShop/PrestaShop/issues/19906
  2. PrestaShop/PrestaShop GitHub issue #32622: WebService API, create new order, id_carrier and total_shipping ignored (duplicate). github.com/PrestaShop/PrestaShop/issues/32622
  3. PrestaShop Forums: WebService API, create new order, id_carrier and total_shipping is ignored. prestashop.com/forums/topic/1026268

On the solution:

  1. PrestaShop Developer Documentation: Orders webservice resource. devdocs.prestashop-project.org/8/webservice/resources/orders/
  2. PrestaShop Developer Documentation: Order carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/order_carriers/
  3. PrestaShop Developer Documentation: Carriers webservice resource. devdocs.prestashop-project.org/8/webservice/resources/carriers/

Stuck on a tricky one?

If you have a problem in PrestaShop orders, totals, 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 catch a dropped shipping cost?

If this saved you a wrong invoice or an awkward payment error investigation, 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