Diagnostic Checkout & Stock Reservation
checkoutCreate returns a stale existing checkout
A shopper starts a new session, calls checkoutCreate, and gets back a cart with someone else's lines, an old voucher that no longer applies, or metadata from a session that ended hours ago. It looks like Saleor is silently reusing a checkout. It is not, at least not anymore. Saleor 3.x always inserts a fresh Checkout row. The stale cart is coming from one layer up, in the storefront or app that cached a checkout token and keeps replaying it. Here is why that confusion still happens and a script that detects the reused, stale checkouts so a human can fix the real cause.
In Saleor 2.x, checkoutCreate intentionally handed a logged in user back their existing open checkout instead of a new one, tracked in GitHub issue #6185, on the assumption that a logged in user has only one active checkout. That dedup logic is gone from Saleor 3.x main. Today checkoutCreate always inserts a new Checkout row, and the mutation's own created field is deprecated with the note "Always returns true." So a stale checkout showing up now is almost always the calling application replaying a saved token or id across logins, logouts, or promo changes instead of asking for a fresh one. Run a Python or Node.js script that pages through open checkouts with the checkouts query, flags the ones with an orphaned voucher, a delisted line, or a session mismatch, and reports them so a human can fix the client side token cache. Full code, tests, and a dry run guard are below.
The problem in plain words
Somewhere in a support queue or a bug report, this shows up: a shopper logs in, the storefront calls checkoutCreate, and the cart that comes back already has three items in it that were not just added. Or a promo code from last week's campaign is still applied. The instinct is to blame Saleor, because that is exactly what an older version of Saleor used to do on purpose.
Saleor's own maintainers confirmed the old behavior in 2020: a logged in user was assumed to have only one active checkout, so checkoutCreate would hand back that existing open checkout rather than create a second one. That was a deliberate simplification, not a bug, in Saleor 2.x. But it is not how current Saleor works. In 3.x main, checkoutCreate unconditionally inserts a new Checkout row every time it is called, and the deprecation note on the mutation's created field says plainly that it "Always returns true." Saleor itself is not reusing anything.
Why it happens
This is a client side caching problem wearing a Saleor costume. A few concrete ways it shows up:
- A storefront stores the checkout
tokenin a cookie with a long expiry, and never clears it on logout, so the next login rehydrates the previous shopper's cart on a shared browser or device. - Middleware or an app persists the checkout id in a session store keyed by something broader than the actual shopping session, so two logins from the same account within a short window get handed the same stale token.
- A promo campaign ends or a voucher's
usageLimitis exhausted, but the checkout'svoucherCodewas set before that and the client never callscheckoutCreatefresh, so the expired code rides along invoucherCodeordiscountName. - A product or variant is unpublished from a channel after it was added to a checkout, so the stored token still carries a line whose variant has no
channelListingsleft for that channel.
This confusion is easy to understand once you know the history. Saleor 2.x really did return a logged in user's existing open checkout on purpose, confirmed by maintainers on issue #6185. Teams who read about that behavior, or worked with an older Saleor version, reasonably assume it still applies. It does not. Current Saleor's checkoutCreate, per saleor/graphql/checkout/mutations/checkout_create.py, always inserts a new row, and the checkout lifecycle docs describe token based continuity as something the client manages, not something Saleor auto-dedupes for you.
You cannot fix this by patching Saleor, because Saleor is not doing anything wrong. The fix is to find where your own stack is caching a checkout token past its useful life, and treat any checkout carrying an orphaned voucher, a delisted line, or a mismatched session as a signal of that caching bug, not as something to silently rewrite. Report it, then fix the token cache.
The fix, as a flow
We do not touch a shopper's live checkout. We add a script that lists open checkouts, checks each one against the store's current vouchers and channel listings, and flags any checkout whose voucher is dead, whose line points at a delisted variant, or whose stored session metadata does not match what the app expected for a new session. Everything else is left alone.
Build it step by step
Get a Saleor auth token
Create a Saleor app token, or sign in with tokenCreate to get a staff JWT, with at least MANAGE_CHECKOUTS scope so it can read checkouts and their metadata. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://your-store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your app or staff token"
export MAX_IDLE_HOURS="24"
export DRY_RUN="true" # start safe, this script only ever reports
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://your-store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your app or staff token"
export MAX_IDLE_HOURS="24"
export DRY_RUN="true" // start safe, this script only ever reports
Talk to the Saleor GraphQL API
Everything is one endpoint, POST 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. We reuse this helper for every query and mutation below.
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;
}
List open checkouts with the fields the decision needs
Page through every open checkout with its token, timestamps, user, channel, voucher, lines, and metadata. This is the raw material for the decision: without voucherCode, lines, and metadata we have nothing to compare against current store state.
CHECKOUTS_QUERY = """
query($cursor: String) {
checkouts(first: 100, after: $cursor) {
edges {
node {
id
token
created
updatedAt
user { id email }
channel { slug }
voucherCode
discountName
lines { id quantity variant { id product { id } } }
metadata { key value }
totalPrice { gross { amount currency } }
}
}
pageInfo { hasNextPage endCursor }
}
}"""
def open_checkouts():
cursor = None
while True:
data = gql(CHECKOUTS_QUERY, {"cursor": cursor})["checkouts"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const CHECKOUTS_QUERY = `
query($cursor: String) {
checkouts(first: 100, after: $cursor) {
edges {
node {
id
token
created
updatedAt
user { id email }
channel { slug }
voucherCode
discountName
lines { id quantity variant { id product { id } } }
metadata { key value }
totalPrice { gross { amount currency } }
}
}
pageInfo { hasNextPage endCursor }
}
}`;
async function* openCheckouts() {
let cursor = null;
while (true) {
const data = (await gql(CHECKOUTS_QUERY, { cursor })).checkouts;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Check the checkout's voucher and lines against current store state
A checkout carrying a voucher only proves it was valid when it was applied. Look the code up now with the vouchers query and see if it is still active. Do the same for every line's variant with productVariant(channel:$slug){ channelListings }, since a missing listing means the product was delisted after the line was added.
VOUCHER_QUERY = """
query($code: String!) {
vouchers(first: 1, filter: { search: $code }) {
edges { node { code usageLimit used endDate } }
}
}"""
VARIANT_LISTING_QUERY = """
query($id: ID!, $channel: String!) {
productVariant(id: $id, channel: $channel) {
id
channelListings { channel { slug } }
}
}"""
def voucher_is_active(code, now_iso):
edges = gql(VOUCHER_QUERY, {"code": code})["vouchers"]["edges"]
if not edges:
return False
node = edges[0]["node"]
if node["endDate"] and node["endDate"] < now_iso:
return False
if node["usageLimit"] is not None and node["used"] >= node["usageLimit"]:
return False
return True
def variant_is_channel_listed(variant_id, channel_slug):
data = gql(VARIANT_LISTING_QUERY, {"id": variant_id, "channel": channel_slug})
variant = data.get("productVariant")
if not variant:
return False
return bool(variant.get("channelListings"))
const VOUCHER_QUERY = `
query($code: String!) {
vouchers(first: 1, filter: { search: $code }) {
edges { node { code usageLimit used endDate } }
}
}`;
const VARIANT_LISTING_QUERY = `
query($id: ID!, $channel: String!) {
productVariant(id: $id, channel: $channel) {
id
channelListings { channel { slug } }
}
}`;
async function voucherIsActive(code, nowIso) {
const edges = (await gql(VOUCHER_QUERY, { code })).vouchers.edges;
if (!edges.length) return false;
const node = edges[0].node;
if (node.endDate && node.endDate < nowIso) return false;
if (node.usageLimit !== null && node.used >= node.usageLimit) return false;
return true;
}
async function variantIsChannelListed(variantId, channelSlug) {
const data = await gql(VARIANT_LISTING_QUERY, { id: variantId, channel: channelSlug });
const variant = data.productVariant;
if (!variant) return false;
return Boolean(variant.channelListings && variant.channelListings.length);
}
Decide, with one pure function
Keep the decision in its own function that takes a plain, pre-fetched checkout shape and returns whether it is stale and why. A pure function like this is easy to read and easy to test, which we do later. It flags a session mismatch, an orphaned voucher, a delisted line, or a checkout that has sat idle far longer than your store's expected session length, and it never touches the network itself.
def classify_stale_checkout(checkout, max_idle_ms):
"""
Pure decision logic, no I/O. All data is pre-fetched.
checkout: {
"id": str, "token": str, "createdAt": str, "updatedAt": str,
"userEmail": str | None, "channelSlug": str, "voucherCode": str | None,
"lines": [{"variantId": str, "isChannelListed": bool}],
"expectedSessionId": str | None, "storedSessionMeta": str | None,
"voucherIsActive": bool | None, "now": str,
}
Returns {"stale": bool, "reasons": [str]}.
"""
reasons = []
expected = checkout.get("expectedSessionId")
if expected is not None and expected != checkout.get("storedSessionMeta"):
reasons.append("session_mismatch")
voucher_code = checkout.get("voucherCode")
if voucher_code is not None and checkout.get("voucherIsActive") is False:
reasons.append("orphaned_voucher")
lines = checkout.get("lines") or []
if any(not line.get("isChannelListed", True) for line in lines):
reasons.append("delisted_line")
now_ms = _to_epoch_ms(checkout["now"])
updated_ms = _to_epoch_ms(checkout["updatedAt"])
if (now_ms - updated_ms) > max_idle_ms:
reasons.append("long_idle")
return {"stale": len(reasons) > 0, "reasons": reasons}
def _to_epoch_ms(iso):
import datetime
return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000
export function classifyStaleCheckout(checkout, maxIdleMs) {
const reasons = [];
const expected = checkout.expectedSessionId;
if (expected !== undefined && expected !== null && expected !== checkout.storedSessionMeta) {
reasons.push("session_mismatch");
}
if (checkout.voucherCode != null && checkout.voucherIsActive === false) {
reasons.push("orphaned_voucher");
}
const lines = checkout.lines || [];
if (lines.some((line) => line.isChannelListed === false)) {
reasons.push("delisted_line");
}
const nowMs = Date.parse(checkout.now);
const updatedMs = Date.parse(checkout.updatedAt);
if (nowMs - updatedMs > maxIdleMs) {
reasons.push("long_idle");
}
return { stale: reasons.length > 0, reasons };
}
Wire it together, report only, dry run guard
The loop pulls every open checkout, resolves whether its voucher is still active and whether every line is still channel listed, feeds the plain shape into classify_stale_checkout, and logs one line per stale checkout with its reasons. There is no automatic mutation here. DRY_RUN is kept for consistency and to make intent explicit in logs, but even outside dry run this script only reports. If a human confirms a checkout is truly orphaned, the sanctioned repairs are a manual checkoutRemovePromoCode for a dead voucher, checkoutLinesDelete after re-verifying a delisted variant, or checkoutCustomerDetach plus fixing the client side token cache so a new checkoutCreate token gets minted per session instead of replayed.
This script only ever emits a report row per stale checkout with its reasons attached. It never calls checkoutLinesDelete, checkoutRemovePromoCode, or checkoutCustomerDetach itself. Those are human decisions, made after confirming there is no live session still using that checkout.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, and is safe to run again and again because it never writes anything, it only reports.
"""Flag Saleor checkouts that look like a stale, client side reused checkout
token rather than a fresh one from checkoutCreate.
Saleor 3.x always inserts a new Checkout row from checkoutCreate and never
auto-reuses one, so a stale checkout almost always means the storefront or app
kept replaying a saved token/id instead of asking for a new one.
Report only. Never edits a checkout or detaches a user. Safe to run again and again.
"""
import os
import datetime
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_stale_checkout")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
MAX_IDLE_HOURS = float(os.environ.get("MAX_IDLE_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CHECKOUTS_QUERY = """
query($cursor: String) {
checkouts(first: 100, after: $cursor) {
edges {
node {
id
token
created
updatedAt
user { id email }
channel { slug }
voucherCode
discountName
lines { id quantity variant { id product { id } } }
metadata { key value }
totalPrice { gross { amount currency } }
}
}
pageInfo { hasNextPage endCursor }
}
}"""
VOUCHER_QUERY = """
query($code: String!) {
vouchers(first: 1, filter: { search: $code }) {
edges { node { code usageLimit used endDate } }
}
}"""
VARIANT_LISTING_QUERY = """
query($id: ID!, $channel: String!) {
productVariant(id: $id, channel: $channel) {
id
channelListings { channel { slug } }
}
}"""
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 _to_epoch_ms(iso):
return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000
def classify_stale_checkout(checkout, max_idle_ms):
"""
Pure decision logic, no I/O. All data is pre-fetched.
checkout: {
"id": str, "token": str, "createdAt": str, "updatedAt": str,
"userEmail": str | None, "channelSlug": str, "voucherCode": str | None,
"lines": [{"variantId": str, "isChannelListed": bool}],
"expectedSessionId": str | None, "storedSessionMeta": str | None,
"voucherIsActive": bool | None, "now": str,
}
Returns {"stale": bool, "reasons": [str]}.
"""
reasons = []
expected = checkout.get("expectedSessionId")
if expected is not None and expected != checkout.get("storedSessionMeta"):
reasons.append("session_mismatch")
voucher_code = checkout.get("voucherCode")
if voucher_code is not None and checkout.get("voucherIsActive") is False:
reasons.append("orphaned_voucher")
lines = checkout.get("lines") or []
if any(not line.get("isChannelListed", True) for line in lines):
reasons.append("delisted_line")
now_ms = _to_epoch_ms(checkout["now"])
updated_ms = _to_epoch_ms(checkout["updatedAt"])
if (now_ms - updated_ms) > max_idle_ms:
reasons.append("long_idle")
return {"stale": len(reasons) > 0, "reasons": reasons}
def voucher_is_active(code, now_iso):
edges = gql(VOUCHER_QUERY, {"code": code})["vouchers"]["edges"]
if not edges:
return False
node = edges[0]["node"]
if node["endDate"] and node["endDate"] < now_iso:
return False
if node["usageLimit"] is not None and node["used"] >= node["usageLimit"]:
return False
return True
def variant_is_channel_listed(variant_id, channel_slug):
data = gql(VARIANT_LISTING_QUERY, {"id": variant_id, "channel": channel_slug})
variant = data.get("productVariant")
if not variant:
return False
return bool(variant.get("channelListings"))
def open_checkouts():
cursor = None
while True:
data = gql(CHECKOUTS_QUERY, {"cursor": cursor})["checkouts"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def _build_checkout_shape(node, now_iso):
channel_slug = (node.get("channel") or {}).get("slug")
voucher_code = node.get("voucherCode")
voucher_active = None
if voucher_code:
voucher_active = voucher_is_active(voucher_code, now_iso)
lines = []
for line in node.get("lines") or []:
variant = line.get("variant") or {}
variant_id = variant.get("id")
listed = True
if variant_id and channel_slug:
listed = variant_is_channel_listed(variant_id, channel_slug)
lines.append({"variantId": variant_id, "isChannelListed": listed})
metadata = {m["key"]: m["value"] for m in (node.get("metadata") or [])}
return {
"id": node["id"],
"token": node["token"],
"createdAt": node["created"],
"updatedAt": node["updatedAt"],
"userEmail": (node.get("user") or {}).get("email"),
"channelSlug": channel_slug,
"voucherCode": voucher_code,
"lines": lines,
"expectedSessionId": metadata.get("expected_session_id"),
"storedSessionMeta": metadata.get("session_id"),
"voucherIsActive": voucher_active,
"now": now_iso,
}
def run():
now_iso = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S%z")
max_idle_ms = MAX_IDLE_HOURS * 3600 * 1000
mode = "dry run" if DRY_RUN else "live"
log.info("Scanning open checkouts (%s, report only, max idle %.1fh)", mode, MAX_IDLE_HOURS)
flagged = 0
for node in open_checkouts():
shape = _build_checkout_shape(node, now_iso)
result = classify_stale_checkout(shape, max_idle_ms)
if not result["stale"]:
continue
flagged += 1
log.warning(
"STALE checkout id=%s token=%s user=%s channel=%s reasons=%s",
shape["id"], shape["token"], shape["userEmail"], shape["channelSlug"],
",".join(result["reasons"]),
)
log.info("Done. %d checkout(s) flagged. No checkouts were changed.", flagged)
return flagged
if __name__ == "__main__":
run()
/**
* Flag Saleor checkouts that look like a stale, client side reused checkout
* token rather than a fresh one from checkoutCreate.
*
* Saleor 3.x always inserts a new Checkout row from checkoutCreate and never
* auto-reuses one, so a stale checkout almost always means the storefront or
* app kept replaying a saved token/id instead of asking for a new one.
*
* Report only. Never edits a checkout or detaches a user. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/saleor/checkout-create-returns-stale-checkout/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://demo.saleor.io/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "token_dummy";
const MAX_IDLE_HOURS = Number(process.env.MAX_IDLE_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function classifyStaleCheckout(checkout, maxIdleMs) {
const reasons = [];
const expected = checkout.expectedSessionId;
if (expected !== undefined && expected !== null && expected !== checkout.storedSessionMeta) {
reasons.push("session_mismatch");
}
if (checkout.voucherCode != null && checkout.voucherIsActive === false) {
reasons.push("orphaned_voucher");
}
const lines = checkout.lines || [];
if (lines.some((line) => line.isChannelListed === false)) {
reasons.push("delisted_line");
}
const nowMs = Date.parse(checkout.now);
const updatedMs = Date.parse(checkout.updatedAt);
if (nowMs - updatedMs > maxIdleMs) {
reasons.push("long_idle");
}
return { stale: reasons.length > 0, reasons };
}
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;
}
const CHECKOUTS_QUERY = `
query($cursor: String) {
checkouts(first: 100, after: $cursor) {
edges {
node {
id
token
created
updatedAt
user { id email }
channel { slug }
voucherCode
discountName
lines { id quantity variant { id product { id } } }
metadata { key value }
totalPrice { gross { amount currency } }
}
}
pageInfo { hasNextPage endCursor }
}
}`;
const VOUCHER_QUERY = `
query($code: String!) {
vouchers(first: 1, filter: { search: $code }) {
edges { node { code usageLimit used endDate } }
}
}`;
const VARIANT_LISTING_QUERY = `
query($id: ID!, $channel: String!) {
productVariant(id: $id, channel: $channel) {
id
channelListings { channel { slug } }
}
}`;
async function voucherIsActive(code, nowIso) {
const edges = (await gql(VOUCHER_QUERY, { code })).vouchers.edges;
if (!edges.length) return false;
const node = edges[0].node;
if (node.endDate && node.endDate < nowIso) return false;
if (node.usageLimit !== null && node.used >= node.usageLimit) return false;
return true;
}
async function variantIsChannelListed(variantId, channelSlug) {
const data = await gql(VARIANT_LISTING_QUERY, { id: variantId, channel: channelSlug });
const variant = data.productVariant;
if (!variant) return false;
return Boolean(variant.channelListings && variant.channelListings.length);
}
async function* openCheckouts() {
let cursor = null;
while (true) {
const data = (await gql(CHECKOUTS_QUERY, { cursor })).checkouts;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function buildCheckoutShape(node, nowIso) {
const channelSlug = node.channel?.slug;
const voucherCode = node.voucherCode;
let voucherActive = null;
if (voucherCode) {
voucherActive = await voucherIsActive(voucherCode, nowIso);
}
const lines = [];
for (const line of node.lines || []) {
const variantId = line.variant?.id;
let listed = true;
if (variantId && channelSlug) {
listed = await variantIsChannelListed(variantId, channelSlug);
}
lines.push({ variantId, isChannelListed: listed });
}
const metadata = {};
for (const m of node.metadata || []) metadata[m.key] = m.value;
return {
id: node.id,
token: node.token,
createdAt: node.created,
updatedAt: node.updatedAt,
userEmail: node.user?.email ?? null,
channelSlug,
voucherCode,
lines,
expectedSessionId: metadata.expected_session_id ?? null,
storedSessionMeta: metadata.session_id ?? null,
voucherIsActive: voucherActive,
now: nowIso,
};
}
export async function run() {
const nowIso = new Date().toISOString();
const maxIdleMs = MAX_IDLE_HOURS * 3600 * 1000;
const mode = DRY_RUN ? "dry run" : "live";
console.log(`Scanning open checkouts (${mode}, report only, max idle ${MAX_IDLE_HOURS}h)`);
let flagged = 0;
for await (const node of openCheckouts()) {
const shape = await buildCheckoutShape(node, nowIso);
const result = classifyStaleCheckout(shape, maxIdleMs);
if (!result.stale) continue;
flagged++;
console.warn(
`STALE checkout id=${shape.id} token=${shape.token} user=${shape.userEmail} ` +
`channel=${shape.channelSlug} reasons=${result.reasons.join(",")}`
);
}
console.log(`Done. ${flagged} checkout(s) flagged. No checkouts were changed.`);
return flagged;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which checkouts land in a human's report. Because we kept classify_stale_checkout pure, the test needs no network and no Saleor store. It just feeds in plain objects and checks the answer.
from flag_stale_checkout import classify_stale_checkout
HOUR_MS = 3600 * 1000
def checkout(**over):
base = {
"id": "checkout-1",
"token": "tok-1",
"createdAt": "2026-07-01T00:00:00Z",
"updatedAt": "2026-07-09T12:00:00Z",
"userEmail": "buyer@example.com",
"channelSlug": "default-channel",
"voucherCode": None,
"lines": [{"variantId": "v-1", "isChannelListed": True}],
"expectedSessionId": None,
"storedSessionMeta": None,
"voucherIsActive": None,
"now": "2026-07-10T00:00:00Z",
}
base.update(over)
return base
def test_not_stale_by_default():
result = classify_stale_checkout(checkout(), 48 * HOUR_MS)
assert result == {"stale": False, "reasons": []}
def test_session_mismatch_flagged():
result = classify_stale_checkout(
checkout(expectedSessionId="sess-new", storedSessionMeta="sess-old"), 48 * HOUR_MS
)
assert result["stale"] is True
assert "session_mismatch" in result["reasons"]
def test_orphaned_voucher_flagged():
result = classify_stale_checkout(
checkout(voucherCode="SUMMER10", voucherIsActive=False), 48 * HOUR_MS
)
assert result["stale"] is True
assert "orphaned_voucher" in result["reasons"]
def test_active_voucher_not_flagged():
result = classify_stale_checkout(
checkout(voucherCode="SUMMER10", voucherIsActive=True), 48 * HOUR_MS
)
assert result["stale"] is False
def test_delisted_line_flagged():
result = classify_stale_checkout(
checkout(lines=[{"variantId": "v-1", "isChannelListed": False}]), 48 * HOUR_MS
)
assert result["stale"] is True
assert "delisted_line" in result["reasons"]
def test_long_idle_flagged():
result = classify_stale_checkout(checkout(), 6 * HOUR_MS)
assert result["stale"] is True
assert "long_idle" in result["reasons"]
def test_multiple_reasons_all_reported():
result = classify_stale_checkout(
checkout(
voucherCode="OLD5",
voucherIsActive=False,
lines=[{"variantId": "v-2", "isChannelListed": False}],
),
6 * HOUR_MS,
)
assert result["stale"] is True
assert set(result["reasons"]) == {"orphaned_voucher", "delisted_line", "long_idle"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStaleCheckout } from "./flag-stale-checkout.js";
const HOUR_MS = 3600 * 1000;
const checkout = (over = {}) => ({
id: "checkout-1",
token: "tok-1",
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-09T12:00:00Z",
userEmail: "buyer@example.com",
channelSlug: "default-channel",
voucherCode: null,
lines: [{ variantId: "v-1", isChannelListed: true }],
expectedSessionId: null,
storedSessionMeta: null,
voucherIsActive: null,
now: "2026-07-10T00:00:00Z",
...over,
});
test("not stale by default", () => {
const result = classifyStaleCheckout(checkout(), 48 * HOUR_MS);
assert.deepEqual(result, { stale: false, reasons: [] });
});
test("session mismatch flagged", () => {
const result = classifyStaleCheckout(
checkout({ expectedSessionId: "sess-new", storedSessionMeta: "sess-old" }),
48 * HOUR_MS
);
assert.equal(result.stale, true);
assert.ok(result.reasons.includes("session_mismatch"));
});
test("orphaned voucher flagged", () => {
const result = classifyStaleCheckout(
checkout({ voucherCode: "SUMMER10", voucherIsActive: false }),
48 * HOUR_MS
);
assert.equal(result.stale, true);
assert.ok(result.reasons.includes("orphaned_voucher"));
});
test("active voucher not flagged", () => {
const result = classifyStaleCheckout(
checkout({ voucherCode: "SUMMER10", voucherIsActive: true }),
48 * HOUR_MS
);
assert.equal(result.stale, false);
});
test("delisted line flagged", () => {
const result = classifyStaleCheckout(
checkout({ lines: [{ variantId: "v-1", isChannelListed: false }] }),
48 * HOUR_MS
);
assert.equal(result.stale, true);
assert.ok(result.reasons.includes("delisted_line"));
});
test("long idle flagged", () => {
const result = classifyStaleCheckout(checkout(), 6 * HOUR_MS);
assert.equal(result.stale, true);
assert.ok(result.reasons.includes("long_idle"));
});
test("multiple reasons all reported", () => {
const result = classifyStaleCheckout(
checkout({
voucherCode: "OLD5",
voucherIsActive: false,
lines: [{ variantId: "v-2", isChannelListed: false }],
}),
6 * HOUR_MS
);
assert.equal(result.stale, true);
assert.deepEqual(new Set(result.reasons), new Set(["orphaned_voucher", "delisted_line", "long_idle"]));
});
Case studies
A retail kiosk kept handing back the previous shopper's cart
A brand ran a browse and checkout kiosk in two of its stores. The storefront saved the checkout token in local storage keyed by the device, not the shopper. The next customer who tapped Start Order got the last person's items, and staff assumed Saleor was merging carts across logins.
Running the detection script showed every flagged checkout carrying a session_mismatch reason, since the app's own metadata recorded an expected_session_id that never matched the token that kept coming back. The fix was entirely client side: clear the stored token on Start Order rather than on checkout completion.
A voucher kept applying itself after the sale ended
A store ran a weekend voucher and the storefront cached the checkout id in a long lived cookie so returning shoppers could resume their cart. Monday morning, support tickets came in about a discount that should not have applied anymore.
The script flagged those checkouts with orphaned_voucher, since the voucher's endDate had passed even though the checkout still carried the code from Saturday. The team called checkoutRemovePromoCode on the confirmed stale ones and shortened the cookie's lifetime so it stops outliving the campaign.
After this runs on a schedule, a stale reused checkout is a report row with a reason attached, not a support mystery blamed on Saleor. Session mismatches, dead vouchers, and delisted lines get caught, a human decides the safe cleanup, and the actual fix, clearing the client side token cache on the right event, stops the next one from happening.
FAQ
Does checkoutCreate in Saleor still return an existing open checkout instead of a new one?
Not in current Saleor. That behavior existed in Saleor 2.x, tracked in GitHub issue 6185, where maintainers assumed a logged in user should have only one active checkout. In Saleor 3.x main, checkoutCreate always inserts a new Checkout row, and the created field on the mutation is deprecated with the note Always returns true. If a shopper gets old lines or a stale voucher back, the storefront or app is replaying a saved checkout token instead of asking Saleor for a new one.
Is it safe to fix a stale checkout by editing it directly with a script?
Only for narrow, confirmed cases. Do not auto-mutate lines, vouchers, or shipping on a live checkout, since that can drop items a shopper is actively buying. Safe, DRY_RUN-guarded moves are removing an already-expired voucher with checkoutRemovePromoCode, stripping lines that reference a delisted variant after re-checking channelListings, and detaching an orphaned checkout with checkoutCustomerDetach. Saleor has no direct checkoutDelete for arbitrary staff cleanup, so genuinely abandoned carts are left to the built in delete_expired_checkouts task.
How do I detect which checkouts are stale reused ones in Saleor?
Page through the checkouts query with a staff or app token that has MANAGE_CHECKOUTS, and read id, token, created, updatedAt, user, channel, voucherCode, lines, and metadata for each one. A checkout is stale reused when its voucherCode is no longer active, when a line references a variant with no channelListings left, or when its stored session metadata does not match the session that was supposed to get a brand new checkout.
Related field notes
Citations
On the problem:
- CheckoutCreate mutation returns open checkout when creating new one. github.com/saleor/saleor/issues/6185
- Saleor source: checkout_create.py, the CheckoutCreate.created field, deprecated with "Always returns true." github.com/saleor/saleor/blob/main/saleor/graphql/checkout/mutations/checkout_create.py
- Saleor Commerce Documentation: Checkout Lifecycle. docs.saleor.io/developer/checkout/lifecycle
On the solution:
- Saleor Commerce Documentation: the checkoutCreate mutation. docs.saleor.io/api-reference/checkout/mutations/checkout-create
- Saleor Commerce Documentation: the Checkout object. docs.saleor.io/api-reference/checkout/objects/checkout
- Saleor Commerce Documentation: Checkout API Guide. docs.saleor.io/developer/checkout/api-guide
Stuck on a tricky one?
If you have a problem in Saleor checkout, stock, orders, 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 clear up a stale checkout mystery?
If this saved you a support ticket blaming Saleor for your own token cache, 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