Reconciler Order State Machine

Transition action names renamed on 6.7 migration

A store upgrades to Shopware 6.7 and a plugin, app, or webhook that used to call the pay, pay_partially, or do_pay transition on an order transaction suddenly gets a 400 error, as if the transition no longer exists. It does not exist under that name any more. A core migration renamed it, and on early 6.7 builds it sometimes renamed the same action name on a completely unrelated custom state machine too. Here is why that happens and a small script that finds every integration still calling the old name and reports a safe remap.

Python and Node.js Admin API Safe by default (report only)
The short answer

Shopware 6.7.0.0 ships Migration1742302302RenamePaidTransitionActions, which duplicates the order_transaction state machine's transition rows and renames actionName from pay to paid, pay_partially to paid_partially, and do_pay to process. The old rows stay in place until a later destructive migration removes them, but any code that still calls the old actionName string against POST /api/_action/order_transaction/{id}/state/{transition} can start failing once behavior shifts to the new rows. Worse, as shipped in 6.7.0.0 the migration's scoping by state_machine_id was buggy (GitHub issue #11157), so a custom state machine that happened to define its own pay, pay_partially, or do_pay action could get renamed too, breaking integrations that have nothing to do with order payments. This was fixed in pull requests #11238 and #11259, backported to 6.7.1.0. A small Python or Node.js script can search state-machine-transition for the old and new action names, flag every row that does not belong to order_transaction.state, and report the exact remap your integration's stored config needs, all before writing anything. Full code, tests, and sources are below.

The problem in plain words

Every order_transaction in Shopware 6 moves through a state machine, and every move happens by calling a named transition, such as pay, on POST /api/_action/order_transaction/{id}/state/{transition}. Plugins, apps, webhook payload templates, and flow-builder custom actions all tend to hardcode that transition name as a plain string, because it rarely changes.

It changed in Shopware 6.7.0.0. The migration Migration1742302302RenamePaidTransitionActions duplicates the order_transaction state machine's transition rows and gives the copies new action names: pay becomes paid, pay_partially becomes paid_partially, and do_pay becomes process. The old rows are kept for a while, so nothing breaks the instant you upgrade, but anything still calling the transition by its old literal string is now standing on a name Shopware intends to retire, and a later destructive migration removes the old rows for good.

What made this worse on 6.7.0.0 through 6.7.0.x is that the rename's scoping by state_machine_id did not reliably limit itself to the order_transaction state machine. GitHub issue #11157 documents stores where a completely unrelated custom state machine, one a plugin defined for its own workflow and that simply happened to reuse the action name pay, pay_partially, or do_pay, got its transitions renamed by the same migration. That custom integration's code kept calling the old name, found no matching transition row any more, and started throwing a 400 with no obvious connection to a payment-related change.

Plugin or webhook calls action "pay" hardcoded string 6.7.0.0 migration ran pay renamed to paid scoping bug, issue #11157 No row named "pay" on order_transaction or an unrelated custom machine 400 response transition not found
The migration keeps the old transition rows for a while on order_transaction itself, but the scoping bug on 6.7.0.0 through 6.7.0.x could rename the same action names on unrelated custom state machines outright, leaving nothing for the old literal string to match.

Why it happens

The rename itself is intentional and documented in the 6.7.0.0 release notes. What breaks integrations is a mix of timing and scope. A few common ways stores end up here:

This is a documented, tracked issue in Shopware's own repository, not a one-off misconfiguration. See the citations at the end for the migration source, the issue thread, and the fix.

The key insight

Do not rename the state_machine_transition rows back. That refights the platform's own migration and breaks again the moment Shopware runs its later destructive cleanup of the old rows. The safe fix lives on your side: find every place your own code or stored configuration calls the old actionName string, and point it at the new resolved name instead, after confirming that name actually exists on the order_transaction state machine.

The fix, as a flow

We never write anything by default. The script searches state-machine-transition for rows whose actionName is in the old or new set, reads back each row's owning state machine, and reports which ones belong to order_transaction versus an unrelated custom state machine. A pure function turns any actionName plus the store's Shopware version into the resolved name a caller should use, and flags a name that still does not land in the expected set. Only with an explicit opt-in does it patch a stored integration config to the resolved name, and even then it re-verifies the new transition exists first.

Search transitions old and new action names resolveTransitionActionName pure decision function version aware remap Check state machine order_transaction.state or unrelated custom Still on the old name? yes no, already correct Report the remap patch stored config only if opted in, after re-verify
The pure function only ever decides which action name a caller should use. Patching a stored config requires a separate explicit opt-in, and the script re-verifies the new transition exists before it writes.

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 only after an explicit opt-in
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 only after an explicit opt-in
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 to read the store version, search for transitions, and, only if the operator opts in, patch a stored integration config.

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_get(path, token):
    r = requests.get(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def api_post(path, token, body=None):
    r = requests.post(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body or {},
        timeout=30,
    )
    r.raise_for_status()
    return r.json() if r.text else {}
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 apiGet(path, token) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on ${path}`);
  return res.json();
}

async function apiPost(path, token, body = {}) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    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 ${path}`);
  const text = await res.text();
  return text ? JSON.parse(text) : {};
}
3

Confirm the version, then list every colliding transition row

Read GET /api/_info/version to confirm the store is on 6.7.0.0 or later. Then search state-machine-transition filtered with equalsAny on actionName against the full old and new set, with the fromStateMachineState, toStateMachineState, and stateMachine associations, so each row also tells you which state machine it belongs to. This single search returns both the order_transaction rows and any unrelated custom state machine rows that collided with the same action names.

step3.py
ACTION_NAME_MAP = {"pay": "paid", "pay_partially": "paid_partially", "do_pay": "process"}
COLLISION_NAMES = list(ACTION_NAME_MAP.keys()) + list(ACTION_NAME_MAP.values())

def get_store_version(token):
    return api_get("/api/_info/version", token)["version"]

def get_colliding_transitions(token):
    body = {
        "filter": [{"type": "equalsAny", "field": "actionName", "value": COLLISION_NAMES}],
        "associations": {"fromStateMachineState": {}, "toStateMachineState": {}, "stateMachine": {}},
        "sort": [{"field": "actionName", "order": "ASC"}],
        "total-count-mode": 1,
        "limit": 500,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine-transition",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body, timeout=30,
    )
    r.raise_for_status()
    rows = []
    for row in r.json()["data"]:
        rows.append({
            "id": row["id"],
            "actionName": row["actionName"],
            "stateMachineTechnicalName": row["stateMachine"]["technicalName"],
            "fromStateTechnicalName": row["fromStateMachineState"]["technicalName"],
            "toStateTechnicalName": row["toStateMachineState"]["technicalName"],
        })
    return rows
step3.js
const ACTION_NAME_MAP = { pay: "paid", pay_partially: "paid_partially", do_pay: "process" };
const COLLISION_NAMES = [...Object.keys(ACTION_NAME_MAP), ...Object.values(ACTION_NAME_MAP)];

async function getStoreVersion(token) {
  const info = await apiGet("/api/_info/version", token);
  return info.version;
}

async function getCollidingTransitions(token) {
  const body = {
    filter: [{ type: "equalsAny", field: "actionName", value: COLLISION_NAMES }],
    associations: { fromStateMachineState: {}, toStateMachineState: {}, stateMachine: {} },
    sort: [{ field: "actionName", order: "ASC" }],
    "total-count-mode": 1,
    limit: 500,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-transition`, {
    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 state-machine-transition search`);
  const data = await res.json();
  return data.data.map((row) => ({
    id: row.id,
    actionName: row.actionName,
    stateMachineTechnicalName: row.stateMachine.technicalName,
    fromStateTechnicalName: row.fromStateMachineState.technicalName,
    toStateTechnicalName: row.toStateMachineState.technicalName,
  }));
}
4

Decide, with one pure function

Keep the resolution logic separate from any network call. Given an old actionName and the store's version string, work out the actionName a caller should use today: unchanged before 6.7.0.0, mapped through the fixed rename table from 6.7.0.0 onward. Then check that the resolved name is actually one Shopware expects, and flag it when it is not, which is what catches the unrelated-custom-state-machine case from issue #11157.

decide.py
ACTION_NAME_MAP = {"pay": "paid", "pay_partially": "paid_partially", "do_pay": "process"}
DEFAULT_EXPECTED_ACTION_NAMES = {
    "paid", "paid_partially", "process", "reopen", "cancel", "refund", "refund_partially", "chargeback",
}


class UnexpectedActionNameError(ValueError):
    pass


def _version_tuple(version):
    parts = version.split(".")
    return tuple(int(p) for p in parts[:4])


def resolve_transition_action_name(action_name, store_version, expected_action_names=None):
    expected_action_names = expected_action_names or DEFAULT_EXPECTED_ACTION_NAMES

    if _version_tuple(store_version) < (6, 7, 0, 0):
        resolved = action_name
    else:
        resolved = ACTION_NAME_MAP.get(action_name, action_name)

    if resolved not in expected_action_names:
        raise UnexpectedActionNameError(
            f"resolved actionName '{resolved}' (from '{action_name}' on version {store_version}) "
            f"is not in the expected set"
        )
    return resolved
decide.js
export const ACTION_NAME_MAP = { pay: "paid", pay_partially: "paid_partially", do_pay: "process" };
export const DEFAULT_EXPECTED_ACTION_NAMES = new Set([
  "paid", "paid_partially", "process", "reopen", "cancel", "refund", "refund_partially", "chargeback",
]);

export class UnexpectedActionNameError extends Error {}

function versionParts(version) {
  return version.split(".").slice(0, 4).map(Number);
}

function versionLessThan(a, b) {
  for (let i = 0; i < 4; i++) {
    const av = a[i] || 0;
    const bv = b[i] || 0;
    if (av !== bv) return av < bv;
  }
  return false;
}

export function resolveTransitionActionName(actionName, storeVersion, expectedActionNames = DEFAULT_EXPECTED_ACTION_NAMES) {
  const resolved = versionLessThan(versionParts(storeVersion), [6, 7, 0, 0])
    ? actionName
    : ACTION_NAME_MAP[actionName] || actionName;

  if (!expectedActionNames.has(resolved)) {
    throw new UnexpectedActionNameError(
      `resolved actionName '${resolved}' (from '${actionName}' on version ${storeVersion}) is not in the expected set`
    );
  }
  return resolved;
}
5

Report by default, patch a stored config only with an explicit opt-in

Scan your own plugin, app, and flow-builder source for literal calls to /api/_action/order_transaction/{id}/state/pay, pay_partially, or do_pay, and cross-reference each call site against the search results from step 3. Log every call site with the actionName it would resolve to. Only when the operator sets an explicit opt-in and DRY_RUN is false does the script PATCH the integration's own stored config entity, such as a flow sequence config, to the resolved name, and only after re-confirming with another state-machine-transition search that a transition with that resolved actionName actually exists on order_transaction.state.

apply.py
def verify_transition_exists(token, action_name):
    body = {
        "filter": [
            {"type": "equals", "field": "actionName", "value": action_name},
            {"type": "equals", "field": "stateMachine.technicalName", "value": "order_transaction.state"},
        ],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine-transition",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body, timeout=30,
    )
    r.raise_for_status()
    return r.json()["meta"]["total"] > 0


def patch_integration_config(token, config_entity, config_id, field, resolved_name):
    if not verify_transition_exists(token, resolved_name):
        raise RuntimeError(f"refusing to patch: no transition named '{resolved_name}' on order_transaction.state")
    return api_patch(f"/api/{config_entity}/{config_id}", token, body={field: resolved_name})


def api_patch(path, token, body):
    r = requests.patch(
        f"{SHOPWARE_URL}{path}",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
        json=body, timeout=30,
    )
    r.raise_for_status()
    return {}
apply.js
async function verifyTransitionExists(token, actionName) {
  const body = {
    filter: [
      { type: "equals", field: "actionName", value: actionName },
      { type: "equals", field: "stateMachine.technicalName", value: "order_transaction.state" },
    ],
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-transition`, {
    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 state-machine-transition search`);
  const data = await res.json();
  return data.meta.total > 0;
}

async function apiPatch(path, token, body) {
  const res = await fetch(`${SHOPWARE_URL}${path}`, {
    method: "PATCH",
    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 ${path}`);
  return {};
}

async function patchIntegrationConfig(token, configEntity, configId, field, resolvedName) {
  const ok = await verifyTransitionExists(token, resolvedName);
  if (!ok) throw new Error(`refusing to patch: no transition named '${resolvedName}' on order_transaction.state`);
  return apiPatch(`/api/${configEntity}/${configId}`, token, { [field]: resolvedName });
}
6

Wire it together with a dry run guard

The loop ties every piece together. It always reads the version, searches for colliding transitions, and reports which call sites would resolve to a different actionName. It only patches a stored config when both DRY_RUN is false and a separate ALLOW_REMAP opt-in is set, and even then only after verify_transition_exists confirms the target row is real. Because resolve_transition_action_name only ever computes a name, running the script again after a remap simply reports that everything already matches.

Run it safe

Start with DRY_RUN=true and leave ALLOW_REMAP unset. Read the reported call sites, confirm which ones are truly your own plugin or app configuration and not a false positive from an unrelated custom state machine, and only then opt in to a write. Never rename state_machine_transition rows back and never patch order_transaction.stateId directly, since both fight the platform's own migration.

The full code

Here is the complete script in one file for each language. It authenticates, confirms the store version, lists every transition row that collides with the old or new action names, resolves the correct name for each call site with a pure function, and only patches a stored integration config when both DRY_RUN is false and ALLOW_REMAP is explicitly set.

View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.

check_renamed_transitions.py
"""Detect and safely remap order_transaction transition action names renamed
by the Shopware 6.7.0.0 migration (shopware/shopware issue #11157).

Migration1742302302RenamePaidTransitionActions renames pay -> paid,
pay_partially -> paid_partially, and do_pay -> process on the order_transaction
state machine. On 6.7.0.0 through 6.7.0.x the migration's scoping was buggy, so
an unrelated custom state machine that reused one of those action names could
get renamed too. This lists every transition row that collides with the old or
new action names, resolves the correct name per call site with a pure function,
and reports the remap. It only patches a stored integration config when both
DRY_RUN is false and the operator explicitly sets ALLOW_REMAP=true, and only
after re-verifying the resolved transition actually exists. Safe to run again
and again.
"""
import os
import logging
import requests

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

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"
ALLOW_REMAP = os.environ.get("ALLOW_REMAP", "false").lower() == "true"

ACTION_NAME_MAP = {"pay": "paid", "pay_partially": "paid_partially", "do_pay": "process"}
COLLISION_NAMES = list(ACTION_NAME_MAP.keys()) + list(ACTION_NAME_MAP.values())
DEFAULT_EXPECTED_ACTION_NAMES = {
    "paid", "paid_partially", "process", "reopen", "cancel", "refund", "refund_partially", "chargeback",
}


class UnexpectedActionNameError(ValueError):
    pass


def _headers(token):
    return {"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"}


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 get_store_version(token):
    r = requests.get(
        f"{SHOPWARE_URL}/api/_info/version",
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["version"]


def get_colliding_transitions(token):
    body = {
        "filter": [{"type": "equalsAny", "field": "actionName", "value": COLLISION_NAMES}],
        "associations": {"fromStateMachineState": {}, "toStateMachineState": {}, "stateMachine": {}},
        "sort": [{"field": "actionName", "order": "ASC"}],
        "total-count-mode": 1,
        "limit": 500,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine-transition", headers=_headers(token), json=body, timeout=30,
    )
    r.raise_for_status()
    rows = []
    for row in r.json()["data"]:
        rows.append({
            "id": row["id"],
            "actionName": row["actionName"],
            "stateMachineTechnicalName": row["stateMachine"]["technicalName"],
            "fromStateTechnicalName": row["fromStateMachineState"]["technicalName"],
            "toStateTechnicalName": row["toStateMachineState"]["technicalName"],
        })
    return rows


def _version_tuple(version):
    parts = version.split(".")
    return tuple(int(p) for p in parts[:4])


def resolve_transition_action_name(action_name, store_version, expected_action_names=None):
    expected_action_names = expected_action_names or DEFAULT_EXPECTED_ACTION_NAMES

    if _version_tuple(store_version) < (6, 7, 0, 0):
        resolved = action_name
    else:
        resolved = ACTION_NAME_MAP.get(action_name, action_name)

    if resolved not in expected_action_names:
        raise UnexpectedActionNameError(
            f"resolved actionName '{resolved}' (from '{action_name}' on version {store_version}) "
            f"is not in the expected set"
        )
    return resolved


def verify_transition_exists(token, action_name):
    body = {
        "filter": [
            {"type": "equals", "field": "actionName", "value": action_name},
            {"type": "equals", "field": "stateMachine.technicalName", "value": "order_transaction.state"},
        ],
        "total-count-mode": 1,
    }
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/state-machine-transition", headers=_headers(token), json=body, timeout=30,
    )
    r.raise_for_status()
    return r.json()["meta"]["total"] > 0


def patch_integration_config(token, config_entity, config_id, field, resolved_name):
    if not verify_transition_exists(token, resolved_name):
        raise RuntimeError(f"refusing to patch: no transition named '{resolved_name}' on order_transaction.state")
    r = requests.patch(
        f"{SHOPWARE_URL}/api/{config_entity}/{config_id}", headers=_headers(token),
        json={field: resolved_name}, timeout=30,
    )
    r.raise_for_status()


def find_call_sites(old_action_names=("pay", "pay_partially", "do_pay")):
    """Placeholder for scanning your own plugin/app/flow-builder source and stored
    config for literal calls to the old action names. Replace with a real scan of
    your codebase or app_config / flow sequence config entities. Each item should
    look like: {"config_entity": ..., "config_id": ..., "field": ..., "action_name": ...}.
    """
    return []


def run():
    token = get_token()
    store_version = get_store_version(token)
    log.info("Store version: %s", store_version)

    colliding = get_colliding_transitions(token)
    for row in colliding:
        if row["stateMachineTechnicalName"] != "order_transaction.state":
            log.warning(
                "Action name '%s' also exists on unrelated state machine '%s' (issue #11157 scoping bug)",
                row["actionName"], row["stateMachineTechnicalName"],
            )

    call_sites = find_call_sites()
    if not call_sites:
        log.info("No call sites configured to scan. Populate find_call_sites() with your own integration config.")
        return

    to_patch = []
    for site in call_sites:
        try:
            resolved = resolve_transition_action_name(site["action_name"], store_version)
        except UnexpectedActionNameError as exc:
            log.error("Call site %s: %s", site, exc)
            continue
        if resolved != site["action_name"]:
            log.warning("Call site %s should use '%s' instead of '%s'", site, resolved, site["action_name"])
            to_patch.append((site, resolved))
        else:
            log.info("Call site %s already uses the correct action name '%s'", site, resolved)

    if not to_patch:
        log.info("Nothing to remap.")
        return

    if not ALLOW_REMAP:
        log.info("%d call site(s) reported. Set ALLOW_REMAP=true and DRY_RUN=false to patch them.", len(to_patch))
        return

    if DRY_RUN:
        for site, resolved in to_patch:
            log.info("DRY_RUN: would patch %s to actionName '%s'", site, resolved)
        return

    for site, resolved in to_patch:
        patch_integration_config(token, site["config_entity"], site["config_id"], site["field"], resolved)
        log.info("Patched %s to actionName '%s'", site, resolved)

    log.info("Done. %d call site(s) remapped.", len(to_patch))


if __name__ == "__main__":
    run()
check-renamed-transitions.js
/**
 * Detect and safely remap order_transaction transition action names renamed
 * by the Shopware 6.7.0.0 migration (shopware/shopware issue #11157).
 *
 * Migration1742302302RenamePaidTransitionActions renames pay -> paid,
 * pay_partially -> paid_partially, and do_pay -> process on the order_transaction
 * state machine. On 6.7.0.0 through 6.7.0.x the migration's scoping was buggy, so
 * an unrelated custom state machine that reused one of those action names could
 * get renamed too. This lists every transition row that collides with the old or
 * new action names, resolves the correct name per call site with a pure function,
 * and reports the remap. It only patches a stored integration config when both
 * DRY_RUN is false and the operator explicitly sets ALLOW_REMAP=true, and only
 * after re-verifying the resolved transition actually exists. Safe to run again
 * and again.
 *
 * Guide: https://www.allanninal.dev/shopware/order-transition-actionname-renamed-6-7/
 */
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 ALLOW_REMAP = (process.env.ALLOW_REMAP || "false").toLowerCase() === "true";

export const ACTION_NAME_MAP = { pay: "paid", pay_partially: "paid_partially", do_pay: "process" };
export const COLLISION_NAMES = [...Object.keys(ACTION_NAME_MAP), ...Object.values(ACTION_NAME_MAP)];
export const DEFAULT_EXPECTED_ACTION_NAMES = new Set([
  "paid", "paid_partially", "process", "reopen", "cancel", "refund", "refund_partially", "chargeback",
]);

export class UnexpectedActionNameError extends Error {}

function versionParts(version) {
  return version.split(".").slice(0, 4).map(Number);
}

function versionLessThan(a, b) {
  for (let i = 0; i < 4; i++) {
    const av = a[i] || 0;
    const bv = b[i] || 0;
    if (av !== bv) return av < bv;
  }
  return false;
}

export function resolveTransitionActionName(actionName, storeVersion, expectedActionNames = DEFAULT_EXPECTED_ACTION_NAMES) {
  const resolved = versionLessThan(versionParts(storeVersion), [6, 7, 0, 0])
    ? actionName
    : ACTION_NAME_MAP[actionName] || actionName;

  if (!expectedActionNames.has(resolved)) {
    throw new UnexpectedActionNameError(
      `resolved actionName '${resolved}' (from '${actionName}' on version ${storeVersion}) is not in the expected set`
    );
  }
  return resolved;
}

function headers(token) {
  return { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" };
}

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 getStoreVersion(token) {
  const res = await fetch(`${SHOPWARE_URL}/api/_info/version`, {
    headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on version`);
  const body = await res.json();
  return body.version;
}

async function getCollidingTransitions(token) {
  const body = {
    filter: [{ type: "equalsAny", field: "actionName", value: COLLISION_NAMES }],
    associations: { fromStateMachineState: {}, toStateMachineState: {}, stateMachine: {} },
    sort: [{ field: "actionName", order: "ASC" }],
    "total-count-mode": 1,
    limit: 500,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-transition`, {
    method: "POST",
    headers: headers(token),
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-transition search`);
  const data = await res.json();
  return data.data.map((row) => ({
    id: row.id,
    actionName: row.actionName,
    stateMachineTechnicalName: row.stateMachine.technicalName,
    fromStateTechnicalName: row.fromStateMachineState.technicalName,
    toStateTechnicalName: row.toStateMachineState.technicalName,
  }));
}

async function verifyTransitionExists(token, actionName) {
  const body = {
    filter: [
      { type: "equals", field: "actionName", value: actionName },
      { type: "equals", field: "stateMachine.technicalName", value: "order_transaction.state" },
    ],
    "total-count-mode": 1,
  };
  const res = await fetch(`${SHOPWARE_URL}/api/search/state-machine-transition`, {
    method: "POST",
    headers: headers(token),
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on state-machine-transition search`);
  const data = await res.json();
  return data.meta.total > 0;
}

async function patchIntegrationConfig(token, configEntity, configId, field, resolvedName) {
  const ok = await verifyTransitionExists(token, resolvedName);
  if (!ok) throw new Error(`refusing to patch: no transition named '${resolvedName}' on order_transaction.state`);
  const res = await fetch(`${SHOPWARE_URL}/api/${configEntity}/${configId}`, {
    method: "PATCH",
    headers: headers(token),
    body: JSON.stringify({ [field]: resolvedName }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status} on patch`);
}

/**
 * Placeholder for scanning your own plugin/app/flow-builder source and stored
 * config for literal calls to the old action names. Replace with a real scan of
 * your codebase or app_config / flow sequence config entities. Each item should
 * look like: { configEntity, configId, field, actionName }.
 */
function findCallSites() {
  return [];
}

export async function run() {
  const token = await getToken();
  const storeVersion = await getStoreVersion(token);
  console.log(`Store version: ${storeVersion}`);

  const colliding = await getCollidingTransitions(token);
  for (const row of colliding) {
    if (row.stateMachineTechnicalName !== "order_transaction.state") {
      console.warn(
        `Action name '${row.actionName}' also exists on unrelated state machine '${row.stateMachineTechnicalName}' (issue #11157 scoping bug)`
      );
    }
  }

  const callSites = findCallSites();
  if (callSites.length === 0) {
    console.log("No call sites configured to scan. Populate findCallSites() with your own integration config.");
    return;
  }

  const toPatch = [];
  for (const site of callSites) {
    let resolved;
    try {
      resolved = resolveTransitionActionName(site.actionName, storeVersion);
    } catch (err) {
      console.error(`Call site ${JSON.stringify(site)}: ${err.message}`);
      continue;
    }
    if (resolved !== site.actionName) {
      console.warn(`Call site ${JSON.stringify(site)} should use '${resolved}' instead of '${site.actionName}'`);
      toPatch.push([site, resolved]);
    } else {
      console.log(`Call site ${JSON.stringify(site)} already uses the correct action name '${resolved}'`);
    }
  }

  if (toPatch.length === 0) {
    console.log("Nothing to remap.");
    return;
  }

  if (!ALLOW_REMAP) {
    console.log(`${toPatch.length} call site(s) reported. Set ALLOW_REMAP=true and DRY_RUN=false to patch them.`);
    return;
  }

  if (DRY_RUN) {
    for (const [site, resolved] of toPatch) {
      console.log(`DRY_RUN: would patch ${JSON.stringify(site)} to actionName '${resolved}'`);
    }
    return;
  }

  for (const [site, resolved] of toPatch) {
    await patchIntegrationConfig(token, site.configEntity, site.configId, site.field, resolved);
    console.log(`Patched ${JSON.stringify(site)} to actionName '${resolved}'`);
  }

  console.log(`Done. ${toPatch.length} call site(s) remapped.`);
}

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 action name your integration ends up calling. Because resolve_transition_action_name is pure and takes the old actionName and a version string as plain arguments, the test needs no network and no Shopware store. It just feeds in version strings and checks the answer, including the case where a resolved name falls outside the expected set.

test_transition_rename.py
import pytest
from check_renamed_transitions import resolve_transition_action_name, UnexpectedActionNameError


def test_old_names_pass_through_unchanged_pre_6_7():
    assert resolve_transition_action_name("pay", "6.6.10.3", {"pay", "pay_partially", "do_pay"}) == "pay"
    assert resolve_transition_action_name("pay_partially", "6.5.0.0", {"pay", "pay_partially", "do_pay"}) == "pay_partially"
    assert resolve_transition_action_name("do_pay", "6.6.9.0", {"pay", "pay_partially", "do_pay"}) == "do_pay"


def test_pay_maps_to_paid_on_6_7_0_0():
    assert resolve_transition_action_name("pay", "6.7.0.0") == "paid"


def test_pay_partially_maps_to_paid_partially_on_6_7():
    assert resolve_transition_action_name("pay_partially", "6.7.1.0") == "paid_partially"


def test_do_pay_maps_to_process_on_6_7():
    assert resolve_transition_action_name("do_pay", "6.7.2.1") == "process"


def test_names_outside_the_rename_map_pass_through_on_6_7():
    assert resolve_transition_action_name("cancel", "6.7.0.0") == "cancel"


def test_raises_when_resolved_name_is_not_in_expected_set():
    with pytest.raises(UnexpectedActionNameError):
        resolve_transition_action_name("some_custom_action", "6.7.0.0", {"paid", "paid_partially", "process"})


def test_does_not_raise_when_pre_6_7_name_is_in_expected_set():
    resolved = resolve_transition_action_name("pay", "6.6.0.0", {"pay", "paid"})
    assert resolved == "pay"
transition-rename.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveTransitionActionName, UnexpectedActionNameError } from "./check-renamed-transitions.js";

test("old names pass through unchanged pre 6.7", () => {
  const expected = new Set(["pay", "pay_partially", "do_pay"]);
  assert.equal(resolveTransitionActionName("pay", "6.6.10.3", expected), "pay");
  assert.equal(resolveTransitionActionName("pay_partially", "6.5.0.0", expected), "pay_partially");
  assert.equal(resolveTransitionActionName("do_pay", "6.6.9.0", expected), "do_pay");
});

test("pay maps to paid on 6.7.0.0", () => {
  assert.equal(resolveTransitionActionName("pay", "6.7.0.0"), "paid");
});

test("pay_partially maps to paid_partially on 6.7", () => {
  assert.equal(resolveTransitionActionName("pay_partially", "6.7.1.0"), "paid_partially");
});

test("do_pay maps to process on 6.7", () => {
  assert.equal(resolveTransitionActionName("do_pay", "6.7.2.1"), "process");
});

test("names outside the rename map pass through on 6.7", () => {
  assert.equal(resolveTransitionActionName("cancel", "6.7.0.0"), "cancel");
});

test("throws when resolved name is not in the expected set", () => {
  assert.throws(
    () => resolveTransitionActionName("some_custom_action", "6.7.0.0", new Set(["paid", "paid_partially", "process"])),
    UnexpectedActionNameError
  );
});

test("does not throw when pre 6.7 name is in the expected set", () => {
  const resolved = resolveTransitionActionName("pay", "6.6.0.0", new Set(["pay", "paid"]));
  assert.equal(resolved, "pay");
});

Case studies

Payment gateway app

The app that could no longer confirm a payment

A payment gateway app called POST /api/_action/order_transaction/{id}/state/pay from its own server after a webhook confirmed a charge, and had worked unchanged for years across several major versions. The week after a store upgraded to 6.7.0.0, every confirmed payment started failing that call with a 400, and orders sat unpaid even though the money had cleared.

Running the detection script against the store showed pay now resolved to paid on the order_transaction state machine. The app's config held the action name as a plain setting rather than a code constant, so the team updated the stored value once the script confirmed the new transition existed, and the webhook flow resumed working without a single code change.

Custom workflow, wrong bug

The loyalty plugin caught in an unrelated rename

A store ran a custom loyalty points state machine with its own pay action, used to mark a points redemption as settled, completely unrelated to order payments. After upgrading to a 6.7.0.x patch release, the loyalty plugin's calls to that action started failing too, and the team assumed their own plugin had a bug.

The detection script's report showed the loyalty state machine's pay action had been renamed to paid as well, a direct hit from the scoping bug in issue #11157. Recognizing it as a platform-side migration bug rather than their own defect, the team tracked the fix in pull requests #11238 and #11259 and planned their patch update around it instead of chasing a phantom bug in their own code.

What good looks like

After running this, you know exactly which of your own integrations still call an old transition action name, what each one should call instead, and whether any of your custom state machines were caught by the 6.7.0.0 scoping bug rather than the intended order_transaction rename. Nothing gets patched until a human reads the report and opts in, and every patch is re-verified against the live state machine first, so the fix respects the platform's own migration instead of fighting it.

FAQ

Why did my order transaction transition calls stop working after upgrading to Shopware 6.7?

Shopware 6.7.0.0 ships a migration, Migration1742302302RenamePaidTransitionActions, that renames the order_transaction state machine's actionName values from pay to paid, pay_partially to paid_partially, and do_pay to process. Any plugin, app, webhook, or flow-builder code that still calls the old action name against POST /api/_action/order_transaction/{id}/state/{transition} gets a 400 transition not found error, because that literal string no longer resolves to a transition row on the order_transaction state machine.

Can this migration rename transitions on my own custom state machine, not just order_transaction?

On Shopware 6.7.0.0 through 6.7.0.x, yes. GitHub issue 11157 reports that the migration's scoping by state_machine_id was insufficient, so any custom state machine that happened to define an action named pay, pay_partially, or do_pay also had those actions silently renamed, breaking integrations that had nothing to do with order payments. This was fixed in pull requests 11238 and 11259, backported to 6.7.1.0, which scope the rename strictly to the order_transaction.state machine.

Should I rename the transition rows back to their old names?

No. Renaming state_machine_transition rows back fights the platform's own migration and will break again on the next update. The correct repair is to update your own integration's stored configuration, such as a flow sequence or app config entry, so it calls the new resolved action name, and to verify that name actually exists on the state machine before flipping. Never patch order_transaction.stateId directly and never delete or reinsert state_machine_transition rows.

Related field notes

Citations

On the problem:

  1. SW 6.7 Migration renames all "pay"/"pay_partially"/"do_pay" state machine state transitions, GitHub issue #11157. github.com/shopware/shopware/issues/11157
  2. Migration1742302302RenamePaidTransitionActions.php source, shopware/shopware at trunk. github.com/shopware/shopware/blob/trunk/src/Core/Migration/V6_7/Migration1742302302RenamePaidTransitionActions.php
  3. Fix: scope RenamePaidTransitionActions migration, pull request #11238, backported in #11259 for 6.7.1.0. github.com/shopware/shopware/pull/11238

On the solution:

  1. Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
  2. Shopware Admin API Reference (Stoplight): StateMachineTransition. shopware.stoplight.io/docs/admin-api/d2d3b29ba06a8-state-machine-transition
  3. Release notes Shopware 6.7.0.0, Shopware Documentation. developer.shopware.com/release-notes/6.7/6.7.0.0.html

Stuck on a tricky one?

If you have a problem in Shopware orders, the state machine, migrations, or a broken integration 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 clear up a broken transition call?

If this saved you from chasing a phantom bug across a version upgrade, 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