Repair Checkout & Stock Reservation
Abandoned checkout keeps stock reserved past TTL
A shopper adds items to a Saleor checkout, Saleor reserves that stock in the warehouse right away, and then the shopper closes the tab and never comes back. The reservation is supposed to expire and free the stock on its own. But when the background job that clears expired reservations is misconfigured, delayed, or simply not running, that stock stays debited to a cart nobody will ever finish. Here is why that happens and a small script that finds the stale holds and releases them safely.
Saleor's optional stock reservation feature allocates warehouse stock to a checkout as soon as lines are added, and that hold is only cleared by a periodic Celery beat task. If the task queue is misconfigured or the worker is down, reservations past their expiry are never deleted, so Stock.quantity stays debited by phantom holds. Run a small Python or Node.js script that lists checkouts, flags any where stockReservationExpires is in the past or lastChange is older than your CHECKOUT_TTL_BEFORE_RELEASING_FUNDS window, and calls checkoutLinesDelete to drop the lines and force Saleor to release the reservation. Full code, tests, and a dry run guard are below.
The problem in plain words
When stock reservation is turned on, Saleor does not wait for payment to protect inventory. The moment a shopper adds a line to a checkout, Saleor reserves that quantity in the warehouse, so two shoppers cannot both claim the last unit while one of them is still typing an address. That reservation carries an expiry timestamp, exposed on the checkout as stockReservationExpires.
The catch is that Saleor never checks that expiry synchronously. Nothing in the storefront or the checkout API re-validates a reservation when you look at it. The only thing that deletes an expired reservation row is a periodic background task, plus a separate job gated by CHECKOUT_TTL_BEFORE_RELEASING_FUNDS (default six hours) that releases funds for checkouts nobody finished. If that task queue is backed up, misconfigured, or the Celery beat or worker process is simply down, the expired rows just sit there. The checkout's lastChange goes stale, the shopper is long gone, but the warehouse still reports the stock as held.
Why it happens
Stock reservation is deliberately eager, because the alternative is overselling. But that design leans entirely on a background process to undo the hold. A few common ways stores end up with stale reservations:
- The Celery beat scheduler that fires the periodic release-expired-reservations task is not running, was never deployed, or crashed silently.
- The Celery worker queue is backed up behind slower jobs, so the release task is scheduled but does not execute in time.
- The separate abandoned-checkout job gated by
CHECKOUT_TTL_BEFORE_RELEASING_FUNDSis misconfigured or disabled, so funds and stock both stay tied to old checkouts for far longer than the default six hours. - A high-traffic flash sale creates a spike of abandoned checkouts faster than the periodic task can clear them, so the backlog grows even though the job is technically running.
This is a common source of confusion for store operators. Inventory dashboards show low or zero available stock, restocking looks unnecessary, but real shoppers cannot buy because the units are locked to carts that will never convert. Saleor's own checkout lifecycle docs describe the intended TTL behavior, and community threads show operators hitting exactly this gap when the periodic tasks are not healthy. See the citations at the end for the exact reports and docs.
Saleor never re-checks a reservation's expiry synchronously, so an expired stockReservationExpires timestamp is not automatically enforced anywhere except that one periodic task. The safe fix does not touch Stock.quantity or allocation rows directly. It removes the checkout lines that hold the expired reservation with checkoutLinesDelete, which is the documented way to make Saleor drop the reservation for those lines without deleting the checkout or touching any order or payment record.
The fix, as a flow
We do not touch the live checkout flow or mutate stock directly. We add a job that lists checkouts, flags the ones whose reservation has expired or whose lastChange is older than the configured TTL, and strips just those lines from the checkout. Saleor then drops the reservation for those lines on its own, and the warehouse stock frees up for a real buyer.
Build it step by step
Get an app or staff token
Create an app in the Saleor dashboard, or use a staff account with tokenCreate, and grant MANAGE_CHECKOUTS (checkout queries need this or HANDLE_PAYMENTS). 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 TTL_MINUTES="360" # matches CHECKOUT_TTL_BEFORE_RELEASING_FUNDS default of 6h
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export TTL_MINUTES="360" // matches CHECKOUT_TTL_BEFORE_RELEASING_FUNDS default of 6h
export DRY_RUN="true" // start safe, change to false to write
Talk to the Saleor GraphQL API
Every call goes to one GraphQL endpoint with your token in the Authorization header. A small helper sends a query and returns the data, and raises if Saleor reports an error. We use this same helper to read checkouts and to run the mutation.
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 checkouts and their reservation state
Ask for checkouts and read back the fields the decision needs: the id, the token, when it last changed, when its stock reservation expires, and its lines with quantity and variant sku. We page through with a cursor so the job handles a large backlog of abandoned carts.
CHECKOUTS_QUERY = """
query($cursor: String) {
checkouts(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
token
lastChange
stockReservationExpires
lines { id quantity variant { id sku } }
}
}
}
}"""
def all_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: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
token
lastChange
stockReservationExpires
lines { id quantity variant { id sku } }
}
}
}
}`;
async function* allCheckouts() {
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;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a checkout, the current time, and the TTL in minutes, 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. A checkout is stale when its reservation has actually expired, or otherwise when its lastChange is older than the TTL window. Checkouts within the TTL window with no expiry set are left alone.
from datetime import datetime, timedelta
def find_stale_reserved_checkouts(checkouts, now, ttl_minutes):
stale = []
for checkout in checkouts:
expires = checkout.get("stockReservationExpires")
if expires is not None:
expires_at = datetime.fromisoformat(expires.replace("Z", "+00:00"))
if expires_at <= now:
stale.append({
"id": checkout["id"],
"lineIds": [line["id"] for line in checkout["lines"]],
"reason": "expired_reservation",
})
continue
last_change = checkout.get("lastChange")
if last_change is None:
continue
changed_at = datetime.fromisoformat(last_change.replace("Z", "+00:00"))
if changed_at <= now - timedelta(minutes=ttl_minutes):
stale.append({
"id": checkout["id"],
"lineIds": [line["id"] for line in checkout["lines"]],
"reason": "past_ttl",
})
return stale
export function findStaleReservedCheckouts(checkouts, now, ttlMinutes) {
const stale = [];
const ttlMs = ttlMinutes * 60 * 1000;
for (const checkout of checkouts) {
const lineIds = checkout.lines.map((line) => line.id);
if (checkout.stockReservationExpires !== null && checkout.stockReservationExpires !== undefined) {
const expiresAt = new Date(checkout.stockReservationExpires).getTime();
if (expiresAt <= now.getTime()) {
stale.push({ id: checkout.id, lineIds, reason: "expired_reservation" });
continue;
}
}
if (!checkout.lastChange) continue;
const changedAt = new Date(checkout.lastChange).getTime();
if (changedAt <= now.getTime() - ttlMs) {
stale.push({ id: checkout.id, lineIds, reason: "past_ttl" });
}
}
return stale;
}
Release the reservation by removing the lines
When a checkout is flagged stale, call checkoutLinesDelete with the checkout id and the flagged line ids. Saleor drops the associated stock reservation for those lines without touching the checkout record itself or any order or payment. Always read back errors. If Saleor refuses, the error tells you why, and the script should stop on it rather than pretend it worked.
LINES_DELETE = """
mutation($id: ID!, $linesIds: [ID!]!) {
checkoutLinesDelete(id: $id, linesIds: $linesIds) {
checkout { id stockReservationExpires }
errors { field message }
}
}"""
def release_reservation(checkout_id, line_ids):
result = gql(LINES_DELETE, {"id": checkout_id, "linesIds": line_ids})["checkoutLinesDelete"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["checkout"]
const LINES_DELETE = `
mutation($id: ID!, $linesIds: [ID!]!) {
checkoutLinesDelete(id: $id, linesIds: $linesIds) {
checkout { id stockReservationExpires }
errors { field message }
}
}`;
async function releaseReservation(checkoutId, lineIds) {
const result = (await gql(LINES_DELETE, { id: checkoutId, linesIds: lineIds })).checkoutLinesDelete;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.checkout;
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which checkouts it would clear and why. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that fits your traffic, for example every fifteen minutes, and log every mutation response before and after each write.
Always start with DRY_RUN=true, and never mutate Stock.quantity or allocation rows directly. Only clear the checkout lines that hold an expired reservation, then re-query stockReservationExpires and the variant stock to confirm the hold is actually gone.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only removes lines from checkouts whose reservation is provably expired or past your TTL.
"""Release Saleor stock reservations left behind by abandoned checkouts.
Only removes checkout lines whose reservation is provably expired or whose
lastChange is older than CHECKOUT_TTL_BEFORE_RELEASING_FUNDS. Never touches
Stock.quantity or allocations directly. Run on a schedule. Safe to run again.
"""
import os
import logging
import requests
from datetime import datetime, timezone, timedelta
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("release_stale_reservations")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
TTL_MINUTES = float(os.environ.get("TTL_MINUTES", "360"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CHECKOUTS_QUERY = """
query($cursor: String) {
checkouts(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
token
lastChange
stockReservationExpires
lines { id quantity variant { id sku } }
}
}
}
}"""
LINES_DELETE = """
mutation($id: ID!, $linesIds: [ID!]!) {
checkoutLinesDelete(id: $id, linesIds: $linesIds) {
checkout { id stockReservationExpires }
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 find_stale_reserved_checkouts(checkouts, now, ttl_minutes):
stale = []
for checkout in checkouts:
expires = checkout.get("stockReservationExpires")
if expires is not None:
expires_at = datetime.fromisoformat(expires.replace("Z", "+00:00"))
if expires_at <= now:
stale.append({
"id": checkout["id"],
"lineIds": [line["id"] for line in checkout["lines"]],
"reason": "expired_reservation",
})
continue
last_change = checkout.get("lastChange")
if last_change is None:
continue
changed_at = datetime.fromisoformat(last_change.replace("Z", "+00:00"))
if changed_at <= now - timedelta(minutes=ttl_minutes):
stale.append({
"id": checkout["id"],
"lineIds": [line["id"] for line in checkout["lines"]],
"reason": "past_ttl",
})
return stale
def all_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 release_reservation(checkout_id, line_ids):
result = gql(LINES_DELETE, {"id": checkout_id, "linesIds": line_ids})["checkoutLinesDelete"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["checkout"]
def run():
now = datetime.now(timezone.utc)
checkouts = list(all_checkouts())
flagged = find_stale_reserved_checkouts(checkouts, now, TTL_MINUTES)
released = 0
for entry in flagged:
log.warning(
"Checkout %s stale (%s), %d line(s). %s",
entry["id"], entry["reason"], len(entry["lineIds"]),
"would release" if DRY_RUN else "releasing",
)
if not DRY_RUN:
checkout = release_reservation(entry["id"], entry["lineIds"])
log.info("Checkout %s stockReservationExpires now %s", checkout["id"], checkout["stockReservationExpires"])
released += 1
log.info("Done. %d stale checkout(s) %s.", released, "to release" if DRY_RUN else "released")
if __name__ == "__main__":
run()
/**
* Release Saleor stock reservations left behind by abandoned checkouts.
* Only removes checkout lines whose reservation is provably expired or whose
* lastChange is older than CHECKOUT_TTL_BEFORE_RELEASING_FUNDS. Never touches
* Stock.quantity or allocations directly. Run on a schedule. Safe to run again.
*/
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 TTL_MINUTES = Number(process.env.TTL_MINUTES || 360);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function findStaleReservedCheckouts(checkouts, now, ttlMinutes) {
const stale = [];
const ttlMs = ttlMinutes * 60 * 1000;
for (const checkout of checkouts) {
const lineIds = checkout.lines.map((line) => line.id);
if (checkout.stockReservationExpires !== null && checkout.stockReservationExpires !== undefined) {
const expiresAt = new Date(checkout.stockReservationExpires).getTime();
if (expiresAt <= now.getTime()) {
stale.push({ id: checkout.id, lineIds, reason: "expired_reservation" });
continue;
}
}
if (!checkout.lastChange) continue;
const changedAt = new Date(checkout.lastChange).getTime();
if (changedAt <= now.getTime() - ttlMs) {
stale.push({ id: checkout.id, lineIds, reason: "past_ttl" });
}
}
return stale;
}
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: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
token
lastChange
stockReservationExpires
lines { id quantity variant { id sku } }
}
}
}
}`;
const LINES_DELETE = `
mutation($id: ID!, $linesIds: [ID!]!) {
checkoutLinesDelete(id: $id, linesIds: $linesIds) {
checkout { id stockReservationExpires }
errors { field message }
}
}`;
async function* allCheckouts() {
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 releaseReservation(checkoutId, lineIds) {
const result = (await gql(LINES_DELETE, { id: checkoutId, linesIds: lineIds })).checkoutLinesDelete;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.checkout;
}
export async function run() {
const now = new Date();
const checkouts = [];
for await (const checkout of allCheckouts()) checkouts.push(checkout);
const flagged = findStaleReservedCheckouts(checkouts, now, TTL_MINUTES);
let released = 0;
for (const entry of flagged) {
console.warn(`Checkout ${entry.id} stale (${entry.reason}), ${entry.lineIds.length} line(s). ${DRY_RUN ? "would release" : "releasing"}`);
if (!DRY_RUN) {
const checkout = await releaseReservation(entry.id, entry.lineIds);
console.log(`Checkout ${checkout.id} stockReservationExpires now ${checkout.stockReservationExpires}`);
}
released++;
}
console.log(`Done. ${released} stale checkout(s) ${DRY_RUN ? "to release" : "released"}.`);
}
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 abandoned checkouts get their stock released. Because we kept find_stale_reserved_checkouts pure, the test needs no network and no Saleor account. It just feeds in plain objects with a fixed clock and checks the answer.
from datetime import datetime, timezone
from release_stale_reservations import find_stale_reserved_checkouts
NOW = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
def checkout(**over):
base = {
"id": "Q2hlY2tvdXQ6MQ==",
"lastChange": "2026-07-09T23:00:00Z",
"stockReservationExpires": None,
"lines": [{"id": "Q2hlY2tvdXRMaW5lOjE=", "quantity": 1, "variantSku": "SKU-1"}],
}
base.update(over)
return base
def test_flags_expired_reservation():
result = find_stale_reserved_checkouts(
[checkout(stockReservationExpires="2026-07-09T23:55:00Z")], NOW, 360
)
assert len(result) == 1
assert result[0]["reason"] == "expired_reservation"
assert result[0]["id"] == "Q2hlY2tvdXQ6MQ=="
def test_flags_past_ttl_when_no_expiry_set():
result = find_stale_reserved_checkouts(
[checkout(lastChange="2026-07-09T17:00:00Z")], NOW, 360
)
assert len(result) == 1
assert result[0]["reason"] == "past_ttl"
def test_skips_checkout_within_ttl_and_no_expiry():
result = find_stale_reserved_checkouts(
[checkout(lastChange="2026-07-09T23:00:00Z")], NOW, 360
)
assert result == []
def test_skips_checkout_with_future_expiry():
result = find_stale_reserved_checkouts(
[checkout(stockReservationExpires="2026-07-10T01:00:00Z", lastChange="2026-07-09T23:50:00Z")], NOW, 360
)
assert result == []
def test_returns_line_ids():
lines = [{"id": "A"}, {"id": "B"}]
result = find_stale_reserved_checkouts(
[checkout(stockReservationExpires="2026-07-09T20:00:00Z", lines=lines)], NOW, 360
)
assert result[0]["lineIds"] == ["A", "B"]
def test_skips_checkout_with_no_last_change_and_no_expiry():
result = find_stale_reserved_checkouts([checkout(lastChange=None)], NOW, 360)
assert result == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStaleReservedCheckouts } from "./release-stale-reservations.js";
const NOW = new Date("2026-07-10T00:00:00Z");
const checkout = (over = {}) => ({
id: "Q2hlY2tvdXQ6MQ==",
lastChange: "2026-07-09T23:00:00Z",
stockReservationExpires: null,
lines: [{ id: "Q2hlY2tvdXRMaW5lOjE=", quantity: 1, variantSku: "SKU-1" }],
...over,
});
test("flags expired reservation", () => {
const result = findStaleReservedCheckouts([checkout({ stockReservationExpires: "2026-07-09T23:55:00Z" })], NOW, 360);
assert.equal(result.length, 1);
assert.equal(result[0].reason, "expired_reservation");
assert.equal(result[0].id, "Q2hlY2tvdXQ6MQ==");
});
test("flags past ttl when no expiry set", () => {
const result = findStaleReservedCheckouts([checkout({ lastChange: "2026-07-09T17:00:00Z" })], NOW, 360);
assert.equal(result.length, 1);
assert.equal(result[0].reason, "past_ttl");
});
test("skips checkout within ttl and no expiry", () => {
const result = findStaleReservedCheckouts([checkout({ lastChange: "2026-07-09T23:00:00Z" })], NOW, 360);
assert.deepEqual(result, []);
});
test("skips checkout with future expiry", () => {
const result = findStaleReservedCheckouts(
[checkout({ stockReservationExpires: "2026-07-10T01:00:00Z", lastChange: "2026-07-09T23:50:00Z" })],
NOW,
360
);
assert.deepEqual(result, []);
});
test("returns line ids", () => {
const lines = [{ id: "A" }, { id: "B" }];
const result = findStaleReservedCheckouts(
[checkout({ stockReservationExpires: "2026-07-09T20:00:00Z", lines })],
NOW,
360
);
assert.deepEqual(result[0].lineIds, ["A", "B"]);
});
test("skips checkout with no lastChange and no expiry", () => {
const result = findStaleReservedCheckouts([checkout({ lastChange: null })], NOW, 360);
assert.deepEqual(result, []);
});
Case studies
A worker outage during a launch
A sneaker store ran a limited drop and the Celery worker crashed under load about an hour in. Reservations kept accumulating from every abandoned cart, but nothing was cleaning them up. By the next morning, several sizes showed zero available stock even though the warehouse actually had units, and support was fielding complaints about a sellout that never really happened.
Once the worker was restored, the team ran the release script in dry run to see the full backlog, confirmed the count against their order volume, then let it clear roughly four hundred stale checkouts in one pass. Stock counts matched the warehouse again within minutes.
A misconfigured beat schedule
A mid-size store had migrated their Celery beat configuration during an infrastructure change, and the periodic release-expired-reservations task quietly stopped being scheduled. Nobody noticed for weeks because the effect was gradual, just a slow creep of unavailable variants that looked like normal demand.
The script found the gap immediately: thousands of checkouts with reservations that had expired days earlier. Running it on a fifteen minute schedule going forward gave them a safety net independent of whether the beat schedule was healthy, and they fixed the underlying Celery config separately.
After this runs on a schedule, an abandoned checkout is never more than one run away from giving its stock back. The warehouse numbers match what shoppers can actually buy, a stalled Celery worker no longer silently costs you sales, and nobody has to guess which reservations are real. Keep the check strict, since only provably expired or past-TTL checkouts should ever be touched.
FAQ
Why does an abandoned Saleor checkout still hold stock?
Saleor's stock reservation feature allocates warehouse stock the moment items are added to a checkout, and that hold is only cleared by a periodic Celery beat task that deletes expired reservation rows. If that background job is misconfigured, delayed, or the worker is down, the reservation rows past their expiry are never removed, so the stock stays debited even though the shopper left.
Is it safe to script the release of stale checkout reservations?
Yes, when the script only flags checkouts whose stockReservationExpires is already in the past or whose lastChange is older than your configured TTL, and only removes the checkout lines through checkoutLinesDelete rather than touching Stock.quantity or allocations directly. Running it in dry run first lets you confirm the exact list before it writes anything.
What does checkoutLinesDelete actually do to the stock hold?
checkoutLinesDelete removes the specified lines from a checkout without deleting the checkout itself or touching any order or payment record. Because the stock reservation is tied to those checkout lines, removing them is the documented way to make Saleor drop the associated reservation rows and free the warehouse stock.
Related field notes
Citations
On the problem:
- Saleor Docs: Checkout Lifecycle. docs.saleor.io/developer/checkout/lifecycle
- GitHub Issue #11257: Canceling the orders that haven't been paid. github.com/saleor/saleor/issues/11257
- GitHub Discussion #13866: Monitoring unfinished checkouts. github.com/saleor/saleor/discussions/13866
On the solution:
- Saleor Docs: Stock Reservation. docs.saleor.io/developer/stock/stock-reservation
- Saleor API Reference: Checkout object, including stockReservationExpires and lastChange. docs.saleor.io/api-reference/checkout/objects/checkout
- Saleor API Reference: the checkoutLinesDelete mutation. docs.saleor.io/api-reference/checkout/mutations/checkout-lines-delete
Stuck on a tricky one?
If you have a problem in Saleor checkout, stock reservation, channels, 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 free up your stock?
If this saved you a pile of phantom out-of-stock variants or a confused restocking meeting, 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