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.
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.
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:
- The frontend fetches
availableShippingMethodsonce inside the same GraphQL response ascheckoutCreateand stores it in component state, then only re-renders that stored list after later mutations instead of re-querying it. - An address mutation is fired and forgotten, its response is used only to confirm the address saved, and the shipping method field is never included in that mutation's selection set or in a follow-up query.
- A previously selected
deliveryMethodis kept selected across the address change because nothing checks whether that method still shows up in the new list, so checkout proceeds with a shipping option tied to the old country. - Draft orders and admin-side flows hit the same shape: saleor-dashboard#533 reports shipping options not refreshing after an order detail change, needing a manual page refresh to show the right ones.
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.
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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
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.
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"]
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;
}
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.
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}
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 };
}
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.
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"]
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;
}
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.
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.
"""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)
/**
* 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.
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"]}
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
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.
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.
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:
- checkoutCreate's available shipping methods is not taking the new shipping address. Issue #3986, saleor/saleor. github.com/saleor/saleor/issues/3986
- Available shipping methods not updated when creating draft order. Issue #533, saleor/saleor-dashboard. github.com/saleor/saleor-dashboard/issues/533
- Insufficient stock at checkoutShippingAddressUpdate Mutation. Issue #8257, saleor/saleor. github.com/saleor/saleor/issues/8257
On the solution:
- 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
- Saleor Docs: Upgrading from 3.22 to 3.23, deliveryOptionsCalculate and CheckoutProblemDeliveryMethodStale. docs.saleor.io/upgrade-guides/core/3-22-to-3-23
- 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.
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