Diagnostic Order State Machine
Delayed flow does not trigger on payment failed transition
You built a Flow Builder flow on order_transaction.state.state_enter for failed, added a delay so the customer gets a grace period before the follow up email goes out, tested it without the delay and it worked fine. With the delay in place, it just never fires. No error in the log, nothing in Flow Builder's own history that looks broken. The payment really did fail, and the merchant never found out. Here is why the delay breaks the flow, and a script that finds every order this has already happened to.
Shopware's Flow Builder delay is built on a StorableFlow: when an action is delayed, only scalar ids are saved as a flow_execution row, and at resume time the flow is rebuilt from scratch by reloading the order, the transaction, and the rules straight from the database. Every condition is re-checked against that fresh, current state, not the state that fired the trigger in the first place. If the transaction has moved on by the time the delay elapses, a retried payment succeeded, the order got cancelled, or a queue race means the reload runs a beat before the state change is fully committed, the "payment status is failed" condition no longer matches and the delayed action is skipped without any error surfacing anywhere. This is tracked in the community as shopware/shopware#11971 and #4510. You cannot safely force a re-trigger by rewriting the transaction's state. Full detection code, tests, and sources are below.
The problem in plain words
A normal flow reacts to an event as it happens. The trigger fires, the flow reads whatever it needs off the event, checks its conditions, and runs its actions right away, all against the same moment in time. That is why the same flow works perfectly the instant you remove the delay.
A delayed action cannot work that way, because Shopware is not going to keep a PHP process alive for hours waiting on a timer. Instead it saves a small record describing what to resume and when, and lets the request finish. Later, a background worker picks that record back up and rebuilds the whole flow from scratch, which means it goes back to the database and asks it what the order and the transaction look like right now. If "right now" disagrees with "back when the trigger fired," the flow quietly follows the new answer, and the customer never gets the notification the merchant was counting on.
Why it happens
Shopware's Flow Builder implements delayed actions with a StorableFlow, a design documented in its own ADR. Only scalar identifiers, order id, transaction id, and similar, are persisted for a delayed step, not a frozen copy of the order or transaction. When the delay elapses, FlowFactory::restore() rebuilds the flow entirely from those ids, reloading the order, the transaction, and the rules fresh from the database and re-evaluating every condition against whatever that fresh read shows. A few concrete ways this bites:
- The customer retries the payment and it succeeds before the delay elapses. The transaction is no longer
failedby the time the flow resumes, so the "payment status is failed" condition fails to match and the delayed action never runs, even though it correctly matched when the trigger first fired. - The order gets cancelled in the meantime, which can move the transaction through its own state changes, again leaving the condition unable to match the state it was written to expect.
- A race in queue processing where the delayed message is picked up and the flow rebuilt a moment before the
state_machine_statetransition write and its related indexing are fully committed, so the reload reads a transaction that has not actually settled intofailedyet. - A separate and unrelated cause that gets mistaken for the same bug: a mail action aborting because a template variable it needs is missing. Shopware support has flagged this as a distinct failure mode worth ruling out before you assume every miss is the state re-check issue.
Community reports (shopware/shopware#11971 and #4510) confirm this is reproducible: the exact same flow fires instantly and correctly with no delay, and silently skips once a delay is added. See the citations at the end for the exact threads and docs.
A delayed flow is not "the same trigger, just later." It is a fresh re-evaluation of the current database state using only the ids it was given. The transaction's failed status at the time you are investigating is correct and must never be rewritten just to force a re-match. The only thing worth automating here is detection: finding failed transactions that are past their expected delay window with no completed flow_execution row, and handing that list to a human, because Shopware gives you no supported endpoint to re-queue or replay a specific delayed execution.
The fix, as a flow
We never touch the transaction's state and we never try to re-run the state transition to trick Flow Builder into re-firing. The script lists transactions sitting in failed, checks each one's flow_execution ledger for a completed row, cross-checks that the flow is still active and really targets this event, and reports every genuine miss so a human can manually resend the notification or re-run the flow from the Administration.
Build it step by step
Get an Admin API access token
Create an integration in Shopware under Settings, System, Integrations, and note its access key id and secret key. Exchange them for a bearer token with POST /api/oauth/token using grant_type: client_credentials. Keep the shop URL and credentials in environment variables, never in the file.
pip install requests
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPWARE_URL="https://yourstore.example.com"
export SHOPWARE_CLIENT_ID="SWIAXXXXXXXXXXXXXXXXXXXXXX"
export SHOPWARE_CLIENT_SECRET="..."
export DRY_RUN="true" // start safe, change to false to write
Authenticate and call the Admin API
A small helper gets a bearer token once, then sends every request with Authorization: Bearer <token> and Accept: application/json. We reuse it for the transaction search, the flow_execution search, and the flow definition check.
import os, requests
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_search(entity, token, body):
r = requests.post(
f"{SHOPWARE_URL}/api/search/{entity}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET;
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function apiSearch(entity, token, body) {
const res = await fetch(`${SHOPWARE_URL}/api/search/${entity}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${entity} search`);
const data = await res.json();
return data.data;
}
List transactions sitting in failed
Search order-transaction for the failed state, with the order association loaded so we have the order id and number ready for reporting. Sort by updatedAt descending so the most recent failures come first.
def failed_transactions(token, limit=500):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "failed"}],
"associations": {"stateMachineState": {}, "order": {}},
"sort": [{"field": "updatedAt", "order": "DESC"}],
"total-count-mode": 1,
}
return api_search("order-transaction", token, body)
async function failedTransactions(token, limit = 500) {
const body = {
page: 1,
limit,
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "failed" }],
associations: { stateMachineState: {}, order: {} },
sort: [{ field: "updatedAt", order: "DESC" }],
"total-count-mode": 1,
};
return apiSearch("order-transaction", token, body);
}
Decide, with one pure function
Keep the decision entirely separate from the network calls. Given the transaction's state and when it entered that state, the flow's definition, the list of flow_execution rows for that order, and the current time, decide whether the delayed action was missed. This takes no I/O, so it is trivial to test with fixed clock values around the boundary.
import datetime
TARGET_STATE = "failed"
STATE_ENTER_EVENT = "order_transaction.state.state_enter"
def _epoch(iso):
return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
def is_delayed_flow_missed(transaction, flow, executions, now):
if not flow.get("active"):
return False
if flow.get("eventName") != STATE_ENTER_EVENT:
return False
if transaction.get("stateTechnicalName") != TARGET_STATE:
return False
state_entered_at = _epoch(transaction["stateEnteredAt"])
due_at = state_entered_at + flow.get("delaySeconds", 0)
now_epoch = _epoch(now) if isinstance(now, str) else now
if now_epoch < due_at:
return False
if not executions:
return True
for execution in executions:
created_at = _epoch(execution["createdAt"])
if execution.get("finishedAt") is not None and created_at >= state_entered_at:
return False
return True
const TARGET_STATE = "failed";
const STATE_ENTER_EVENT = "order_transaction.state.state_enter";
function toEpoch(value) {
return typeof value === "number" ? value : Date.parse(value) / 1000;
}
export function isDelayedFlowMissed(transaction, flow, executions, now) {
if (!flow.active) return false;
if (flow.eventName !== STATE_ENTER_EVENT) return false;
if (transaction.stateTechnicalName !== TARGET_STATE) return false;
const stateEnteredAt = toEpoch(transaction.stateEnteredAt);
const dueAt = stateEnteredAt + (flow.delaySeconds || 0);
const nowEpoch = toEpoch(now);
if (nowEpoch < dueAt) return false;
if (!executions.length) return true;
const hasCompleted = executions.some(
(e) => e.finishedAt !== null && e.finishedAt !== undefined && toEpoch(e.createdAt) >= stateEnteredAt
);
return !hasCompleted;
}
Check the flow_execution ledger and the flow definition
For each failed transaction, search flow-execution by orderId to see whether the delayed action ever completed, and confirm the flow itself is active and really targets this state enter event, rather than assuming a disabled or misconfigured flow is the same bug.
def executions_for_order(token, order_id):
body = {
"filter": [{"type": "equals", "field": "orderId", "value": order_id}],
"associations": {"flow": {}},
"sort": [{"field": "createdAt", "order": "DESC"}],
}
return api_search("flow-execution", token, body)
def flows_for_event(token, event_name):
body = {
"filter": [{"type": "equals", "field": "eventName", "value": event_name}],
}
return api_search("flow", token, body)
async function executionsForOrder(token, orderId) {
const body = {
filter: [{ type: "equals", field: "orderId", value: orderId }],
associations: { flow: {} },
sort: [{ field: "createdAt", order: "DESC" }],
};
return apiSearch("flow-execution", token, body);
}
async function flowsForEvent(token, eventName) {
const body = {
filter: [{ type: "equals", field: "eventName", value: eventName }],
};
return apiSearch("flow", token, body);
}
Wire it together with a dry run guard
The loop ties every piece together: list failed transactions, pull the matching flow and its executions, run the pure decision function, and collect every miss into a report of {orderNumber, orderTransactionId, flowId, failedAt, expectedActionType}. Nothing here ever writes to the order or the transaction. If DRY_RUN is off, the only allowed write is calling the flow's own action directly, such as resending the mail template, never a state PATCH or a replayed transition.
Never PATCH stateId and never replay the state transition to force a re-trigger. This script only reports. If you wire up automated remediation later, gate it behind an explicit DRY_RUN=false, have it call the flow's action directly, and log every action taken for audit.
The full code
Here is the complete script in one file for each language. It authenticates, lists failed transactions, checks each one's flow_execution history and the flow's own definition, classifies with the pure function, and reports every genuine miss, respecting DRY_RUN and never writing to order state.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Flag Shopware 6 orders where a delayed Flow Builder action never fired
after the order_transaction moved to failed, without ever rewriting state.
Flow Builder's delay is implemented with a StorableFlow: only scalar ids are stored
while a delayed step waits, and at resume time FlowFactory::restore() rebuilds the
flow from scratch, reloading the order, transaction, and rules fresh from the
database and re-checking every condition against that current state, not the state
that fired the trigger. If the transaction has moved on by the time the delay
elapses, the condition no longer matches and the action is silently skipped
(shopware/shopware#11971, #4510).
This never PATCHes stateId and never replays the state transition to force a
re-trigger. It only detects genuine misses and reports them for a human to follow
up on, for example by resending the flow's mail action by hand. Run on a schedule.
Safe to run again and again, it never writes to order or transaction state.
"""
import os
import datetime
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_missed_delayed_flow")
SHOPWARE_URL = os.environ["SHOPWARE_URL"].rstrip("/")
CLIENT_ID = os.environ["SHOPWARE_CLIENT_ID"]
CLIENT_SECRET = os.environ["SHOPWARE_CLIENT_SECRET"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
GRACE_SECONDS = float(os.environ.get("GRACE_SECONDS", "300"))
TARGET_STATE = "failed"
STATE_ENTER_EVENT = "order_transaction.state.state_enter"
def _epoch(value):
if isinstance(value, (int, float)):
return float(value)
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
def is_delayed_flow_missed(transaction, flow, executions, now):
"""Pure decision. No I/O. All inputs are passed in."""
if not flow.get("active"):
return False
if flow.get("eventName") != STATE_ENTER_EVENT:
return False
if transaction.get("stateTechnicalName") != TARGET_STATE:
return False
state_entered_at = _epoch(transaction["stateEnteredAt"])
due_at = state_entered_at + flow.get("delaySeconds", 0)
now_epoch = _epoch(now)
if now_epoch < due_at:
return False
if not executions:
return True
for execution in executions:
created_at = _epoch(execution["createdAt"])
if execution.get("finishedAt") is not None and created_at >= state_entered_at:
return False
return True
def get_token():
r = requests.post(
f"{SHOPWARE_URL}/api/oauth/token",
json={"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET},
timeout=30,
)
r.raise_for_status()
return r.json()["access_token"]
def api_search(entity, token, body):
r = requests.post(
f"{SHOPWARE_URL}/api/search/{entity}",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
def failed_transactions(token, limit=500):
body = {
"page": 1,
"limit": limit,
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": TARGET_STATE}],
"associations": {"stateMachineState": {}, "order": {}},
"sort": [{"field": "updatedAt", "order": "DESC"}],
"total-count-mode": 1,
}
return api_search("order-transaction", token, body)
def executions_for_order(token, order_id):
body = {
"filter": [{"type": "equals", "field": "orderId", "value": order_id}],
"associations": {"flow": {}},
"sort": [{"field": "createdAt", "order": "DESC"}],
}
return api_search("flow-execution", token, body)
def flows_for_event(token, event_name):
body = {
"filter": [{"type": "equals", "field": "eventName", "value": event_name}],
}
return api_search("flow", token, body)
def run():
token = get_token()
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
now_epoch = _epoch(now)
candidate_flows = flows_for_event(token, STATE_ENTER_EVENT)
if not candidate_flows:
log.info("No active flow targets %s. Nothing to check.", STATE_ENTER_EVENT)
return
flagged = []
for transaction in failed_transactions(token):
order = transaction.get("order") or {}
order_id = transaction.get("orderId")
transaction_snapshot = {
"stateTechnicalName": (transaction.get("stateMachineState") or {}).get("technicalName"),
"stateEnteredAt": transaction["updatedAt"],
}
executions = executions_for_order(token, order_id) if order_id else []
for flow in candidate_flows:
flow_snapshot = {
"active": flow.get("active", False),
"eventName": flow.get("eventName"),
"delaySeconds": GRACE_SECONDS,
}
if is_delayed_flow_missed(transaction_snapshot, flow_snapshot, executions, now_epoch):
flagged.append({
"orderNumber": order.get("orderNumber"),
"orderTransactionId": transaction["id"],
"flowId": flow["id"],
"failedAt": transaction_snapshot["stateEnteredAt"],
"expectedActionType": "mail_send (or the flow's configured action)",
})
if not flagged:
log.info("Done. No missed delayed flow actions found.")
return
for item in flagged:
log.warning(
"Order %s: transaction %s failed at %s, flow %s never completed. %s",
item["orderNumber"], item["orderTransactionId"], item["failedAt"], item["flowId"],
"would flag for manual follow up" if DRY_RUN else "flagged for manual follow up",
)
log.info("Done. %d order(s) with a missed delayed flow action %s.",
len(flagged), "to review" if DRY_RUN else "reported")
if __name__ == "__main__":
run()
/**
* Flag Shopware 6 orders where a delayed Flow Builder action never fired
* after the order_transaction moved to failed, without ever rewriting state.
*
* Flow Builder's delay is implemented with a StorableFlow: only scalar ids are stored
* while a delayed step waits, and at resume time FlowFactory::restore() rebuilds the
* flow from scratch, reloading the order, transaction, and rules fresh from the
* database and re-checking every condition against that current state, not the state
* that fired the trigger. If the transaction has moved on by the time the delay
* elapses, the condition no longer matches and the action is silently skipped
* (shopware/shopware#11971, #4510).
*
* This never PATCHes stateId and never replays the state transition to force a
* re-trigger. It only detects genuine misses and reports them for a human to follow
* up on, for example by resending the flow's mail action by hand. Run on a schedule.
* Safe to run again and again, it never writes to order or transaction state.
*
* Guide: https://www.allanninal.dev/shopware/delayed-flow-not-triggered-on-failed/
*/
import { pathToFileURL } from "node:url";
const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-secret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const GRACE_SECONDS = Number(process.env.GRACE_SECONDS || 300);
const TARGET_STATE = "failed";
const STATE_ENTER_EVENT = "order_transaction.state.state_enter";
function toEpoch(value) {
return typeof value === "number" ? value : Date.parse(value) / 1000;
}
export function isDelayedFlowMissed(transaction, flow, executions, now) {
if (!flow.active) return false;
if (flow.eventName !== STATE_ENTER_EVENT) return false;
if (transaction.stateTechnicalName !== TARGET_STATE) return false;
const stateEnteredAt = toEpoch(transaction.stateEnteredAt);
const dueAt = stateEnteredAt + (flow.delaySeconds || 0);
const nowEpoch = toEpoch(now);
if (nowEpoch < dueAt) return false;
if (!executions.length) return true;
const hasCompleted = executions.some(
(e) => e.finishedAt !== null && e.finishedAt !== undefined && toEpoch(e.createdAt) >= stateEnteredAt
);
return !hasCompleted;
}
async function getToken() {
const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grant_type: "client_credentials", client_id: CLIENT_ID, client_secret: CLIENT_SECRET }),
});
if (!res.ok) throw new Error(`Shopware auth ${res.status}`);
const body = await res.json();
return body.access_token;
}
async function apiSearch(entity, token, body) {
const res = await fetch(`${SHOPWARE_URL}/api/search/${entity}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Shopware ${res.status} on ${entity} search`);
const data = await res.json();
return data.data;
}
async function failedTransactions(token, limit = 500) {
const body = {
page: 1,
limit,
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: TARGET_STATE }],
associations: { stateMachineState: {}, order: {} },
sort: [{ field: "updatedAt", order: "DESC" }],
"total-count-mode": 1,
};
return apiSearch("order-transaction", token, body);
}
async function executionsForOrder(token, orderId) {
const body = {
filter: [{ type: "equals", field: "orderId", value: orderId }],
associations: { flow: {} },
sort: [{ field: "createdAt", order: "DESC" }],
};
return apiSearch("flow-execution", token, body);
}
async function flowsForEvent(token, eventName) {
const body = {
filter: [{ type: "equals", field: "eventName", value: eventName }],
};
return apiSearch("flow", token, body);
}
export async function run() {
const token = await getToken();
const now = new Date().toISOString();
const nowEpoch = toEpoch(now);
const candidateFlows = await flowsForEvent(token, STATE_ENTER_EVENT);
if (!candidateFlows.length) {
console.log(`No active flow targets ${STATE_ENTER_EVENT}. Nothing to check.`);
return;
}
const transactions = await failedTransactions(token);
const flagged = [];
for (const transaction of transactions) {
const order = transaction.order || {};
const orderId = transaction.orderId;
const transactionSnapshot = {
stateTechnicalName: transaction.stateMachineState?.technicalName,
stateEnteredAt: transaction.updatedAt,
};
const executions = orderId ? await executionsForOrder(token, orderId) : [];
for (const flow of candidateFlows) {
const flowSnapshot = {
active: flow.active ?? false,
eventName: flow.eventName,
delaySeconds: GRACE_SECONDS,
};
if (isDelayedFlowMissed(transactionSnapshot, flowSnapshot, executions, nowEpoch)) {
flagged.push({
orderNumber: order.orderNumber,
orderTransactionId: transaction.id,
flowId: flow.id,
failedAt: transactionSnapshot.stateEnteredAt,
expectedActionType: "mail_send (or the flow's configured action)",
});
}
}
}
if (!flagged.length) {
console.log("Done. No missed delayed flow actions found.");
return;
}
for (const item of flagged) {
console.warn(
`Order ${item.orderNumber}: transaction ${item.orderTransactionId} failed at ${item.failedAt}, flow ${item.flowId} never completed. ${DRY_RUN ? "would flag for manual follow up" : "flagged for manual follow up"}`
);
}
console.log(`Done. ${flagged.length} order(s) with a missed delayed flow action ${DRY_RUN ? "to review" : "reported"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision function is the part most worth testing, because it decides which orders get reported as a missed delayed action. Because is_delayed_flow_missed is pure and takes a fixed clock value, the test needs no network and no live Shopware store, and covers the due-at boundary directly.
from flag_missed_delayed_flow import is_delayed_flow_missed, _epoch
NOW = _epoch("2026-07-10T00:10:00Z") # 10 minutes after state entered
def transaction(**over):
base = {"stateTechnicalName": "failed", "stateEnteredAt": "2026-07-10T00:00:00Z"}
base.update(over)
return base
def flow(**over):
base = {"active": True, "eventName": "order_transaction.state.state_enter", "delaySeconds": 300}
base.update(over)
return base
def test_missed_when_no_executions_and_due():
assert is_delayed_flow_missed(transaction(), flow(), [], NOW) is True
def test_not_missed_when_flow_inactive():
assert is_delayed_flow_missed(transaction(), flow(active=False), [], NOW) is False
def test_not_missed_when_event_name_does_not_match():
assert is_delayed_flow_missed(transaction(), flow(eventName="order.state.state_enter"), [], NOW) is False
def test_not_missed_when_transaction_state_does_not_match():
assert is_delayed_flow_missed(transaction(stateTechnicalName="paid"), flow(), [], NOW) is False
def test_not_missed_when_not_due_yet():
early_now = _epoch("2026-07-10T00:01:00Z") # only 1 minute in, delay is 300s
assert is_delayed_flow_missed(transaction(), flow(), [], early_now) is False
def test_not_missed_when_a_completed_execution_exists():
executions = [{"createdAt": "2026-07-10T00:00:05Z", "finishedAt": "2026-07-10T00:00:10Z"}]
assert is_delayed_flow_missed(transaction(), flow(), executions, NOW) is False
def test_missed_when_execution_is_stuck_unfinished():
executions = [{"createdAt": "2026-07-10T00:00:05Z", "finishedAt": None}]
assert is_delayed_flow_missed(transaction(), flow(), executions, NOW) is True
def test_not_missed_when_completed_execution_predates_state_enter():
# a finished execution from before this state_enter does not count
executions = [{"createdAt": "2026-07-09T23:00:00Z", "finishedAt": "2026-07-09T23:00:05Z"}]
assert is_delayed_flow_missed(transaction(), flow(), executions, NOW) is True
def test_exactly_at_due_at_is_checked():
exactly_due = _epoch("2026-07-10T00:05:00Z")
assert is_delayed_flow_missed(transaction(), flow(), [], exactly_due) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { isDelayedFlowMissed } from "./flag-missed-delayed-flow.js";
const NOW = Date.parse("2026-07-10T00:10:00Z") / 1000;
const transaction = (over = {}) => ({
stateTechnicalName: "failed",
stateEnteredAt: "2026-07-10T00:00:00Z",
...over,
});
const flow = (over = {}) => ({
active: true,
eventName: "order_transaction.state.state_enter",
delaySeconds: 300,
...over,
});
test("missed when no executions and due", () => {
assert.equal(isDelayedFlowMissed(transaction(), flow(), [], NOW), true);
});
test("not missed when flow inactive", () => {
assert.equal(isDelayedFlowMissed(transaction(), flow({ active: false }), [], NOW), false);
});
test("not missed when event name does not match", () => {
assert.equal(isDelayedFlowMissed(transaction(), flow({ eventName: "order.state.state_enter" }), [], NOW), false);
});
test("not missed when transaction state does not match", () => {
assert.equal(isDelayedFlowMissed(transaction({ stateTechnicalName: "paid" }), flow(), [], NOW), false);
});
test("not missed when not due yet", () => {
const earlyNow = Date.parse("2026-07-10T00:01:00Z") / 1000;
assert.equal(isDelayedFlowMissed(transaction(), flow(), [], earlyNow), false);
});
test("not missed when a completed execution exists", () => {
const executions = [{ createdAt: "2026-07-10T00:00:05Z", finishedAt: "2026-07-10T00:00:10Z" }];
assert.equal(isDelayedFlowMissed(transaction(), flow(), executions, NOW), false);
});
test("missed when execution is stuck unfinished", () => {
const executions = [{ createdAt: "2026-07-10T00:00:05Z", finishedAt: null }];
assert.equal(isDelayedFlowMissed(transaction(), flow(), executions, NOW), true);
});
test("not missed when completed execution predates state enter", () => {
const executions = [{ createdAt: "2026-07-09T23:00:00Z", finishedAt: "2026-07-09T23:00:05Z" }];
assert.equal(isDelayedFlowMissed(transaction(), flow(), executions, NOW), true);
});
test("exactly at due_at is checked", () => {
const exactlyDue = Date.parse("2026-07-10T00:05:00Z") / 1000;
assert.equal(isDelayedFlowMissed(transaction(), flow(), [], exactlyDue), true);
});
Case studies
The follow up email that never went out
A store used a delayed flow to wait fifteen minutes after a payment failed before emailing the customer a link to retry, giving the payment provider's own retry logic a chance to succeed first without spamming a customer whose card just had a temporary hiccup. Support started noticing customers who had paid successfully on a second attempt, but never got the original heads up email either, which was fine for them, and a separate group of customers whose payment genuinely stayed failed and who also never heard anything.
Running the detection script in dry run turned up the second group immediately: transactions still sitting in failed for over an hour, no completed flow_execution row, and a flow that was confirmed active and correctly targeting order_transaction.state.state_enter. Support could then manually resend the retry email to that exact list instead of guessing at a wider batch.
The batch of failures from a payment provider outage
During a short outage at a payment provider, a burst of transactions failed within the same few minutes, and the delayed flow that was supposed to notify the merchant's operations inbox seemed to fire for some orders and not others with no obvious pattern. The team's first guess was a bug in the mail template.
The script's report showed the affected orders all had a real flow_execution row that stayed unfinished, consistent with the queue processing the resume events during the same burst that was also slowing down state writes. None of the underlying transactions had actually changed state, so it was a stuck queued execution rather than a stale-condition miss, and the team manually re-ran the flow for that batch from the Administration instead of touching any order data.
After this runs on a schedule, a missed delayed flow action stops being invisible. Every failed transaction past its delay window with no completed execution shows up in a clear list with the order number, the transaction id, the flow id, and when it failed, ready for a human to manually resend the notification or re-run the flow. Nothing here ever rewrites the transaction's state or replays the transition, so the fix can never make the order's history say something that did not happen.
FAQ
Why does my delayed Flow Builder action never run when payment fails?
Shopware's Flow Builder delay is implemented with a StorableFlow: only scalar ids are stored while the flow waits, and at resume time the flow is rebuilt from scratch and every condition is re-checked against the current database state, not the state that fired the trigger. If the transaction moved on in the meantime, for example a retried payment succeeded or the order was cancelled, the failed condition no longer matches and the delayed action is silently skipped.
How do I find orders where the delayed action was missed?
List order-transaction records in the failed state, then for each one search flow_execution filtered by orderId. A healthy delayed action shows a row that finished before its scheduled time. A transaction with no matching finished execution well past the delay window is a miss worth flagging for a human to review.
Can I just re-trigger the flow automatically once I find a miss?
Do not force it by rewriting state. The transaction's failed status is correct and there is no supported Admin API endpoint to re-queue a specific flow_execution record. The safe path is to flag the affected orders for a human, or, if automation is required, call the flow's own action directly, such as sending the mail template, behind an explicit DRY_RUN=false confirmation, and never PATCH stateId or replay the state transition.
Related field notes
Citations
On the problem:
- Delayed flow does not trigger when payment status fails (shopware/shopware #11971). github.com/shopware/shopware/issues/11971
- Flow Builder, "Exit status" as a trigger and a "delay" not working (shopware/shopware #4510). github.com/shopware/shopware/issues/4510
- Shopware 6 Features: Flow Builder delayed actions. docs.shopware.com/en/shopware-6-en/features/delayed-flow-actions
On the solution:
- ADR: Adding the StorableFlow instead of the FlowEvent for implementing the flow DelayAction in Flow Builder. developer.shopware.com StorableFlow ADR
- Shopware Developer Documentation: Flow concept. developer.shopware.com/docs/concepts/framework/flow-concept.html
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
Stuck on a tricky one?
If you have a problem in Shopware orders, payments, Flow Builder, or the message queue 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 explain a flow that went quiet?
If this saved you from chasing a phantom bug in your mail templates, 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