Diagnostic Customers & Notifications
Order confirmation email sent before payment succeeds
The customer got redirected off to a payment provider, never finished paying, and still landed a cheerful "your order is confirmed" email in their inbox minutes later. Now support is fielding a confused reply about an order that is not actually paid, and possibly never will be. Here is why Saleor sends that email at order creation instead of payment success, and a script that finds every order caught in the gap.
Saleor's checkout flow calls send_order_confirmation synchronously right after checkoutComplete creates the order, which fires the ORDER_CREATED notification and logs the OrderEventsEnum.PLACED order event, regardless of whether the chosen payment or transaction has actually reached a captured or charge-success state. This is a long-standing, acknowledged gap in Saleor (GitHub issue #3527). When a customer is redirected off-site to a payment provider and abandons or fails that payment, they still receive a confirmation email for an order sitting UNCONFIRMED or UNFULFILLED with isPaid false. There is no mutation to un-send that email. Run a small Python or Node.js script that compares each order's PLACED event timestamp against its transaction timeline, flags the ones where the email fired with no successful charge behind it, and only cancels the truly abandoned ones after a grace window, guarded by dry run. Full code, tests, and the decision logic are below.
The problem in plain words
In Saleor, placing an order and paying for an order are two separate events that happen to usually occur close together, but are never actually linked. checkoutComplete converts a checkout into an order, and as part of that same call, Saleor triggers send_order_confirmation, which emits the order confirmation notification and logs the PLACED event. That happens whether the payment behind the checkout captured successfully, is still pending, or failed outright.
Most of the time this is invisible, because an on-site card payment authorizes and captures within the same request, so the order is effectively paid by the time the email goes out. But plenty of payment methods do not work that way. A customer redirected to a bank page, a wallet, or a buy-now-pay-later provider might close the tab, get declined on the provider's side, or simply never finish. Saleor already sent the order confirmation before any of that plays out, because the email was never waiting on the transaction in the first place. The order exists in UNCONFIRMED or UNFULFILLED with isPaid false, sometimes forever, while the customer holds an email that says otherwise.
Why it happens
- Saleor treats "order placed" and "payment captured" as two independent, non-blocking events. An order can exist in
UNCONFIRMEDorUNFULFILLEDwithisPaidfalse while its transactions are stillCHARGE_PENDINGor have failed outright. checkoutCompletecallssend_order_confirmationsynchronously as part of creating the order, which emits theORDER_CREATEDor account confirmation notification and logs theOrderEventsEnum.PLACEDorder event. Nothing in that path checks the payment or transaction state first.- Nothing in core checkout logic gates the confirmation email on a successful
TransactionEventof typeCHARGE_SUCCESSorAUTHORIZATION_SUCCESS. The dispatch and the payment outcome are simply unrelated code paths. - This gap is most visible with payment methods that redirect off-site, such as bank transfers, wallets, or buy-now-pay-later, where there is a real gap in time and control between order creation and the payment actually resolving one way or the other.
This is a long-standing, acknowledged design gap, not a bug specific to any one deployment: see GitHub issue #3527 in the citations, filed years ago and still describing the exact same behavior. Store owners usually find out about it only when a customer replies confused to a confirmation email for an order that later shows unpaid or cancelled.
You cannot fix this by watching the email queue, because the email is not the bug, the missing gate is. The confirmation dispatch and the payment success are two independent signals in Saleor, and nothing in core wiring will ever make the first wait on the second. Detection has to compare timestamps after the fact: when did PLACED fire, and when, if ever, did a CHARGE_SUCCESS transaction event land. There is also no supported way to un-send an email once it is out, so the only honest response to a bad match is to flag it for a human, and only cancel the order outright once you are sure the payment truly is not coming.
The fix, as a flow
The script runs on a schedule. For each order it pulls the order event timeline and the transaction timeline, finds the timestamp of the PLACED event and the earliest successful charge event, and runs a single pure function to classify the order. Orders where the email correctly followed a successful charge are left alone. Orders where the email fired early and payment never arrived are reported for support follow-up, and only once they clear a grace window with still no successful charge does the script mark them eligible for cancellation, itself gated behind a dry run flag.
Build it step by step
Get an app token with order read and manage access
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders. If you plan to use the guarded cancellation path, it also needs permission to manage orders since that path calls orderCancel. Use the resulting app token as a Bearer token, or exchange staff credentials with tokenCreate. 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="your-app-or-staff-token"
export CANCEL_GRACE_HOURS="24"
export DRY_RUN="true" # start safe, this script never writes without it off
// 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 CANCEL_GRACE_HOURS="24"
export DRY_RUN="true" // start safe, this script never writes without it off
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
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;
}
Page through orders and pull each timeline
First page through orders(first, after) reading id, number, isPaid, status, created, events { type date }, and the legacy payments { transactions { kind isSuccess created } } for older deployments. Then, per order, call the Transactions API to read transactions { events { type createdAt } } for the current transaction model. Page with a cursor so the job handles a large backlog.
ORDERS_QUERY = """
query OrdersWithTimeline($first: Int!, $after: String) {
orders(first: $first, after: $after) {
pageInfo { hasNextPage endCursor }
edges {
node {
id number isPaid status created
events { type date }
payments { id isActive transactions { id kind isSuccess created } }
}
}
}
}"""
TRANSACTIONS_QUERY = """
query OrderTransactions($id: ID!) {
order(id: $id) {
id isPaid
transactions { id events { type createdAt pspReference } }
}
}"""
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"first": 50, "after": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def fetch_transactions(order_id):
return gql(TRANSACTIONS_QUERY, {"id": order_id})["order"]["transactions"]
const ORDERS_QUERY = `
query OrdersWithTimeline($first: Int!, $after: String) {
orders(first: $first, after: $after) {
pageInfo { hasNextPage endCursor }
edges {
node {
id number isPaid status created
events { type date }
payments { id isActive transactions { id kind isSuccess created } }
}
}
}
}`;
const TRANSACTIONS_QUERY = `
query OrderTransactions($id: ID!) {
order(id: $id) {
id isPaid
transactions { id events { type createdAt pspReference } }
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { first: 50, after: cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function fetchTransactions(orderId) {
const data = await gql(TRANSACTIONS_QUERY, { id: orderId });
return data.order.transactions;
}
Find the two timestamps that matter
From the order events, take the timestamp of the event where type == PLACED, since that is when send_order_confirmation fired. From the transactions, take the earliest TransactionEvent.createdAt where type == CHARGE_SUCCESS, falling back to the legacy Transaction.created where kind == CAPTURE and isSuccess == true for stores still on the old payments model.
def confirm_event_timestamp(order):
for event in order.get("events") or []:
if event.get("type") == "PLACED":
return event.get("date")
return None
def charge_success_timestamp(order, transactions):
times = [e["createdAt"] for t in (transactions or []) for e in (t.get("events") or [])
if e.get("type") == "CHARGE_SUCCESS"]
if not times:
for payment in order.get("payments") or []:
for tx in payment.get("transactions") or []:
if tx.get("kind") == "CAPTURE" and tx.get("isSuccess"):
times.append(tx["created"])
return min(times) if times else None
export function confirmEventTimestamp(order) {
for (const event of order.events || []) {
if (event.type === "PLACED") return event.date;
}
return null;
}
export function chargeSuccessTimestamp(order, transactions) {
let times = [];
for (const t of transactions || []) {
for (const e of t.events || []) {
if (e.type === "CHARGE_SUCCESS") times.push(e.createdAt);
}
}
if (times.length === 0) {
for (const payment of order.payments || []) {
for (const tx of payment.transactions || []) {
if (tx.kind === "CAPTURE" && tx.isSuccess) times.push(tx.created);
}
}
}
return times.length ? times.sort()[0] : null;
}
Decide, with one pure function
Keep the decision in its own function that takes the two timestamps, whether the order is paid, and the current time, and returns one of three outcomes. It never touches the network, so it is easy to test and easy to trust. An order is ok whenever there was never a confirmation to worry about, when the charge succeeded at or before the confirmation, or when it is paid through some other path such as a manual orderMarkAsPaid. It becomes flag_email_premature when the confirmation fired with no successful charge yet and the order is still young, and flag_and_eligible_for_cancel once that same order clears the grace window.
def decide_confirmation_timing_issue(confirm_event_ts, charge_success_ts, order_is_paid,
now, cancel_grace_hours=24):
if confirm_event_ts is None:
return "ok"
if charge_success_ts is not None and confirm_event_ts <= charge_success_ts:
return "ok"
if charge_success_ts is not None and confirm_event_ts > charge_success_ts:
return "ok"
if order_is_paid:
return "ok"
age_hours = (now - confirm_event_ts).total_seconds() / 3600
if age_hours >= cancel_grace_hours:
return "flag_and_eligible_for_cancel"
return "flag_email_premature"
export function decideConfirmationTimingIssue(confirmEventTs, chargeSuccessTs, orderIsPaid,
now, cancelGraceHours = 24) {
if (confirmEventTs === null || confirmEventTs === undefined) return "ok";
if (chargeSuccessTs !== null && chargeSuccessTs !== undefined && confirmEventTs <= chargeSuccessTs) return "ok";
if (chargeSuccessTs !== null && chargeSuccessTs !== undefined && confirmEventTs > chargeSuccessTs) return "ok";
if (orderIsPaid) return "ok";
const ageHours = (now - confirmEventTs) / (1000 * 60 * 60);
if (ageHours >= cancelGraceHours) return "flag_and_eligible_for_cancel";
return "flag_email_premature";
}
Report every flagged order, cancel only the eligible ones
For every order the function does not call ok, write a report entry with the order id, number, both timestamps, and current isPaid and status, so support can send a corrected follow-up email. Only for orders that come back flag_and_eligible_for_cancel, and only under DRY_RUN=false, call orderCancel to release the allocation. There is no supported mutation to un-send the email itself, so cancellation is the only corrective write this script ever makes.
CANCEL_ORDER = """
mutation CancelUnpaidOrder($id: ID!) {
orderCancel(id: $id) {
order { id status }
errors { field message code }
}
}"""
def cancel_unpaid_order(order_id):
result = gql(CANCEL_ORDER, {"id": order_id})["orderCancel"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["status"]
const CANCEL_ORDER = `
mutation CancelUnpaidOrder($id: ID!) {
orderCancel(id: $id) {
order { id status }
errors { field message code }
}
}`;
async function cancelUnpaidOrder(orderId) {
const result = (await gql(CANCEL_ORDER, { id: orderId })).orderCancel;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.order.status;
}
This script's default behavior is report-only, and it should stay that way for almost every store. Only flip DRY_RUN=false once you have reviewed the exact list of orders it would cancel, and only for orders past the grace window with no successful charge, so a slower payment method such as a bank transfer still in flight never gets cancelled out from under a customer who did pay.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through orders, pulls each transaction timeline, classifies every order with the pure function, reports every flagged order, and only cancels the ones eligible for it when DRY_RUN is off.
"""Flag Saleor orders whose confirmation email fired before the payment ever
succeeded, because send_order_confirmation runs synchronously inside
checkoutComplete at order-creation time, never gated on a successful
CHARGE_SUCCESS or AUTHORIZATION_SUCCESS transaction event (see saleor/saleor#3527
and the TransactionEvent and Order object docs).
This script never un-sends an email, since Saleor has no mutation for that.
Under DRY_RUN=true (the default) it only logs a report entry for each flagged
order for support follow-up. Only orders that clear CANCEL_GRACE_HOURS with
still no successful charge are cancelled with orderCancel, and only when
DRY_RUN=false. 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("flag_confirmation_timing")
API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
CANCEL_GRACE_HOURS = float(os.environ.get("CANCEL_GRACE_HOURS", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDERS_QUERY = """
query OrdersWithTimeline($first: Int!, $after: String) {
orders(first: $first, after: $after) {
pageInfo { hasNextPage endCursor }
edges {
node {
id number isPaid status created
events { type date }
payments { id isActive transactions { id kind isSuccess created } }
}
}
}
}"""
TRANSACTIONS_QUERY = """
query OrderTransactions($id: ID!) {
order(id: $id) {
id isPaid
transactions { id events { type createdAt pspReference } }
}
}"""
CANCEL_ORDER = """
mutation CancelUnpaidOrder($id: ID!) {
orderCancel(id: $id) {
order { id status }
errors { field message code }
}
}"""
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 confirm_event_timestamp(order):
for event in order.get("events") or []:
if event.get("type") == "PLACED":
return event.get("date")
return None
def charge_success_timestamp(order, transactions):
times = [e["createdAt"] for t in (transactions or []) for e in (t.get("events") or [])
if e.get("type") == "CHARGE_SUCCESS"]
if not times:
for payment in order.get("payments") or []:
for tx in payment.get("transactions") or []:
if tx.get("kind") == "CAPTURE" and tx.get("isSuccess"):
times.append(tx["created"])
return min(times) if times else None
def decide_confirmation_timing_issue(confirm_event_ts, charge_success_ts, order_is_paid,
now, cancel_grace_hours=24):
if confirm_event_ts is None:
return "ok"
if charge_success_ts is not None and confirm_event_ts <= charge_success_ts:
return "ok"
if charge_success_ts is not None and confirm_event_ts > charge_success_ts:
return "ok"
if order_is_paid:
return "ok"
age_hours = (now - confirm_event_ts).total_seconds() / 3600
if age_hours >= cancel_grace_hours:
return "flag_and_eligible_for_cancel"
return "flag_email_premature"
def parse_iso(value):
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
def all_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"first": 50, "after": cursor})["orders"]
for edge in data["edges"]:
yield edge["node"]
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def fetch_transactions(order_id):
return gql(TRANSACTIONS_QUERY, {"id": order_id})["order"]["transactions"]
def cancel_unpaid_order(order_id):
result = gql(CANCEL_ORDER, {"id": order_id})["orderCancel"]
if result["errors"]:
raise RuntimeError(result["errors"])
return result["order"]["status"]
def run():
now = datetime.datetime.now(datetime.timezone.utc)
flagged = 0
cancelled = 0
for order in all_orders():
confirm_iso = confirm_event_timestamp(order)
if confirm_iso is None:
continue
transactions = fetch_transactions(order["id"])
charge_iso = charge_success_timestamp(order, transactions)
confirm_ts = parse_iso(confirm_iso)
charge_ts = parse_iso(charge_iso) if charge_iso else None
outcome = decide_confirmation_timing_issue(
confirm_ts, charge_ts, order.get("isPaid", False), now, CANCEL_GRACE_HOURS
)
if outcome == "ok":
continue
flagged += 1
report_entry = {
"orderId": order["id"],
"number": order["number"],
"confirmEventTs": confirm_iso,
"chargeSuccessTs": charge_iso,
"isPaid": order.get("isPaid", False),
"status": order.get("status"),
"outcome": outcome,
}
log.warning("Confirmation timing issue found. %s", report_entry)
if outcome == "flag_and_eligible_for_cancel":
if not DRY_RUN:
cancel_unpaid_order(order["id"])
cancelled += 1
else:
log.info("Order %s would be cancelled (dry run).", order["number"])
log.info("Done. %d order(s) flagged, %d cancelled.", flagged, cancelled)
if __name__ == "__main__":
run()
/**
* Flag Saleor orders whose confirmation email fired before the payment ever
* succeeded, because send_order_confirmation runs synchronously inside
* checkoutComplete at order-creation time, never gated on a successful
* CHARGE_SUCCESS or AUTHORIZATION_SUCCESS transaction event (see saleor/saleor#3527
* and the TransactionEvent and Order object docs).
*
* This script never un-sends an email, since Saleor has no mutation for that.
* Under DRY_RUN=true (the default) it only logs a report entry for each flagged
* order for support follow-up. Only orders that clear CANCEL_GRACE_HOURS with
* still no successful charge are cancelled with orderCancel, and only when
* DRY_RUN=false. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/confirmation-email-sent-before-payment/
*/
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-token";
const CANCEL_GRACE_HOURS = Number(process.env.CANCEL_GRACE_HOURS || 24);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function confirmEventTimestamp(order) {
for (const event of order.events || []) {
if (event.type === "PLACED") return event.date;
}
return null;
}
export function chargeSuccessTimestamp(order, transactions) {
let times = [];
for (const t of transactions || []) {
for (const e of t.events || []) {
if (e.type === "CHARGE_SUCCESS") times.push(e.createdAt);
}
}
if (times.length === 0) {
for (const payment of order.payments || []) {
for (const tx of payment.transactions || []) {
if (tx.kind === "CAPTURE" && tx.isSuccess) times.push(tx.created);
}
}
}
return times.length ? times.sort()[0] : null;
}
export function decideConfirmationTimingIssue(confirmEventTs, chargeSuccessTs, orderIsPaid,
now, cancelGraceHours = 24) {
if (confirmEventTs === null || confirmEventTs === undefined) return "ok";
if (chargeSuccessTs !== null && chargeSuccessTs !== undefined && confirmEventTs <= chargeSuccessTs) return "ok";
if (chargeSuccessTs !== null && chargeSuccessTs !== undefined && confirmEventTs > chargeSuccessTs) return "ok";
if (orderIsPaid) return "ok";
const ageHours = (now - confirmEventTs) / (1000 * 60 * 60);
if (ageHours >= cancelGraceHours) return "flag_and_eligible_for_cancel";
return "flag_email_premature";
}
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 ORDERS_QUERY = `
query OrdersWithTimeline($first: Int!, $after: String) {
orders(first: $first, after: $after) {
pageInfo { hasNextPage endCursor }
edges {
node {
id number isPaid status created
events { type date }
payments { id isActive transactions { id kind isSuccess created } }
}
}
}
}`;
const TRANSACTIONS_QUERY = `
query OrderTransactions($id: ID!) {
order(id: $id) {
id isPaid
transactions { id events { type createdAt pspReference } }
}
}`;
const CANCEL_ORDER = `
mutation CancelUnpaidOrder($id: ID!) {
orderCancel(id: $id) {
order { id status }
errors { field message code }
}
}`;
async function* allOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { first: 50, after: cursor })).orders;
for (const edge of data.edges) yield edge.node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function fetchTransactions(orderId) {
const data = await gql(TRANSACTIONS_QUERY, { id: orderId });
return data.order.transactions;
}
async function cancelUnpaidOrder(orderId) {
const result = (await gql(CANCEL_ORDER, { id: orderId })).orderCancel;
if (result.errors.length) throw new Error(JSON.stringify(result.errors));
return result.order.status;
}
export async function run() {
const now = Date.now();
let flagged = 0;
let cancelled = 0;
for await (const order of allOrders()) {
const confirmIso = confirmEventTimestamp(order);
if (confirmIso === null) continue;
const transactions = await fetchTransactions(order.id);
const chargeIso = chargeSuccessTimestamp(order, transactions);
const confirmTs = Date.parse(confirmIso);
const chargeTs = chargeIso ? Date.parse(chargeIso) : null;
const outcome = decideConfirmationTimingIssue(
confirmTs, chargeTs, order.isPaid === true, now, CANCEL_GRACE_HOURS
);
if (outcome === "ok") continue;
flagged++;
const reportEntry = {
orderId: order.id,
number: order.number,
confirmEventTs: confirmIso,
chargeSuccessTs: chargeIso,
isPaid: order.isPaid === true,
status: order.status,
outcome,
};
console.warn("Confirmation timing issue found.", reportEntry);
if (outcome === "flag_and_eligible_for_cancel") {
if (!DRY_RUN) {
await cancelUnpaidOrder(order.id);
cancelled++;
} else {
console.log(`Order ${order.number} would be cancelled (dry run).`);
}
}
}
console.log(`Done. ${flagged} order(s) flagged, ${cancelled} cancelled.`);
}
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 orders get reported to support and which ones ever become eligible for cancellation. Because decide_confirmation_timing_issue is pure, taking the current time as an argument instead of reading the clock itself, the test needs no network and no Saleor account. It just feeds in plain timestamps and checks the answer.
import datetime
from flag_confirmation_timing import decide_confirmation_timing_issue
NOW = datetime.datetime(2026, 7, 10, tzinfo=datetime.timezone.utc)
def hours_ago(h):
return NOW - datetime.timedelta(hours=h)
def test_ok_when_no_confirmation_was_ever_sent():
result = decide_confirmation_timing_issue(None, hours_ago(1), False, NOW)
assert result == "ok"
def test_ok_when_charge_succeeded_before_confirmation():
confirm = hours_ago(1)
charge = hours_ago(2)
result = decide_confirmation_timing_issue(confirm, charge, False, NOW)
assert result == "ok"
def test_ok_when_charge_succeeded_after_confirmation_same_request():
confirm = hours_ago(2)
charge = hours_ago(1)
result = decide_confirmation_timing_issue(confirm, charge, False, NOW)
assert result == "ok"
def test_ok_when_no_charge_but_order_is_paid_another_way():
result = decide_confirmation_timing_issue(hours_ago(30), None, True, NOW)
assert result == "ok"
def test_flag_email_premature_when_recent_and_unpaid_with_no_charge():
result = decide_confirmation_timing_issue(hours_ago(1), None, False, NOW, cancel_grace_hours=24)
assert result == "flag_email_premature"
def test_flag_and_eligible_for_cancel_past_grace_window():
result = decide_confirmation_timing_issue(hours_ago(25), None, False, NOW, cancel_grace_hours=24)
assert result == "flag_and_eligible_for_cancel"
def test_exactly_at_grace_window_is_eligible_for_cancel():
result = decide_confirmation_timing_issue(hours_ago(24), None, False, NOW, cancel_grace_hours=24)
assert result == "flag_and_eligible_for_cancel"
def test_custom_grace_window_is_respected():
result = decide_confirmation_timing_issue(hours_ago(5), None, False, NOW, cancel_grace_hours=4)
assert result == "flag_and_eligible_for_cancel"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideConfirmationTimingIssue } from "./flag-confirmation-timing.js";
const NOW = new Date("2026-07-10T00:00:00Z").getTime();
const hoursAgo = (h) => NOW - h * 60 * 60 * 1000;
test("ok when no confirmation was ever sent", () => {
const result = decideConfirmationTimingIssue(null, hoursAgo(1), false, NOW);
assert.equal(result, "ok");
});
test("ok when charge succeeded before confirmation", () => {
const confirm = hoursAgo(1);
const charge = hoursAgo(2);
assert.equal(decideConfirmationTimingIssue(confirm, charge, false, NOW), "ok");
});
test("ok when charge succeeded after confirmation in the same request", () => {
const confirm = hoursAgo(2);
const charge = hoursAgo(1);
assert.equal(decideConfirmationTimingIssue(confirm, charge, false, NOW), "ok");
});
test("ok when no charge but order is paid another way", () => {
assert.equal(decideConfirmationTimingIssue(hoursAgo(30), null, true, NOW), "ok");
});
test("flag_email_premature when recent and unpaid with no charge", () => {
const result = decideConfirmationTimingIssue(hoursAgo(1), null, false, NOW, 24);
assert.equal(result, "flag_email_premature");
});
test("flag_and_eligible_for_cancel past the grace window", () => {
const result = decideConfirmationTimingIssue(hoursAgo(25), null, false, NOW, 24);
assert.equal(result, "flag_and_eligible_for_cancel");
});
test("exactly at the grace window is eligible for cancel", () => {
const result = decideConfirmationTimingIssue(hoursAgo(24), null, false, NOW, 24);
assert.equal(result, "flag_and_eligible_for_cancel");
});
test("a custom grace window is respected", () => {
const result = decideConfirmationTimingIssue(hoursAgo(5), null, false, NOW, 4);
assert.equal(result, "flag_and_eligible_for_cancel");
});
Case studies
The customer closed the tab and kept the email
A store offering a redirect-based bank payment method saw a spike in support tickets from customers asking why an item they never finished paying for was "confirmed." The confirmation email had gone out the instant the order was created, well before the customer even reached the bank's page, and a good number of them simply abandoned it there.
Running the flag script surfaced every order where the PLACED event had no successful charge behind it. Support used the report to send each customer a short, honest follow-up explaining the payment never completed, and the orders that stayed unpaid past 24 hours were cancelled automatically, freeing the stock they had been holding.
The wallet declined the charge two minutes after the email
A merchant using a wallet-based payment provider found that a subset of orders were marked isPaid: false for days despite customers reporting they received the confirmation email right away. The wallet provider's decline came back a full two minutes after Saleor had already created the order and dispatched the notification, so the timing gap was there in every single case.
The team scheduled the script hourly. Orders flagged as flag_email_premature gave support a same-day heads-up on likely declines, and only the ones that aged past the grace window with no successful charge were ever cancelled, keeping the automation from touching an order some slower payment method might still legitimately complete.
After this runs on a schedule, a confirmation email that went out ahead of a failed or abandoned payment stops being a silent support burden. It shows up in a report within one run of the timing gap existing, with both timestamps and the current payment state right there for whoever follows up. No email ever gets un-sent, since Saleor has no way to do that, but the honest correction, and the cancellation of orders that truly never got paid, both happen on a schedule instead of only when a confused customer writes in.
FAQ
Why did my customer get an order confirmation email for an order that was never paid?
Saleor sends the order confirmation email the moment checkoutComplete creates the order, not when the payment actually succeeds. The order can exist as UNCONFIRMED or UNFULFILLED with isPaid false while the transaction is still pending, failed, or the customer abandoned an off-site payment page. Nothing in core checkout logic gates the email on a successful CHARGE_SUCCESS or AUTHORIZATION_SUCCESS transaction event.
Can I stop Saleor from sending that email early or un-send it after the fact?
Not through the API. Saleor has no mutation to un-send or retroactively suppress an email already delivered, and no supported mutation to defer send_order_confirmation until after a charge succeeds. The only durable fix is application-level: gate the notification dispatch behind a webhook or task triggered by a successful TRANSACTION_CHARGE_REQUESTED handling or legacy PAYMENT_CAPTURED event, which is a custom plugin or app change, not something an ops script can patch through GraphQL.
What should a detection script actually do about orders like this?
Flag and report only. Compare each order's PLACED event timestamp against its earliest successful charge event, and if the confirmation fired with no successful charge and the order is still unpaid, surface it for support to send a corrected follow-up email. Only under a DRY_RUN=false guard, and only once the order is old enough to clear a grace window for slower payment methods, is it safe to also cancel the order with orderCancel so it does not sit around unpaid forever.
Related field notes
Citations
On the problem:
- Order confirmation email is sent before order is paid. github.com/saleor/saleor/issues/3527
- How to receive notification about customer's order? github.com/saleor/saleor/issues/4932
- Saleor Commerce Documentation: Order Status. docs.saleor.io/developer/checkout/order-status
On the solution:
- Saleor Commerce Documentation: the TransactionEvent object. docs.saleor.io/api-reference/payments/objects/transaction-event
- Saleor Commerce Documentation: the TransactionEventTypeEnum enum. docs.saleor.io/api-reference/payments/enums/transaction-event-type-enum
- Saleor Commerce Documentation: the Order object. docs.saleor.io/api-reference/orders/objects/order
Stuck on a tricky one?
If you have a problem in Saleor checkout, payments, notifications, 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 batch of confusing emails?
If this saved you from a customer replying confused to a confirmation email that never should have gone out yet, 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