Skip to content

Reconciler Shipping & Warehouses

Shipping methods do not refresh after address change

The customer changes their shipping address. Saleor updates the checkout, the new country is right there in the response. But the shipping method list on screen does not move. It still shows the options for the old country, or an empty list, and the checkout can complete with a delivery method that no longer makes sense for where the order is actually going. Here is why the read goes stale even though the server did its job, and a script that catches it before it reaches an order.

Python and Node.js Saleor GraphQL API Safe by default (dry run)
A support agent with a headset
Photo by Vagaro on Unsplash
The short answer

Saleor resolves checkout.availableShippingMethods by looking at eligible ShippingZones for the checkout's current shipping address country, then layers two sync-webhook-backed caches on top: an external listing cache with roughly a twelve hour TTL, and a filtered-methods cache with roughly a three minute TTL. checkoutShippingAddressUpdate does invalidate both caches on the server. The stale read is a client problem: a client that fetched availableShippingMethods once and never re-requests it after the address changes keeps rendering the old list. Run a Python or Node.js script that re-queries the checkout fresh after every address or line change, checks whether the selected method is still valid, and only reselects a method under a dry run guard. Full code, tests, and citations are below.

The problem in plain words

When a checkout's shipping address changes country, or moves into a region a different zone covers, the set of shipping methods that should be available changes with it. Saleor's server side handles this correctly: checkoutShippingAddressUpdate invalidates the SHIPPING_LIST_METHODS_FOR_CHECKOUT and CHECKOUT_FILTER_SHIPPING_METHODS caches, so the next fresh query for that checkout returns methods scoped to the new address.

The trouble is that a lot of frontends never make that next fresh query. They fetch availableShippingMethods once, often in the same response as checkoutCreate or bundled with the address mutation, and then hold onto that list in local state. The address mutation succeeds, the country on the checkout is genuinely updated, but the shipping method list the customer sees on screen was never asked to update, so it shows options for a country the checkout is no longer shipping to.

checkoutCreate fetches methods once Address changes new country in response Saleor invalidates caches server side client never re-asks Old list stays in local state Screen shows wrong methods
Saleor's server correctly invalidates its shipping caches on address change. The screen goes stale because the client that rendered the list was never asked to look again.

Why it happens

Saleor layers two sync-webhook-backed caches on top of the shipping zone lookup: SHIPPING_LIST_METHODS_FOR_CHECKOUT, an external listing cache with roughly a twelve hour TTL, and CHECKOUT_FILTER_SHIPPING_METHODS, a filtered-methods cache with roughly a three minute TTL. Both get invalidated correctly by checkoutShippingAddressUpdate. A few common ways stores still end up staring at a stale list anyway:

This is exactly the pattern reported in saleor/saleor#3986, where checkoutCreate with a non-default address returned no shipping methods because the address on the checkout had not been accounted for in the same call. See the citations at the end for the exact issues and docs.

The key insight

This is not data corruption on Saleor's side, so it is not something to auto-fix on the backend. It is a client-side stale read. The safe response is to force a fresh, cache-busting fetch and compare it against what is currently selected, not to reach in and rewrite checkout data on a hunch. Only once a fresh read proves the selected method is genuinely gone from the new list is a reselect justified, and even then it should be logged and gated, because it changes what the order will cost.

The fix, as a flow

After any checkoutShippingAddressUpdate, checkoutBillingAddressUpdate, or checkoutLinesUpdate call, immediately re-fetch the checkout fresh, on Saleor 3.23+ with the explicit deliveryOptionsCalculate mutation, or on older cores with a cache-busting re-query of availableShippingMethods and deliveryMethod. Feed the previous method id and the fresh list into one pure decision function. If it says stale, log the pair and only call checkoutDeliveryMethodUpdate for real once DRY_RUN is off.

Address or lines mutation completes Re-fetch fresh deliveryOptionsCalculate decideStaleShipping old id, new list, problems Still valid? yes, nothing to do no, stale Log pair, reselect only if DRY_RUN false
Every mutation that could shift eligible shipping methods is followed by a fresh re-fetch. A pure decision function says whether the checkout is stale, and only then, under a dry run guard, does a reselect happen.

Build it step by step

1

Get an app token with checkout scopes

Create an app in the Saleor dashboard, or use tokenCreate with staff credentials, and grant it permission to manage checkouts. Keep the API URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true"   # start safe, change to false to reselect for real
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false to reselect for real
2

Talk to the Saleor GraphQL endpoint

Every call goes to the single GraphQL endpoint with your token in the Authorization: Bearer header. A small helper sends a query and returns the data, and raises if Saleor reports an error.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

Re-fetch the checkout fresh after the address changes

Run checkoutShippingAddressUpdate, then immediately re-query the checkout in a fresh, non-cached request. Ask for the current shipping address country, the fresh availableShippingMethods and shippingMethods, the problems field, and the currently selected deliveryMethod. This is the whole picture the decision needs.

step3.py
ADDRESS_UPDATE = """
mutation($id: ID!, $address: AddressInput!) {
  checkoutShippingAddressUpdate(id: $id, shippingAddress: $address) {
    checkout { id shippingAddress { country { code } } }
    errors { field message }
  }
}"""

FRESH_CHECKOUT_QUERY = """
query($id: ID!) {
  checkout(id: $id) {
    shippingAddress { country { code } }
    availableShippingMethods { id name }
    shippingMethods { id name }
    problems {
      __typename
      ... on CheckoutProblemDeliveryMethodStale { __typename }
      ... on CheckoutProblemDeliveryMethodInvalid { __typename }
    }
    deliveryMethod { ... on ShippingMethod { id } }
  }
}"""

def update_address_then_refetch(checkout_id, address):
    gql(ADDRESS_UPDATE, {"id": checkout_id, "address": address})
    return gql(FRESH_CHECKOUT_QUERY, {"id": checkout_id})["checkout"]
step3.js
const ADDRESS_UPDATE = `
mutation($id: ID!, $address: AddressInput!) {
  checkoutShippingAddressUpdate(id: $id, shippingAddress: $address) {
    checkout { id shippingAddress { country { code } } }
    errors { field message }
  }
}`;

const FRESH_CHECKOUT_QUERY = `
query($id: ID!) {
  checkout(id: $id) {
    shippingAddress { country { code } }
    availableShippingMethods { id name }
    shippingMethods { id name }
    problems {
      __typename
      ... on CheckoutProblemDeliveryMethodStale { __typename }
      ... on CheckoutProblemDeliveryMethodInvalid { __typename }
    }
    deliveryMethod { ... on ShippingMethod { id } }
  }
}`;

async function updateAddressThenRefetch(checkoutId, address) {
  await gql(ADDRESS_UPDATE, { id: checkoutId, address });
  return (await gql(FRESH_CHECKOUT_QUERY, { id: checkoutId })).checkout;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the previously selected method id, the fresh methods list, and the fresh problems array, and returns whether the checkout is stale and what to reselect. A pure function like this is easy to read and easy to test, which we do later. It is stale when the old method id is set but missing from the fresh list, or when the problems array reports CheckoutProblemDeliveryMethodStale or CheckoutProblemDeliveryMethodInvalid. The replacement is the first eligible fresh method, or none if the fresh list is empty, and if it is not stale the old id is simply kept.

decide.py
STALE_TYPENAMES = {"CheckoutProblemDeliveryMethodStale", "CheckoutProblemDeliveryMethodInvalid"}

def decide_stale_shipping(old_method_id, fresh_methods, problems):
    fresh_ids = [m["id"] for m in fresh_methods]
    has_stale_problem = any(p.get("__typename") in STALE_TYPENAMES for p in (problems or []))
    is_stale = (old_method_id is not None and old_method_id not in fresh_ids) or has_stale_problem
    if is_stale:
        replacement_id = fresh_ids[0] if fresh_ids else None
    else:
        replacement_id = old_method_id
    return {"isStale": is_stale, "replacementId": replacement_id}
decide.js
const STALE_TYPENAMES = new Set(["CheckoutProblemDeliveryMethodStale", "CheckoutProblemDeliveryMethodInvalid"]);

export function decideStaleShipping(oldMethodId, freshMethods, problems) {
  const freshIds = freshMethods.map((m) => m.id);
  const hasStaleProblem = (problems || []).some((p) => STALE_TYPENAMES.has(p.__typename));
  const isStale = (oldMethodId != null && !freshIds.includes(oldMethodId)) || hasStaleProblem;
  const replacementId = isStale ? (freshIds[0] ?? null) : oldMethodId;
  return { isStale, replacementId };
}
5

Reselect only under a dry run guard

When the decision says stale, log the checkout id, the old method id, and the proposed new method id before doing anything. Only call checkoutDeliveryMethodUpdate when DRY_RUN is explicitly false. Reassigning a shipping method changes order cost, so this step should always be auditable, never silent.

apply.py
DELIVERY_METHOD_UPDATE = """
mutation($id: ID!, $methodId: ID) {
  checkoutDeliveryMethodUpdate(id: $id, deliveryMethodId: $methodId) {
    checkout { id deliveryMethod { ... on ShippingMethod { id } } }
    errors { field message }
  }
}"""

def reselect_delivery_method(checkout_id, new_method_id):
    result = gql(DELIVERY_METHOD_UPDATE, {"id": checkout_id, "methodId": new_method_id})["checkoutDeliveryMethodUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["checkout"]
apply.js
const DELIVERY_METHOD_UPDATE = `
mutation($id: ID!, $methodId: ID) {
  checkoutDeliveryMethodUpdate(id: $id, deliveryMethodId: $methodId) {
    checkout { id deliveryMethod { ... on ShippingMethod { id } } }
    errors { field message }
  }
}`;

async function reselectDeliveryMethod(checkoutId, newMethodId) {
  const result = (await gql(DELIVERY_METHOD_UPDATE, { id: checkoutId, methodId: newMethodId })).checkoutDeliveryMethodUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.checkout;
}
6

Wire it together with a dry run guard

The run loop ties every piece together. It re-fetches the checkout fresh, runs the pure decision function, and logs the {checkoutId, oldMethodId, newMethodId} pair whenever it finds a stale one. On the first few runs leave DRY_RUN on so it only reports what it would reselect. Read the output, agree with it, then switch it off to let it write for real.

Run it safe

Always start with DRY_RUN=true. This script never rewrites checkout data on its own. It only reports which checkouts are stale, and it only calls checkoutDeliveryMethodUpdate once DRY_RUN is explicitly set to false, with the exact pair it is about to change logged first.

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 only ever reselects a shipping method for a checkout it just confirmed is stale.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
reconcile_stale_shipping.py
"""Detect and reconcile Saleor checkouts with a stale shipping method after an address change.

checkoutShippingAddressUpdate invalidates Saleor's shipping caches on the server, but a
client that only fetched availableShippingMethods once, at checkoutCreate or in the same
response as the address mutation, never re-fetches, so the screen keeps showing the
pre-update list. This re-fetches the checkout fresh, decides with a pure function whether
the previously selected method is still valid, and only ever reselects a method under a
DRY_RUN guard, logging the {checkoutId, oldMethodId, newMethodId} pair first.
"""
import os
import logging
import requests

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

API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

STALE_TYPENAMES = {"CheckoutProblemDeliveryMethodStale", "CheckoutProblemDeliveryMethodInvalid"}

FRESH_CHECKOUT_QUERY = """
query($id: ID!) {
  checkout(id: $id) {
    shippingAddress { country { code } }
    availableShippingMethods { id name }
    shippingMethods { id name }
    problems {
      __typename
      ... on CheckoutProblemDeliveryMethodStale { __typename }
      ... on CheckoutProblemDeliveryMethodInvalid { __typename }
    }
    deliveryMethod { ... on ShippingMethod { id } }
  }
}"""

DELIVERY_METHOD_UPDATE = """
mutation($id: ID!, $methodId: ID) {
  checkoutDeliveryMethodUpdate(id: $id, deliveryMethodId: $methodId) {
    checkout { id deliveryMethod { ... on ShippingMethod { id } } }
    errors { field message }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def decide_stale_shipping(old_method_id, fresh_methods, problems):
    """Pure decision: no I/O, fully unit-testable with fixture inputs."""
    fresh_ids = [m["id"] for m in fresh_methods]
    has_stale_problem = any(p.get("__typename") in STALE_TYPENAMES for p in (problems or []))
    is_stale = (old_method_id is not None and old_method_id not in fresh_ids) or has_stale_problem
    if is_stale:
        replacement_id = fresh_ids[0] if fresh_ids else None
    else:
        replacement_id = old_method_id
    return {"isStale": is_stale, "replacementId": replacement_id}


def fetch_fresh_checkout(checkout_id):
    return gql(FRESH_CHECKOUT_QUERY, {"id": checkout_id})["checkout"]


def reselect_delivery_method(checkout_id, new_method_id):
    result = gql(DELIVERY_METHOD_UPDATE, {"id": checkout_id, "methodId": new_method_id})["checkoutDeliveryMethodUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    return result["checkout"]


def reconcile_checkout(checkout_id):
    checkout = fetch_fresh_checkout(checkout_id)
    old_method = checkout.get("deliveryMethod") or {}
    old_method_id = old_method.get("id")
    fresh_methods = checkout.get("availableShippingMethods") or []
    problems = checkout.get("problems") or []

    decision = decide_stale_shipping(old_method_id, fresh_methods, problems)
    if not decision["isStale"]:
        return False

    log.warning(
        "Checkout %s stale shipping method. old=%s new=%s %s",
        checkout_id, old_method_id, decision["replacementId"],
        "would reselect" if DRY_RUN else "reselecting",
    )
    if not DRY_RUN and decision["replacementId"] is not None:
        reselect_delivery_method(checkout_id, decision["replacementId"])
    return True


def run(checkout_ids):
    fixed = 0
    for checkout_id in checkout_ids:
        if reconcile_checkout(checkout_id):
            fixed += 1
    log.info("Done. %d checkout(s) %s.", fixed, "to reconcile" if DRY_RUN else "reconciled")


if __name__ == "__main__":
    ids = [cid for cid in os.environ.get("CHECKOUT_IDS", "").split(",") if cid]
    run(ids)
reconcile-stale-shipping.js
/**
 * Detect and reconcile Saleor checkouts with a stale shipping method after an address change.
 *
 * checkoutShippingAddressUpdate invalidates Saleor's shipping caches on the server, but a
 * client that only fetched availableShippingMethods once, at checkoutCreate or in the same
 * response as the address mutation, never re-fetches, so the screen keeps showing the
 * pre-update list. This re-fetches the checkout fresh, decides with a pure function whether
 * the previously selected method is still valid, and only ever reselects a method under a
 * DRY_RUN guard, logging the {checkoutId, oldMethodId, newMethodId} pair first.
 *
 * Guide: https://www.allanninal.dev/saleor/shipping-methods-stale-after-address-change/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const STALE_TYPENAMES = new Set(["CheckoutProblemDeliveryMethodStale", "CheckoutProblemDeliveryMethodInvalid"]);

const FRESH_CHECKOUT_QUERY = `
query($id: ID!) {
  checkout(id: $id) {
    shippingAddress { country { code } }
    availableShippingMethods { id name }
    shippingMethods { id name }
    problems {
      __typename
      ... on CheckoutProblemDeliveryMethodStale { __typename }
      ... on CheckoutProblemDeliveryMethodInvalid { __typename }
    }
    deliveryMethod { ... on ShippingMethod { id } }
  }
}`;

const DELIVERY_METHOD_UPDATE = `
mutation($id: ID!, $methodId: ID) {
  checkoutDeliveryMethodUpdate(id: $id, deliveryMethodId: $methodId) {
    checkout { id deliveryMethod { ... on ShippingMethod { id } } }
    errors { field message }
  }
}`;

export function decideStaleShipping(oldMethodId, freshMethods, problems) {
  const freshIds = freshMethods.map((m) => m.id);
  const hasStaleProblem = (problems || []).some((p) => STALE_TYPENAMES.has(p.__typename));
  const isStale = (oldMethodId != null && !freshIds.includes(oldMethodId)) || hasStaleProblem;
  const replacementId = isStale ? (freshIds[0] ?? null) : oldMethodId;
  return { isStale, replacementId };
}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

async function fetchFreshCheckout(checkoutId) {
  return (await gql(FRESH_CHECKOUT_QUERY, { id: checkoutId })).checkout;
}

async function reselectDeliveryMethod(checkoutId, newMethodId) {
  const result = (await gql(DELIVERY_METHOD_UPDATE, { id: checkoutId, methodId: newMethodId })).checkoutDeliveryMethodUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  return result.checkout;
}

async function reconcileCheckout(checkoutId) {
  const checkout = await fetchFreshCheckout(checkoutId);
  const oldMethodId = checkout.deliveryMethod?.id ?? null;
  const freshMethods = checkout.availableShippingMethods || [];
  const problems = checkout.problems || [];

  const decision = decideStaleShipping(oldMethodId, freshMethods, problems);
  if (!decision.isStale) return false;

  console.warn(
    `Checkout ${checkoutId} stale shipping method. old=${oldMethodId} new=${decision.replacementId} `
    + `${DRY_RUN ? "would reselect" : "reselecting"}`
  );
  if (!DRY_RUN && decision.replacementId !== null) {
    await reselectDeliveryMethod(checkoutId, decision.replacementId);
  }
  return true;
}

export async function run(checkoutIds) {
  let fixed = 0;
  for (const checkoutId of checkoutIds) {
    if (await reconcileCheckout(checkoutId)) fixed++;
  }
  console.log(`Done. ${fixed} checkout(s) ${DRY_RUN ? "to reconcile" : "reconciled"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const ids = (process.env.CHECKOUT_IDS || "").split(",").filter(Boolean);
  run(ids).catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides whether a real checkout gets its shipping method silently swapped. Because we kept decide_stale_shipping pure, the test needs no network and no Saleor store. It just feeds in plain fixture data and checks the answer.

test_shipping_stale_decision.py
from reconcile_stale_shipping import decide_stale_shipping

METHOD_A = {"id": "U2hpcHBpbmdNZXRob2Q6MQ=="}
METHOD_B = {"id": "U2hpcHBpbmdNZXRob2Q6Mg=="}


def test_not_stale_when_old_method_still_in_fresh_list():
    result = decide_stale_shipping(METHOD_A["id"], [METHOD_A, METHOD_B], [])
    assert result == {"isStale": False, "replacementId": METHOD_A["id"]}


def test_stale_when_old_method_missing_from_fresh_list():
    result = decide_stale_shipping(METHOD_A["id"], [METHOD_B], [])
    assert result == {"isStale": True, "replacementId": METHOD_B["id"]}


def test_stale_with_no_replacement_when_fresh_list_empty():
    result = decide_stale_shipping(METHOD_A["id"], [], [])
    assert result == {"isStale": True, "replacementId": None}


def test_not_stale_when_old_method_id_is_none():
    result = decide_stale_shipping(None, [METHOD_A], [])
    assert result == {"isStale": False, "replacementId": None}


def test_stale_when_problems_report_delivery_method_stale():
    problems = [{"__typename": "CheckoutProblemDeliveryMethodStale"}]
    result = decide_stale_shipping(METHOD_A["id"], [METHOD_A], problems)
    assert result == {"isStale": True, "replacementId": METHOD_A["id"]}


def test_stale_when_problems_report_delivery_method_invalid():
    problems = [{"__typename": "CheckoutProblemDeliveryMethodInvalid"}]
    result = decide_stale_shipping(METHOD_A["id"], [METHOD_A, METHOD_B], problems)
    assert result == {"isStale": True, "replacementId": METHOD_A["id"]}


def test_unrelated_problem_types_do_not_trigger_staleness():
    problems = [{"__typename": "CheckoutProblemInsufficientStock"}]
    result = decide_stale_shipping(METHOD_A["id"], [METHOD_A], problems)
    assert result == {"isStale": False, "replacementId": METHOD_A["id"]}
shipping-stale-decision.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideStaleShipping } from "./reconcile-stale-shipping.js";

const METHOD_A = { id: "U2hpcHBpbmdNZXRob2Q6MQ==" };
const METHOD_B = { id: "U2hpcHBpbmdNZXRob2Q6Mg==" };

test("not stale when old method still in fresh list", () => {
  const result = decideStaleShipping(METHOD_A.id, [METHOD_A, METHOD_B], []);
  assert.deepEqual(result, { isStale: false, replacementId: METHOD_A.id });
});

test("stale when old method missing from fresh list", () => {
  const result = decideStaleShipping(METHOD_A.id, [METHOD_B], []);
  assert.deepEqual(result, { isStale: true, replacementId: METHOD_B.id });
});

test("stale with no replacement when fresh list empty", () => {
  const result = decideStaleShipping(METHOD_A.id, [], []);
  assert.deepEqual(result, { isStale: true, replacementId: null });
});

test("not stale when old method id is null", () => {
  const result = decideStaleShipping(null, [METHOD_A], []);
  assert.deepEqual(result, { isStale: false, replacementId: null });
});

test("stale when problems report delivery method stale", () => {
  const problems = [{ __typename: "CheckoutProblemDeliveryMethodStale" }];
  const result = decideStaleShipping(METHOD_A.id, [METHOD_A], problems);
  assert.deepEqual(result, { isStale: true, replacementId: METHOD_A.id });
});

test("stale when problems report delivery method invalid", () => {
  const problems = [{ __typename: "CheckoutProblemDeliveryMethodInvalid" }];
  const result = decideStaleShipping(METHOD_A.id, [METHOD_A, METHOD_B], problems);
  assert.deepEqual(result, { isStale: true, replacementId: METHOD_A.id });
});

test("unrelated problem types do not trigger staleness", () => {
  const problems = [{ __typename: "CheckoutProblemInsufficientStock" }];
  const result = decideStaleShipping(METHOD_A.id, [METHOD_A], problems);
  assert.deepEqual(result, { isStale: false, replacementId: METHOD_A.id });
});

Case studies

International storefront

The checkout that kept a domestic courier after an overseas address swap

A storefront let logged-in customers change their shipping address mid-checkout to ship a gift abroad. The frontend had fetched availableShippingMethods once at checkoutCreate and kept that list in a state store. The address mutation succeeded, the country field updated, but the shipping method dropdown never refreshed, so the customer could still submit checkout with a domestic-only courier attached to an international address.

Adding the fresh re-fetch and the pure decision function after every address mutation caught the mismatch immediately: deliveryMethod was no longer present in the new availableShippingMethods list. Running the reconciler in dry run first showed exactly which live checkouts were affected before any reselect went out for real.

Draft orders in the dashboard

The staff-created order stuck on a shipping option from the old address

Staff building a draft order in the Saleor dashboard changed the customer's address after building the cart, mirroring the exact pattern from saleor-dashboard#533. The shipping options shown in the dashboard did not update, and only a manual page refresh brought the correct list back, costing a few minutes on every order that needed an address correction.

Running the same detection logic against the draft order's underlying checkout surfaced the CheckoutProblemDeliveryMethodStale problem directly, which is now checked automatically as part of the order review step instead of relying on someone remembering to refresh the page.

What good looks like

After wiring the fresh re-fetch into every address and lines mutation, a checkout's shipping method list always reflects the country it is actually shipping to. Stale reselects are logged with the exact old and new method id before anything changes, dry run lets a human review the list first, and no order ships with a shipping method tied to an address the customer already left behind.

FAQ

Why do shipping methods not update after I change the checkout address?

checkoutShippingAddressUpdate does invalidate Saleor's shipping method caches on the server. The problem is the client: if it only fetched availableShippingMethods once, at checkoutCreate or in the same response as the address mutation, it never asks again, so the screen keeps showing the pre-update list even though a fresh query would return the right one.

How do I detect a stale shipping method on a Saleor checkout?

Re-query the checkout right after any address or line change and read three signals: whether the selected deliveryMethod is still present in the fresh availableShippingMethods list, whether problems contains CheckoutProblemDeliveryMethodStale or CheckoutProblemDeliveryMethodInvalid, and whether the shipping zones eligible for the new country actually match what came back. Any one of those firing means the checkout is stale.

Is it safe to auto reselect a shipping method when the old one goes stale?

Only with a human-auditable guard, because reselecting changes what the order costs. The safe pattern is to run in DRY_RUN by default, log the checkout id and the old and new method ids, and only call checkoutDeliveryMethodUpdate for real once DRY_RUN is explicitly turned off.

Related field notes

Citations

On the problem:

  1. checkoutCreate's available shipping methods is not taking the new shipping address. Issue #3986, saleor/saleor. github.com/saleor/saleor/issues/3986
  2. Available shipping methods not updated when creating draft order. Issue #533, saleor/saleor-dashboard. github.com/saleor/saleor-dashboard/issues/533
  3. Insufficient stock at checkoutShippingAddressUpdate Mutation. Issue #8257, saleor/saleor. github.com/saleor/saleor/issues/8257

On the solution:

  1. Saleor Docs: Shipping events, the SHIPPING_LIST_METHODS_FOR_CHECKOUT and CHECKOUT_FILTER_SHIPPING_METHODS synchronous webhooks. docs.saleor.io/developer/extending/webhooks/synchronous-events/shipping
  2. Saleor Docs: Upgrading from 3.22 to 3.23, deliveryOptionsCalculate and CheckoutProblemDeliveryMethodStale. docs.saleor.io/upgrade-guides/core/3-22-to-3-23
  3. Saleor Docs: the checkoutShippingMethodUpdate mutation. docs.saleor.io/api-reference/checkout/mutations/checkout-shipping-method-update

Fighting a Saleor bug right now?

If you have a problem in Saleor checkout, channels, shipping, or fulfillment 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 find your stale shipping method?

If this saved you a support thread or a checkout that shipped with the wrong courier, 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 Saleor field notes