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.

Python and Node.js Admin API Safe by default (dry run)
The short answer

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.

Payment fails state_enter: failed Condition matches at trigger time, delay queued flow_execution stored only scalar ids, no snapshot Rebuilt from DB state reloaded fresh If the state changed since, "failed" no longer matches. Action silently skipped, nothing errors.
The delay window is where the flow loses its grip on the original moment. It resumes against whatever the database says now, not what it said when the trigger fired.

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:

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.

The key insight

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.

List failed transactions stateMachineState = failed Search flow_execution by orderId, check flow is active and matches event Completed execution? yes, healthy No action needed no, missed or stuck Flag {order, flow, action} Human resends the action
Detection only. The transaction's failed status is never rewritten, and any remediation goes through the flow's own action, gated by a human decision and DRY_RUN.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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)
step3.js
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);
}
4

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.

decide.py
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
decide.js
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;
}
5

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.

apply.py
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)
apply.js
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);
}
6

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.

Run it safe

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_missed_delayed_flow.py
"""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-missed-delayed-flow.js
/**
 * 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.

test_delayed_flow_decision.py
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
delayed-flow-decision.test.js
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

Retried payment

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.

Queue race

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.

What good looks like

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:

  1. Delayed flow does not trigger when payment status fails (shopware/shopware #11971). github.com/shopware/shopware/issues/11971
  2. Flow Builder, "Exit status" as a trigger and a "delay" not working (shopware/shopware #4510). github.com/shopware/shopware/issues/4510
  3. Shopware 6 Features: Flow Builder delayed actions. docs.shopware.com/en/shopware-6-en/features/delayed-flow-actions

On the solution:

  1. ADR: Adding the StorableFlow instead of the FlowEvent for implementing the flow DelayAction in Flow Builder. developer.shopware.com StorableFlow ADR
  2. Shopware Developer Documentation: Flow concept. developer.shopware.com/docs/concepts/framework/flow-concept.html
  3. 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.

Contact me on LinkedIn

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

Back to all Shopware field notes