Order tracking: every call to tracking-api returns 500 since 9.5.5.3 (missing `PayPal-Merchant-Id` header), then 422 on address/SKU comparison
Prerequisites
- I understand and accept the project's code of conduct.
- I have already searched in existing issues and found no previous report of this bug.
Describe the bug and add attachments
Summary
After upgrading from 9.5.1.1 to 9.5.5.3, 100% of shipment tracking calls (POST/PATCH /tracking-api/v1/trackers) failed with
{"statusCode":500,"message":"Internal server error"} — 685 calls out of 685 over eight days, ~280 orders without tracking on PayPal
(seller protection lost). order-api, payment-api and webhook-api worked fine with the same token and headers.
Root cause 1 (module): OrderShipmentTrackingConfigurationBuilder does not send the PayPal-Merchant-Id header, while
OrderHttpClientConfigurationBuilder, PaymentHttpClientConfigurationBuilder and WebhookHttpClientConfigurationBuilder do.
The tracking-api on api-live.checkout.prestashop.com requires it and crashes (500 instead of 4xx) when it is missing.
Evidence: the same PATCH replayed with curl — without the header 500 (cf-ray a3c83863f98516a3), with the header 422 "No fields
provided for update in the tracker" on a minimal body (cf-ray a3c860dcdad1ee82), i.e. auth and lookup passed. The .env change from
api-live-v2 to api-live is irrelevant: both hosts are served by the same service (identical 500 on both).
Fix: add 'PayPal-Merchant-Id' => $this->configuration->get(PayPalConfiguration::PS_CHECKOUT_PAYPAL_ID_MERCHANT) to the tracking
builder (2 lines). main still lacks it.
Root cause 2 (module + service): once the header is present, the service compares the address node literally with the PayPal
order's shipping address (PayPal normalizes it: upper case city, state name instead of ISO code) and answers
422 Address mismatch for property 'admin_area_2'; omitting the node gives 422 address must be a non-empty object.
ShipmentProcessor::getOrderDeliveryAddress() builds the node from the PrestaShop delivery address, so ~28% of our POSTs failed
(56 of 202 in the backlog, both home delivery and pickup points). Fix: inject PayPalOrderProviderInterface (cached) into
ShipmentProcessor and use purchase_units[0].shipping.address from the PayPal order when available (fallback to the PS address).
Verified: same POST with the PayPal address → 201 (cf-ray a3c9131fae40ee81).
Root cause 3 (module): the service also validates item SKUs against the PayPal order (422 Invalid items: …).
TrackingItemsNodeBuilder::resolveSku() prefers the current product/combination reference, but the PayPal order was created with
the reference at order time; catalog updates change references (in our September orders: PayPal SKU == current reference 420/487,
== order_detail.product_reference 484/487). Fix: pass product_reference from the order line in
ShipmentProcessor::getProductsFromOrderCarrier() and prefer it in resolveSku().
Side notes for the platform team
- A missing header should be a 4xx, not an unhandled 500.
- The address comparison is case/format sensitive against PayPal-normalized data; a merchant cannot match it from its own data.
- Orders created without a shipping address on PayPal cannot receive a tracker at all (
400 Shipping address is missing).
Patch against the 9.5.5.3 build (paths are inside the built module; in the monorepo they map to api/, core/ and ps9/config/shared/, and the yml change applies to ps8/ps17 as well). Running in production on our shop since 2026-09-17 (266 trackers sent without server-side errors).
diff -ruN orig/config/shared/process.yml live/config/shared/process.yml
--- a/config/shared/process.yml
+++ b/config/shared/process.yml
@@ -129,6 +129,7 @@
- '@PsCheckout\Core\PayPal\ShippingTracking\Service\TrackingDatabaseHandler'
- '@PsCheckout\Infrastructure\Repository\CountryRepository'
- '@PsCheckout\Infrastructure\Repository\StateRepository'
+ - '@PsCheckout\Core\PayPal\Order\Provider\PayPalOrderProvider'
# Shipping Tracking Processor
PsCheckout\Core\PayPal\ShippingTracking\Processor\ShipmentProcessorInterface:
@@ -143,6 +144,7 @@
- '@PsCheckout\Core\PayPal\ShippingTracking\Service\TrackingDatabaseHandler'
- '@PsCheckout\Infrastructure\Repository\CountryRepository'
- '@PsCheckout\Infrastructure\Repository\StateRepository'
+ - '@PsCheckout\Core\PayPal\Order\Provider\PayPalOrderProvider'
# External Shipping Tracking Processor
PsCheckout\Core\PayPal\ShippingTracking\Processor\ExternalShipmentProcessor:
diff -ruN orig/vendor/invertus/api/src/Http/Configuration/OrderShipmentTrackingConfigurationBuilder.php live/vendor/invertus/api/src/Http/Configuration/OrderShipmentTrackingConfigurationBuilder.php
--- a/vendor/invertus/api/src/Http/Configuration/OrderShipmentTrackingConfigurationBuilder.php
+++ b/vendor/invertus/api/src/Http/Configuration/OrderShipmentTrackingConfigurationBuilder.php
@@ -20,6 +20,7 @@
namespace PsCheckout\Api\Http\Configuration;
+use PsCheckout\Core\Settings\Configuration\PayPalConfiguration;
use PsCheckout\Infrastructure\Adapter\ConfigurationInterface;
use PsCheckout\Infrastructure\Adapter\LinkInterface;
use PsCheckout\Infrastructure\Environment\EnvInterface;
@@ -78,6 +79,7 @@
'Checkout-Bn-Code' => $this->env->getBnCode(),
'Checkout-Module-Version' => $this->moduleVersion,
'Checkout-Prestashop-Version' => _PS_VERSION_,
+ 'PayPal-Merchant-Id' => $this->configuration->get(PayPalConfiguration::PS_CHECKOUT_PAYPAL_ID_MERCHANT),
],
];
diff -ruN orig/vendor/invertus/core/src/PayPal/ShippingTracking/Builder/Node/TrackingItemsNodeBuilder.php live/vendor/invertus/core/src/PayPal/ShippingTracking/Builder/Node/TrackingItemsNodeBuilder.php
--- a/vendor/invertus/core/src/PayPal/ShippingTracking/Builder/Node/TrackingItemsNodeBuilder.php
+++ b/vendor/invertus/core/src/PayPal/ShippingTracking/Builder/Node/TrackingItemsNodeBuilder.php
@@ -139,11 +139,16 @@
*/
private function resolveSku(array $product, array $productData): string
{
+ // Prefer the order-line reference (= the SKU the PayPal order was created with), then the current product data.
+ if (!empty($product['reference'])) {
+ return $product['reference'];
+ }
+
if (!empty($productData['sku'])) {
return $productData['sku'];
}
- $sku = $product['reference'] ?? '';
+ $sku = '';
if (empty($sku)) {
$this->logger->warning('No SKU/reference found for product, sku field will be omitted from tracking payload.', [
diff -ruN orig/vendor/invertus/core/src/PayPal/ShippingTracking/Processor/ShipmentProcessor.php live/vendor/invertus/core/src/PayPal/ShippingTracking/Processor/ShipmentProcessor.php
--- a/vendor/invertus/core/src/PayPal/ShippingTracking/Processor/ShipmentProcessor.php
+++ b/vendor/invertus/core/src/PayPal/ShippingTracking/Processor/ShipmentProcessor.php
@@ -24,6 +24,7 @@
use Order;
use OrderCarrier;
use OrderInvoice;
+use PsCheckout\Core\PayPal\Order\Provider\PayPalOrderProviderInterface;
use PsCheckout\Core\PayPal\ShippingTracking\Builder\TrackingPayloadBuilderInterface;
use PsCheckout\Core\PayPal\ShippingTracking\Cache\ShippingTrackingCacheInterface;
use PsCheckout\Core\PayPal\ShippingTracking\Repository\ShippingTrackingRepositoryInterface;
@@ -87,6 +88,11 @@
*/
private $stateRepository;
+ /**
+ * @var PayPalOrderProviderInterface
+ */
+ private $payPalOrderProvider;
+
public function __construct(
OrderTrackerValidatorInterface $orderTrackerValidator,
TrackingPayloadBuilderInterface $payloadBuilder,
@@ -96,7 +102,8 @@
TrackingApiService $trackingApiService,
TrackingDatabaseHandler $trackingDatabaseHandler,
CountryRepositoryInterface $countryRepository,
- StateRepositoryInterface $stateRepository
+ StateRepositoryInterface $stateRepository,
+ PayPalOrderProviderInterface $payPalOrderProvider
) {
$this->orderTrackerValidator = $orderTrackerValidator;
$this->payloadBuilder = $payloadBuilder;
@@ -107,6 +114,7 @@
$this->trackingDatabaseHandler = $trackingDatabaseHandler;
$this->countryRepository = $countryRepository;
$this->stateRepository = $stateRepository;
+ $this->payPalOrderProvider = $payPalOrderProvider;
}
/**
@@ -125,6 +133,10 @@
$payPalOrder = $orderData['paypal_order'];
$capture = $orderData['capture'];
+ // The tracking service compares the address node literally with the PayPal order's shipping address (which PayPal
+ // normalizes) and rejects a missing node with 422: send PayPal's own address when available.
+ $address = $this->getPayPalShippingAddress($payPalOrder->getId()) ?: $address;
+
// Get products from order invoice
$products = $this->getProductsFromOrderCarrier($orderCarrier);
@@ -235,7 +247,9 @@
$products[] = [
'id_product' => (int) $product['id_product'],
'id_product_attribute' => (int) ($product['product_attribute_id'] ?? 0),
- 'reference' => $product['reference'] ?? '',
+ // The SKU must be the one the PayPal order was created with, i.e. the reference stored on the order line
+ // (order_detail.product_reference), not the current catalog reference.
+ 'reference' => !empty($product['product_reference']) ? $product['product_reference'] : ($product['reference'] ?? ''),
'quantity' => (int) $product['product_quantity'],
'name' => $product['product_name'] ?? '',
];
@@ -287,6 +301,28 @@
}
}
+ /**
+ * Shipping address as PayPal knows it (from the cached provider: no API call when the COMPLETED order is already
+ * cached). Empty when unavailable: the PrestaShop delivery address is used as before.
+ *
+ * @param string $payPalOrderId
+ *
+ * @return array
+ */
+ private function getPayPalShippingAddress(string $payPalOrderId): array
+ {
+ try {
+ $purchaseUnits = $this->payPalOrderProvider->getById($payPalOrderId)->getPurchaseUnits();
+ $address = $purchaseUnits[0]['shipping']['address'] ?? [];
+
+ return is_array($address) ? array_filter($address, static function ($v) { return is_string($v) && $v !== ''; }) : [];
+ } catch (\Exception $e) {
+ $this->logger->warning('PayPal shipping address not available for order ' . $payPalOrderId . ': ' . $e->getMessage());
+
+ return [];
+ }
+ }
+
/**
* @param Order $order
*Module http logs (ps_checkout-1-http-*) and the cf-ray ids above are available on request.
Steps to reproduce
PrestaShop 9.0.2 with ps_checkout 9.5.5.3, live mode, an order paid with PayPal and shipped (a carrier with a tracking number).
Set the order to a shipped state, or use the "Update tracking" action in the order page. The module calls
POST/PATCH https://api-live.checkout.prestashop.com/tracking-api/v1/trackers.Look at the module http log (
ps_checkout-1-http-*.log): every call answers
{"statusCode":500,"message":"Internal server error"}In our shop this was 685 calls out of 685 over eight days, i.e. ~280 orders left without tracking on PayPal.
To confirm the cause, replay the very same PATCH with curl, using the module's own token:
- without the
PayPal-Merchant-Idheader → 500 (cf-ray a3c83863f98516a3) - with
PayPal-Merchant-Id: <your merchant id>added → 422 "No fields provided for update in the tracker" on a minimal body (cf-ray a3c860dcdad1ee82)
The 422 proves authentication and tracker lookup succeeded: the only difference is the missing header, which the other builders (
OrderHttpClientConfigurationBuilder,PaymentHttpClientConfigurationBuilder,WebhookHttpClientConfigurationBuilder) do send.- without the
After adding the header,
POSTcalls still fail on part of the orders:- with the
addressnode built from the PrestaShop delivery address → 422Address mismatch for property 'admin_area_2'(PayPal normalizes the address: upper case city, state name instead of ISO code) - omitting the node → 422
address must be a non-empty object - with the address taken from
purchase_units[0].shipping.addressof the PayPal order → 201 (cf-ray a3c9131fae40ee81)
In our backlog this affected 56 POSTs out of 202 (~28%), both home delivery and pickup points.
- with the
Some orders then fail on SKUs: 422
Invalid items: ….TrackingItemsNodeBuilder::resolveSku()uses the current product/combination reference, while the PayPal order carries the reference as it was at order time. In our September orders the PayPal SKU matched the current reference in 420 lines out of 487, andorder_detail.product_referencein 484 out of 487.
Expected behavior
Shipment tracking reaches PayPal: the module sends the tracking number successfully and the order shows the tracking information on the PayPal side (which is also what keeps seller protection).
Specifically:
OrderShipmentTrackingConfigurationBuildershould send thePayPal-Merchant-Idheader, like the other three http client configuration builders already do;- the
addressnode should be built from the PayPal order (purchase_units[0].shipping.address) rather than from the PrestaShop address, because the service compares it literally against PayPal-normalized data that a merchant cannot reproduce from its own database; - the item SKU should be the reference stored on the order line (
order_detail.product_reference), i.e. the one the PayPal order was created with, not the current catalogue reference.
Side notes for the platform team:
- a missing required header should produce a 4xx, not an unhandled 500;
- orders created without a shipping address on PayPal cannot receive a tracker at all (
400 Shipping address is missing): it would help if the API said so explicitly.
Actual Result
No response
PrestaShop version where the bug happens
PrestaShop 9.0.2 — module ps_checkout 9.5.5.3
How have you installed PrestaShop
No response
PHP version(s) where the bug happened
PHP 8.3.33
Your company or customer's name goes here (if applicable).
Massimo Manarini
Source: PrestaShop/PrestaShop