Skip to content

Reconciler Checkout & Stock Reservation

Paid checkout never converts to an order

The card was charged, the payment provider confirmed it, and by every measure the sale happened. But Saleor still has a Checkout, not an Order, and nothing in the storefront is going to fix that on its own. Here is why Saleor leaves paid checkouts stranded like this and a small script that finds the ones a human should trust and finishes them safely.

Python and Node.js GraphQL API Safe by default (dry run)
Holding a smartphone
Photo by Jonas Leupe on Unsplash
The short answer

Saleor's checkout-to-order conversion is not automatic by default. checkoutComplete is a separate mutation the storefront must call after the payment provider confirms the charge, so if the tab closes or the network drops between the payment reaching FULL and that call, the Checkout is left behind with money captured and no Order. Run a small Python or Node.js script that lists checkouts with authorizeStatus: FULL, keeps only the ones older than a short grace period with no matching Order, and calls checkoutComplete for each. Full code, tests, and a dry run guard are below.

The problem in plain words

In Saleor, a Checkout and an Order are two different objects. A Checkout holds the cart, the shipping choice, and the in-progress payment. An Order is the finished, permanent record of a sale. Turning one into the other is not something Saleor does automatically the moment money moves. It happens when the storefront calls the checkoutComplete mutation.

Normally that call happens right after the payment provider says the charge succeeded, all within the same request or the same page load. But that gap between "payment confirmed" and "checkoutComplete called" is real time on the network, and plenty of things can happen inside it. The tab closes. The app crashes. A redirect back from a 3D Secure page fails. The result is the same either way: the customer paid, Saleor captured or authorized the funds, and the Checkout row just sits there, never becoming an Order.

Payment confirmed authorizeStatus FULL Tab closes or network drops checkoutComplete never runs Checkout stranded no Order created Reservation expires ~6h
The payment went through, but nothing told Saleor to finish the checkout. The Checkout sits stranded until the default reservation window silently voids it.

Why it happens

Saleor's own documentation flags this exact race condition. A few common ways stores end up with stranded, paid checkouts:

If neither an automatic completion setting nor the CHECKOUT_FULLY_PAID webhook is configured, these paid but incomplete checkouts just sit there until the default reservation and fund release window, roughly six hours, silently voids them. See the citations at the end for the exact docs and issue thread.

The key insight

The fix is not to complete every open checkout. It is to complete only the ones Saleor itself already confirms were paid. We lean on authorizeStatus: FULL as proof the money moved, a short grace period so we never race a checkout that is still mid-flow, and we leave anything Saleor flags as needing further confirmation, such as a 3DS redirect, for a human to check rather than force it blind.

The fix, as a flow

We do not touch the live checkout flow. We add a job that lists checkouts with full authorization, skips anything too new or already converted, calls checkoutComplete for the rest, and reports rather than retries anything that still needs confirmation.

Scheduled job runs on a timer List paid checkouts authorizeStatus: FULL Check age and order grace period, hasOrder Old enough and unconverted? yes no, skip checkoutComplete order becomes real
The script only completes checkouts that Saleor confirms were paid, are old enough to not be mid-flow, and have no Order yet. Anything still needing confirmation is flagged, not forced.

Build it step by step

1

Get an app token or staff JWT

Create an app in Saleor Dashboard, or generate a staff JWT with tokenCreate, with permission to read and complete checkouts. Keep the API URL and the 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="your app or staff token"
export GRACE_MINUTES="5"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your app or staff token"
export GRACE_MINUTES="5"
export DRY_RUN="true"   // start safe, change to false to write
2

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.

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

List checkouts with full authorization

Ask for checkouts whose authorizeStatus is FULL, and read back the fields the decision needs: the id, token, when it was created, the channel, the total, and the transaction states. A converted checkout is deleted and replaced by an Order, so anything still showing up here with full payment is a candidate.

step3.py
CHECKOUTS_QUERY = """
query {
  checkouts(first: 50, filter: {authorizeStatus: [FULL]}) {
    edges {
      node {
        id
        token
        created
        channel { slug }
        totalPrice { gross { amount currency } }
        authorizeStatus
        chargeStatus
        transactions { id chargeStatus authorizeStatus }
      }
    }
  }
}"""

def paid_checkouts():
    data = gql(CHECKOUTS_QUERY)["checkouts"]
    return [edge["node"] for edge in data["edges"]]
step3.js
const CHECKOUTS_QUERY = `
query {
  checkouts(first: 50, filter: {authorizeStatus: [FULL]}) {
    edges {
      node {
        id
        token
        created
        channel { slug }
        totalPrice { gross { amount currency } }
        authorizeStatus
        chargeStatus
        transactions { id chargeStatus authorizeStatus }
      }
    }
  }
}`;

async function paidCheckouts() {
  const data = (await gql(CHECKOUTS_QUERY)).checkouts;
  return data.edges.map((edge) => edge.node);
}
4

Decide, with one pure function

Keep the decision in its own function that takes a checkout shape and the current time and returns an action. A pure function like this is easy to read and easy to test, which we do later. It skips anything that already has an Order, anything not fully authorized yet, and anything too new to be past the grace period. It flags, rather than completes, anything whose charge status still looks pending or partial on the provider side, since that usually means a redirect step is still outstanding.

decide.py
from datetime import datetime, timezone

PENDING_CHARGE_STATES = {"PENDING", "PARTIAL"}

def _parse(iso):
    return datetime.fromisoformat(iso.replace("Z", "+00:00"))

def should_complete_checkout(checkout, now_iso, grace_minutes=5):
    if checkout.get("hasOrder"):
        return {"action": "skip", "reason": "already has an order"}
    if checkout.get("authorizeStatus") != "FULL":
        return {"action": "skip", "reason": "not fully authorized"}
    age_minutes = (_parse(now_iso) - _parse(checkout["createdAt"])).total_seconds() / 60
    if age_minutes < grace_minutes:
        return {"action": "skip", "reason": "too new, still likely mid-flow"}
    if checkout.get("chargeStatus") in PENDING_CHARGE_STATES:
        return {"action": "flag", "reason": "provider-side confirmation still pending"}
    return {"action": "complete", "reason": "paid, aged past grace period, no order yet"}
decide.js
const PENDING_CHARGE_STATES = new Set(["PENDING", "PARTIAL"]);

export function shouldCompleteCheckout(checkout, nowIso, graceMinutes = 5) {
  if (checkout.hasOrder) return { action: "skip", reason: "already has an order" };
  if (checkout.authorizeStatus !== "FULL") return { action: "skip", reason: "not fully authorized" };
  const ageMinutes = (Date.parse(nowIso) - Date.parse(checkout.createdAt)) / 60000;
  if (ageMinutes < graceMinutes) return { action: "skip", reason: "too new, still likely mid-flow" };
  if (PENDING_CHARGE_STATES.has(checkout.chargeStatus)) {
    return { action: "flag", reason: "provider-side confirmation still pending" };
  }
  return { action: "complete", reason: "paid, aged past grace period, no order yet" };
}
5

Complete the checkout the way the payment redirect would

When a checkout is eligible, call the checkoutComplete mutation with its id. Saleor turns the Checkout into an Order and returns it. Always read back confirmationNeeded and errors. If confirmationNeeded is true, the payment provider still needs a 3DS or redirect follow-up, so do not force it, report it instead. Treat any error, such as already completed or insufficient payment, as already converted or unfixable and skip rather than retry.

apply.py
COMPLETE_MUTATION = """
mutation Complete($id: ID!) {
  checkoutComplete(id: $id) {
    order { id number }
    confirmationNeeded
    confirmationData
    errors { field code message }
  }
}"""

def complete_checkout(checkout_id):
    result = gql(COMPLETE_MUTATION, {"id": checkout_id})["checkoutComplete"]
    if result["errors"]:
        return {"status": "unfixable", "errors": result["errors"]}
    if result["confirmationNeeded"]:
        return {"status": "needs_confirmation", "confirmationData": result["confirmationData"]}
    return {"status": "completed", "order": result["order"]}
apply.js
const COMPLETE_MUTATION = `
mutation Complete($id: ID!) {
  checkoutComplete(id: $id) {
    order { id number }
    confirmationNeeded
    confirmationData
    errors { field code message }
  }
}`;

async function completeCheckout(checkoutId) {
  const result = (await gql(COMPLETE_MUTATION, { id: checkoutId })).checkoutComplete;
  if (result.errors.length) return { status: "unfixable", errors: result.errors };
  if (result.confirmationNeeded) return { status: "needs_confirmation", confirmationData: result.confirmationData };
  return { status: "completed", order: result.order };
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs which checkouts it would complete, and separately reports anything it would flag for a human. Read the output, agree with it, then switch it off to let it write. Run it on a schedule shorter than your reservation window, for example every fifteen minutes.

Run it safe

Always start with DRY_RUN=true. Never force checkoutComplete on a checkout where confirmationNeeded comes back true. That is Saleor telling you the payment provider still has a step left, and completing it anyway is not something a script should decide.

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 touches checkouts that Saleor reports as fully authorized, aged past the grace period, and still without an Order.

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.
complete_paid_checkouts.py
"""Complete Saleor checkouts that were paid but never converted to an Order.
Only touches checkouts that are fully authorized, aged past a grace period, and
have no Order yet. Anything still needing provider-side confirmation is flagged,
not forced. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import datetime
import requests

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

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
GRACE_MINUTES = float(os.environ.get("GRACE_MINUTES", "5"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PENDING_CHARGE_STATES = {"PENDING", "PARTIAL"}

CHECKOUTS_QUERY = """
query {
  checkouts(first: 50, filter: {authorizeStatus: [FULL]}) {
    edges {
      node {
        id
        token
        created
        channel { slug }
        totalPrice { gross { amount currency } }
        authorizeStatus
        chargeStatus
        transactions { id chargeStatus authorizeStatus }
      }
    }
  }
}"""

COMPLETE_MUTATION = """
mutation Complete($id: ID!) {
  checkoutComplete(id: $id) {
    order { id number }
    confirmationNeeded
    confirmationData
    errors { field code 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 _parse(iso):
    return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00"))


def should_complete_checkout(checkout, now_iso, grace_minutes=5):
    if checkout.get("hasOrder"):
        return {"action": "skip", "reason": "already has an order"}
    if checkout.get("authorizeStatus") != "FULL":
        return {"action": "skip", "reason": "not fully authorized"}
    age_minutes = (_parse(now_iso) - _parse(checkout["createdAt"])).total_seconds() / 60
    if age_minutes < grace_minutes:
        return {"action": "skip", "reason": "too new, still likely mid-flow"}
    if checkout.get("chargeStatus") in PENDING_CHARGE_STATES:
        return {"action": "flag", "reason": "provider-side confirmation still pending"}
    return {"action": "complete", "reason": "paid, aged past grace period, no order yet"}


def paid_checkouts():
    data = gql(CHECKOUTS_QUERY)["checkouts"]
    for edge in data["edges"]:
        node = edge["node"]
        node["createdAt"] = node["created"]
        node["hasOrder"] = False
        yield node


def complete_checkout(checkout_id):
    result = gql(COMPLETE_MUTATION, {"id": checkout_id})["checkoutComplete"]
    if result["errors"]:
        return {"status": "unfixable", "errors": result["errors"]}
    if result["confirmationNeeded"]:
        return {"status": "needs_confirmation", "confirmationData": result["confirmationData"]}
    return {"status": "completed", "order": result["order"]}


def run():
    now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
    completed = 0
    flagged = 0
    for checkout in paid_checkouts():
        decision = should_complete_checkout(checkout, now_iso, GRACE_MINUTES)
        if decision["action"] == "skip":
            continue
        if decision["action"] == "flag":
            log.warning("Checkout %s flagged: %s", checkout["token"], decision["reason"])
            flagged += 1
            continue
        log.info("Checkout %s eligible. %s", checkout["token"],
                  "would complete" if DRY_RUN else "completing")
        if not DRY_RUN:
            outcome = complete_checkout(checkout["id"])
            if outcome["status"] != "completed":
                log.warning("Checkout %s not completed: %s", checkout["token"], outcome)
                continue
        completed += 1
    log.info("Done. %d checkout(s) %s, %d flagged for review.",
              completed, "to complete" if DRY_RUN else "completed", flagged)


if __name__ == "__main__":
    run()
complete-paid-checkouts.js
/**
 * Complete Saleor checkouts that were paid but never converted to an Order.
 * Only touches checkouts that are fully authorized, aged past a grace period, and
 * have no Order yet. Anything still needing provider-side confirmation is flagged,
 * not forced. Run on a schedule. Safe to run again and 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 GRACE_MINUTES = Number(process.env.GRACE_MINUTES || 5);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PENDING_CHARGE_STATES = new Set(["PENDING", "PARTIAL"]);

export function shouldCompleteCheckout(checkout, nowIso, graceMinutes = 5) {
  if (checkout.hasOrder) return { action: "skip", reason: "already has an order" };
  if (checkout.authorizeStatus !== "FULL") return { action: "skip", reason: "not fully authorized" };
  const ageMinutes = (Date.parse(nowIso) - Date.parse(checkout.createdAt)) / 60000;
  if (ageMinutes < graceMinutes) return { action: "skip", reason: "too new, still likely mid-flow" };
  if (PENDING_CHARGE_STATES.has(checkout.chargeStatus)) {
    return { action: "flag", reason: "provider-side confirmation still pending" };
  }
  return { action: "complete", reason: "paid, aged past grace period, no order yet" };
}

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 {
  checkouts(first: 50, filter: {authorizeStatus: [FULL]}) {
    edges {
      node {
        id
        token
        created
        channel { slug }
        totalPrice { gross { amount currency } }
        authorizeStatus
        chargeStatus
        transactions { id chargeStatus authorizeStatus }
      }
    }
  }
}`;

const COMPLETE_MUTATION = `
mutation Complete($id: ID!) {
  checkoutComplete(id: $id) {
    order { id number }
    confirmationNeeded
    confirmationData
    errors { field code message }
  }
}`;

async function paidCheckouts() {
  const data = (await gql(CHECKOUTS_QUERY)).checkouts;
  return data.edges.map((edge) => {
    const node = edge.node;
    node.createdAt = node.created;
    node.hasOrder = false;
    return node;
  });
}

async function completeCheckout(checkoutId) {
  const result = (await gql(COMPLETE_MUTATION, { id: checkoutId })).checkoutComplete;
  if (result.errors.length) return { status: "unfixable", errors: result.errors };
  if (result.confirmationNeeded) return { status: "needs_confirmation", confirmationData: result.confirmationData };
  return { status: "completed", order: result.order };
}

export async function run() {
  const nowIso = new Date().toISOString();
  let completed = 0;
  let flagged = 0;
  for (const checkout of await paidCheckouts()) {
    const decision = shouldCompleteCheckout(checkout, nowIso, GRACE_MINUTES);
    if (decision.action === "skip") continue;
    if (decision.action === "flag") {
      console.warn(`Checkout ${checkout.token} flagged: ${decision.reason}`);
      flagged++;
      continue;
    }
    console.log(`Checkout ${checkout.token} eligible. ${DRY_RUN ? "would complete" : "completing"}`);
    if (!DRY_RUN) {
      const outcome = await completeCheckout(checkout.id);
      if (outcome.status !== "completed") {
        console.warn(`Checkout ${checkout.token} not completed:`, outcome);
        continue;
      }
    }
    completed++;
  }
  console.log(`Done. ${completed} checkout(s) ${DRY_RUN ? "to complete" : "completed"}, ${flagged} flagged for review.`);
}

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 whether a real checkout gets completed, flagged, or left alone. Because we kept shouldCompleteCheckout pure and gave it a fixed clock, the test needs no network and no Saleor store. It just feeds in plain objects and checks the answer.

test_paid_checkout_decision.py
from complete_paid_checkouts import should_complete_checkout

NOW = "2026-07-10T00:30:00+00:00"


def checkout(**over):
    base = {
        "hasOrder": False,
        "authorizeStatus": "FULL",
        "chargeStatus": "FULL",
        "createdAt": "2026-07-10T00:20:00+00:00",  # 10 minutes old
    }
    base.update(over)
    return base


def test_completes_when_paid_aged_and_no_order():
    result = should_complete_checkout(checkout(), NOW, grace_minutes=5)
    assert result["action"] == "complete"


def test_skips_when_already_has_order():
    result = should_complete_checkout(checkout(hasOrder=True), NOW, grace_minutes=5)
    assert result["action"] == "skip"


def test_skips_when_not_fully_authorized():
    result = should_complete_checkout(checkout(authorizeStatus="PARTIAL"), NOW, grace_minutes=5)
    assert result["action"] == "skip"


def test_skips_when_too_new():
    result = should_complete_checkout(checkout(createdAt="2026-07-10T00:27:00+00:00"), NOW, grace_minutes=5)
    assert result["action"] == "skip"


def test_flags_when_charge_status_pending():
    result = should_complete_checkout(checkout(chargeStatus="PENDING"), NOW, grace_minutes=5)
    assert result["action"] == "flag"


def test_exactly_at_grace_period_completes():
    result = should_complete_checkout(checkout(createdAt="2026-07-10T00:25:00+00:00"), NOW, grace_minutes=5)
    assert result["action"] == "complete"
decision.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { shouldCompleteCheckout } from "./complete-paid-checkouts.js";

const NOW = "2026-07-10T00:30:00.000Z";

const checkout = (over = {}) => ({
  hasOrder: false,
  authorizeStatus: "FULL",
  chargeStatus: "FULL",
  createdAt: "2026-07-10T00:20:00.000Z",
  ...over,
});

test("completes when paid, aged, and no order", () => {
  assert.equal(shouldCompleteCheckout(checkout(), NOW, 5).action, "complete");
});

test("skips when already has an order", () => {
  assert.equal(shouldCompleteCheckout(checkout({ hasOrder: true }), NOW, 5).action, "skip");
});

test("skips when not fully authorized", () => {
  assert.equal(shouldCompleteCheckout(checkout({ authorizeStatus: "PARTIAL" }), NOW, 5).action, "skip");
});

test("skips when too new", () => {
  assert.equal(shouldCompleteCheckout(checkout({ createdAt: "2026-07-10T00:27:00.000Z" }), NOW, 5).action, "skip");
});

test("flags when charge status is pending", () => {
  assert.equal(shouldCompleteCheckout(checkout({ chargeStatus: "PENDING" }), NOW, 5).action, "flag");
});

test("exactly at grace period completes", () => {
  assert.equal(shouldCompleteCheckout(checkout({ createdAt: "2026-07-10T00:25:00.000Z" }), NOW, 5).action, "complete");
});

Case studies

3D Secure redirect

The bank redirect that never came back

A subscription box store used a card provider that sends shoppers through a 3D Secure redirect on their bank's own site. A slow connection or a closed tab after the bank approved the charge meant the shopper never landed back on the confirmation page, and checkoutComplete never fired.

Support was fielding a support ticket every few days from customers who had been charged but never received an order confirmation. Running the reconciler every fifteen minutes closed that gap: checkouts with authorizeStatus: FULL and no pending provider state got completed automatically, and only the ones still mid-redirect were flagged for a human to check.

Mobile checkout

The mobile app that got backgrounded

A storefront built as a mobile web app saw a pattern where shoppers would approve a payment, then switch apps to check a confirmation text message, and the browser tab would get suspended by the OS before the completion call ran.

The team first ran the script with DRY_RUN=true for a week and watched the flagged and completed counts, confirming the grace period was long enough to avoid touching in-flight checkouts. Once they trusted the numbers, they turned it live and stopped losing paid sales to a background tab.

What good looks like

After this runs on a schedule, a payment that succeeds always ends up as an Order, whether the storefront's own completion call made it through or not. Checkouts that still need a 3DS or redirect step are surfaced for a human instead of being silently forced or silently lost. Nobody has to explain to a customer why they were charged for an order that does not exist.

FAQ

Why does a paid Saleor checkout not turn into an Order?

Saleor does not convert a Checkout to an Order by itself when a payment succeeds. The storefront has to call the checkoutComplete mutation as a separate step after the payment provider confirms the charge. If the browser tab closes, the app crashes, or the network drops between the payment reaching FULL and that final call, the Checkout is left behind with money captured but no Order.

Is it safe to run checkoutComplete for old checkouts with a script?

Yes, when the script only targets checkouts whose authorizeStatus is FULL, that are older than a short grace period so it never races a checkout still mid-flow, and that do not already have an Order. It should also skip anything where confirmationNeeded comes back true, since that means the payment provider still needs a redirect or 3DS step a script cannot complete on its own.

What happens to a paid checkout if nobody fixes it?

Saleor holds the stock reservation and the captured funds against that Checkout for a limited window, roughly six hours by default. If nothing calls checkoutComplete before that window closes, the reservation and the payment record are released and the sale is effectively lost even though the customer was charged.

Related field notes

Citations

On the problem:

  1. Saleor Commerce Documentation: Checkout Troubleshooting. docs.saleor.io/developer/checkout/troubleshooting
  2. Saleor Commerce Documentation: Checkout Lifecycle. docs.saleor.io/developer/checkout/lifecycle
  3. Saleor GitHub: Bug, the checkoutPaymentCreate can be run during checkoutComplete performing, Issue #11132. github.com/saleor/saleor/issues/11132

On the solution:

  1. Saleor Commerce Documentation: the checkoutComplete mutation. docs.saleor.io/api-reference/checkout/mutations/checkout-complete
  2. Saleor Commerce Documentation: the checkouts query. docs.saleor.io/api-reference/checkout/queries/checkouts
  3. Saleor Commerce Documentation: Transactions. docs.saleor.io/developer/payments/transactions

Stuck on a tricky one?

If you have a problem in Saleor checkout, stock reservation, payments, or order flows 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 recover a lost sale?

If this saved you a pile of stranded checkouts or a confused customer, 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