Diagnostic Order State Machine
Order state cannot be patched directly and must use transitions
A bulk import tool, a custom ERP sync job, or an ad-hoc script tries to fix an order by sending a plain PATCH with a new stateId, straight to /api/order/{id} or /api/order-transaction/{id}. Shopware refuses every time with a 400 error about scope crud. That is not a bug, it is the state machine doing its job. Here is why Shopware locks state changes behind transitions and a small script that finds orders whose state, transaction, and delivery have drifted out of sync, then repairs them the correct way.
Shopware 6 routes every order, order_transaction, and order_delivery state change through the StateMachineRegistry, so only edges defined in that entity's transition graph can happen. A generic PATCH with a stateId key is write protected for scope crud and is rejected with "Changing the state-machine-state of this entity is not allowed for scope crud. Either change the state-machine-state via a state-transition or use a different scope." The fix is to call the dedicated transition endpoint instead: POST /api/_action/order/{id}/state/{transition}, POST /api/_action/order_transaction/{id}/state/{transition}, or POST /api/_action/order_delivery/{id}/state/{transition}, each with an empty body. A small Python or Node.js script can search for orders whose order, transaction, and delivery states have drifted out of sync, work out which single transition reaches the state you want, and fire it, gated by DRY_RUN. Full code, tests, and sources are below.
The problem in plain words
Every order in Shopware 6 carries three separate states that all move independently: the order's own status, its payment state on order_transaction, and its shipment state on order_delivery. Each of those states lives on a state machine, a graph of named states connected by named transitions, and Shopware only ever lets a state move along one of those defined edges. You cannot jump an order transaction straight from open to refunded, because that edge does not exist in the payment state machine, it has to go through paid first.
A generic CRUD write does not know about any of that. If the standard API let you PATCH a stateId directly, you could set any state on any entity in one call, skipping the graph entirely and skipping everything Shopware normally does when a transition actually runs, like adjusting stock, generating documents, or sending a status mail. So Shopware puts a write-protection rule on the stateId field for the CRUD scope, and any tool that tries to shortcut the state machine this way gets a flat 400 error instead of a silent bad write.
Why it happens
The state machine exists so that order, payment, and shipment states can only ever move along paths Shopware has explicitly modeled, which keeps every side effect that depends on state in sync. A few common ways teams run into the block:
- A bulk import tool built for a different platform tries to set order status the way it always has, with a straight field update, and never learned Shopware uses transitions instead.
- A custom ERP sync job writes back payment or shipment status after reconciling an external system, using a generic upsert that includes
stateIdin the payload. - An ad-hoc "fix the order" script, written under time pressure to unstick one order, reaches for the obvious
PATCHinstead of looking up the right transition. - A developer copies a working example for a different field, like
orderNumberoramountTotal, and does not realizestateIdis treated differently by the write-protection rules.
This is a well documented rule, not an inconsistency in the API. Shopware's own developer docs describe the state machine and its transition actions as the only supported way to move an order, transaction, or delivery between states, and the community forum has multiple threads from developers who hit the exact same 400 error before finding the transition endpoints. See the citations at the end for the exact docs and forum threads.
The fix is never to patch stateId harder or find a way around the write protection. It is to ask the state machine to do the move for you: read the entity's current stateMachineState.technicalName, look up which transition reaches the state you want, and call that transition's action endpoint. Shopware then runs the same logic the Administration UI's status dropdown runs, including any side effects, and updates the state as a result rather than as a raw write.
The fix, as a flow
We never touch stateId. The script searches for orders whose order, transaction, and delivery states look inconsistent with each other, reads the current technicalName for the entity that needs fixing, and uses a pure lookup over each entity's transition graph to find the one transition that reaches the desired state. If a direct edge exists, it calls the matching _action transition endpoint. If no single edge reaches the target, it flags the order for manual review instead of guessing.
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 search request and for the transition action calls.
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_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 {}
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 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) : {};
}
Find orders whose states have drifted out of sync
Search order with the transactions and deliveries associations, reading stateMachineState.technicalName at all three levels. Orders where the transaction is already paid but the order itself is still open, or the delivery is shipped while the transaction is not paid, are the ones a rejected direct PATCH would have left stuck.
def search_orders(token, limit=50):
body = {
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "open"}],
"associations": {"transactions": {}, "deliveries": {}},
"sort": [{"field": "orderDateTime", "order": "ASC"}],
"limit": limit,
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()["data"]
async function searchOrders(token, limit = 50) {
const body = {
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "open" }],
associations: { transactions: {}, deliveries: {} },
sort: [{ field: "orderDateTime", order: "ASC" }],
limit,
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
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 order search`);
const data = await res.json();
return data.data;
}
Decide, with one pure function
Keep the decision separate from any network call. Given the entity type, its current technicalName, the desired technicalName, and a static map of that entity's state machine graph, look up whether a single transition reaches the target. If the current state already matches, there is nothing to do. If no direct edge exists, the caller has to chain transitions or flag the order for a human, rather than guess at a multi-step path.
def pick_transition(entity, current_technical_name, desired_technical_name, allowed_transitions_by_state):
if current_technical_name == desired_technical_name:
return {"action": "noop"}
edges = allowed_transitions_by_state.get(current_technical_name, {})
for transition_name, target_state in edges.items():
if target_state == desired_technical_name:
return {"action": "transition", "transition": transition_name}
return {"action": "unsupported"}
export function pickTransition(entity, currentTechnicalName, desiredTechnicalName, allowedTransitionsByState) {
if (currentTechnicalName === desiredTechnicalName) return { action: "noop" };
const edges = allowedTransitionsByState[currentTechnicalName] || {};
for (const [transitionName, targetState] of Object.entries(edges)) {
if (targetState === desiredTechnicalName) return { action: "transition", transition: transitionName };
}
return { action: "unsupported" };
}
Call the transition action, never a PATCH
When pickTransition returns a transition, fire the matching _action endpoint for that entity with an empty body. The route differs by entity: order, order_transaction, or order_delivery. Always verify afterward with a search or a direct read that stateMachineState.technicalName now matches what you expected.
def transition_route(entity, entity_id, transition):
return f"/api/_action/{entity}/{entity_id}/state/{transition}"
def run_transition(token, entity, entity_id, transition):
return api_post(transition_route(entity, entity_id, transition), token, body={})
function transitionRoute(entity, entityId, transition) {
return `/api/_action/${entity}/${entityId}/state/${transition}`;
}
async function runTransition(token, entity, entityId, transition) {
return apiPost(transitionRoute(entity, entityId, transition), token, {});
}
Wire it together with a dry run guard
The loop ties every piece together. For each candidate order, compare the order, transaction, and delivery technicalName values, decide with pickTransition, and only call the transition action when it is not a dry run. Anything unsupported gets logged for manual review instead of being forced through multiple guessed transitions.
Never patch stateId, and never chain transitions blindly to "get there eventually." Start with DRY_RUN=true, read the planned transitions, and only let the script write once you trust the list. An unsupported result means the state machine itself has no single path from here, which is worth a human looking at the order.
The full code
Here is the complete script in one file for each language. It authenticates, searches for orders whose order, transaction, and delivery states have drifted apart, decides the correct transition with a pure lookup over each entity's graph, and calls the matching _action endpoint, respecting DRY_RUN.
View this code on GitHub Full runnable folder with tests in the shopware-fixes repo.
"""Repair Shopware 6 orders whose order, transaction, and delivery states drifted
out of sync, without ever patching stateId directly.
Searches order with the transactions and deliveries associations, compares each
entity's stateMachineState.technicalName, and uses a pure lookup over each entity's
state machine graph to find the single transition that reaches the desired state.
Only the matching _action transition endpoint is ever called. Run on a schedule,
gated by DRY_RUN. 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("fix_order_state")
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"
# Static edges of each entity's state machine graph: currentState -> {transitionName: targetState}
ORDER_TRANSITIONS = {
"open": {"process": "in_progress", "cancel": "cancelled"},
"in_progress": {"complete": "completed", "cancel": "cancelled"},
"cancelled": {"reopen": "open"},
"completed": {},
}
ORDER_TRANSACTION_TRANSITIONS = {
"open": {"authorize": "authorized", "do_pay": "paid", "paid_partially": "paid_partially",
"paid": "paid", "cancel": "cancelled", "fail": "failed", "remind": "reminded"},
"authorized": {"paid": "paid", "cancel": "cancelled", "fail": "failed"},
"paid_partially": {"paid": "paid", "refund_partially": "refunded_partially", "cancel": "cancelled"},
"paid": {"refund_partially": "refunded_partially", "refund": "refunded", "cancel": "cancelled"},
"reminded": {"do_pay": "paid", "paid": "paid", "cancel": "cancelled"},
"refunded_partially": {"refund": "refunded"},
"failed": {"reopen": "open"},
"cancelled": {"reopen": "open"},
}
ORDER_DELIVERY_TRANSITIONS = {
"open": {"ship": "shipped", "ship_partially": "shipped_partially", "cancel": "cancelled", "retour": "returned"},
"shipped_partially": {"ship": "shipped", "retour_partially": "returned_partially", "cancel": "cancelled"},
"shipped": {"retour": "returned", "cancel": "cancelled"},
"returned_partially": {"retour": "returned"},
"cancelled": {"reopen": "open"},
}
GRAPHS_BY_ENTITY = {
"order": ORDER_TRANSITIONS,
"order_transaction": ORDER_TRANSACTION_TRANSITIONS,
"order_delivery": ORDER_DELIVERY_TRANSITIONS,
}
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_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 {}
def search_orders(token, limit=50):
body = {
"filter": [{"type": "equals", "field": "stateMachineState.technicalName", "value": "open"}],
"associations": {"transactions": {}, "deliveries": {}},
"sort": [{"field": "orderDateTime", "order": "ASC"}],
"limit": limit,
"total-count-mode": 1,
}
r = requests.post(
f"{SHOPWARE_URL}/api/search/order",
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 pick_transition(entity, current_technical_name, desired_technical_name, allowed_transitions_by_state):
if current_technical_name == desired_technical_name:
return {"action": "noop"}
edges = allowed_transitions_by_state.get(current_technical_name, {})
for transition_name, target_state in edges.items():
if target_state == desired_technical_name:
return {"action": "transition", "transition": transition_name}
return {"action": "unsupported"}
def transition_route(entity, entity_id, transition):
return f"/api/_action/{entity}/{entity_id}/state/{transition}"
def run_transition(token, entity, entity_id, transition):
return api_post(transition_route(entity, entity_id, transition), token, body={})
def transaction_is_paid_but_order_still_open(order):
transactions = ((order.get("transactions") or {}).get("data")) or order.get("transactions") or []
if isinstance(transactions, dict):
transactions = transactions.get("data", [])
for tx in transactions:
state = (tx.get("stateMachineState") or {}).get("technicalName")
if state == "paid":
return True
return False
def run():
token = get_token()
orders = search_orders(token)
fixed = 0
flagged = 0
for order in orders:
if not transaction_is_paid_but_order_still_open(order):
continue
current = (order.get("stateMachineState") or {}).get("technicalName", "open")
decision = pick_transition("order", current, "in_progress", ORDER_TRANSITIONS)
if decision["action"] == "noop":
continue
if decision["action"] == "unsupported":
log.warning("Order %s cannot reach in_progress from %s in one transition, flagging for review",
order.get("orderNumber"), current)
flagged += 1
continue
transition = decision["transition"]
log.info("Order %s: transaction paid but order still %s. %s '%s'",
order.get("orderNumber"), current,
"would run transition" if DRY_RUN else "running transition", transition)
if not DRY_RUN:
run_transition(token, "order", order["id"], transition)
fixed += 1
log.info("Done. %d order(s) %s, %d flagged for manual review.",
fixed, "to transition" if DRY_RUN else "transitioned", flagged)
if __name__ == "__main__":
run()
/**
* Repair Shopware 6 orders whose order, transaction, and delivery states drifted
* out of sync, without ever patching stateId directly.
*
* Searches order with the transactions and deliveries associations, compares each
* entity's stateMachineState.technicalName, and uses a pure lookup over each entity's
* state machine graph to find the single transition that reaches the desired state.
* Only the matching _action transition endpoint is ever called. Run on a schedule,
* gated by DRY_RUN. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/shopware/order-state-requires-transition-action/
*/
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";
// Static edges of each entity's state machine graph: currentState -> {transitionName: targetState}
export const ORDER_TRANSITIONS = {
open: { process: "in_progress", cancel: "cancelled" },
in_progress: { complete: "completed", cancel: "cancelled" },
cancelled: { reopen: "open" },
completed: {},
};
export const ORDER_TRANSACTION_TRANSITIONS = {
open: { authorize: "authorized", do_pay: "paid", paid_partially: "paid_partially",
paid: "paid", cancel: "cancelled", fail: "failed", remind: "reminded" },
authorized: { paid: "paid", cancel: "cancelled", fail: "failed" },
paid_partially: { paid: "paid", refund_partially: "refunded_partially", cancel: "cancelled" },
paid: { refund_partially: "refunded_partially", refund: "refunded", cancel: "cancelled" },
reminded: { do_pay: "paid", paid: "paid", cancel: "cancelled" },
refunded_partially: { refund: "refunded" },
failed: { reopen: "open" },
cancelled: { reopen: "open" },
};
export const ORDER_DELIVERY_TRANSITIONS = {
open: { ship: "shipped", ship_partially: "shipped_partially", cancel: "cancelled", retour: "returned" },
shipped_partially: { ship: "shipped", retour_partially: "returned_partially", cancel: "cancelled" },
shipped: { retour: "returned", cancel: "cancelled" },
returned_partially: { retour: "returned" },
cancelled: { reopen: "open" },
};
export function pickTransition(entity, currentTechnicalName, desiredTechnicalName, allowedTransitionsByState) {
if (currentTechnicalName === desiredTechnicalName) return { action: "noop" };
const edges = allowedTransitionsByState[currentTechnicalName] || {};
for (const [transitionName, targetState] of Object.entries(edges)) {
if (targetState === desiredTechnicalName) return { action: "transition", transition: transitionName };
}
return { action: "unsupported" };
}
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 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) : {};
}
async function searchOrders(token, limit = 50) {
const body = {
filter: [{ type: "equals", field: "stateMachineState.technicalName", value: "open" }],
associations: { transactions: {}, deliveries: {} },
sort: [{ field: "orderDateTime", order: "ASC" }],
limit,
"total-count-mode": 1,
};
const res = await fetch(`${SHOPWARE_URL}/api/search/order`, {
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 order search`);
const data = await res.json();
return data.data;
}
function transitionRoute(entity, entityId, transition) {
return `/api/_action/${entity}/${entityId}/state/${transition}`;
}
async function runTransition(token, entity, entityId, transition) {
return apiPost(transitionRoute(entity, entityId, transition), token, {});
}
function transactionIsPaidButOrderStillOpen(order) {
const raw = order.transactions;
const transactions = Array.isArray(raw) ? raw : raw?.data || [];
return transactions.some((tx) => tx?.stateMachineState?.technicalName === "paid");
}
export async function run() {
const token = await getToken();
const orders = await searchOrders(token);
let fixed = 0;
let flagged = 0;
for (const order of orders) {
if (!transactionIsPaidButOrderStillOpen(order)) continue;
const current = order.stateMachineState?.technicalName || "open";
const decision = pickTransition("order", current, "in_progress", ORDER_TRANSITIONS);
if (decision.action === "noop") continue;
if (decision.action === "unsupported") {
console.warn(`Order ${order.orderNumber} cannot reach in_progress from ${current} in one transition, flagging for review`);
flagged++;
continue;
}
const transition = decision.transition;
console.log(`Order ${order.orderNumber}: transaction paid but order still ${current}. ${DRY_RUN ? "would run transition" : "running transition"} '${transition}'`);
if (!DRY_RUN) await runTransition(token, "order", order.id, transition);
fixed++;
}
console.log(`Done. ${fixed} order(s) ${DRY_RUN ? "to transition" : "transitioned"}, ${flagged} flagged for manual review.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which transition, if any, gets called against a live order. Because pickTransition is pure and takes the state machine graph as a plain argument, the test needs no network and no Shopware store. It just feeds in fixture graphs and checks the answer.
from fix_order_state import pick_transition, ORDER_TRANSACTION_TRANSITIONS, ORDER_TRANSITIONS
def test_noop_when_already_at_desired_state():
result = pick_transition("order_transaction", "paid", "paid", ORDER_TRANSACTION_TRANSITIONS)
assert result == {"action": "noop"}
def test_finds_direct_transition_edge():
result = pick_transition("order_transaction", "open", "paid", ORDER_TRANSACTION_TRANSITIONS)
assert result["action"] == "transition"
assert result["transition"] in ("do_pay", "paid")
def test_unsupported_when_no_direct_edge():
result = pick_transition("order_transaction", "refunded_partially", "cancelled", ORDER_TRANSACTION_TRANSITIONS)
assert result == {"action": "unsupported"}
def test_order_open_to_in_progress_uses_process():
result = pick_transition("order", "open", "in_progress", ORDER_TRANSITIONS)
assert result == {"action": "transition", "transition": "process"}
def test_completed_order_has_no_outgoing_edges():
result = pick_transition("order", "completed", "cancelled", ORDER_TRANSITIONS)
assert result == {"action": "unsupported"}
def test_unknown_current_state_is_unsupported():
result = pick_transition("order", "made_up_state", "cancelled", ORDER_TRANSITIONS)
assert result == {"action": "unsupported"}
import { test } from "node:test";
import assert from "node:assert/strict";
import { pickTransition, ORDER_TRANSACTION_TRANSITIONS, ORDER_TRANSITIONS } from "./fix-order-state.js";
test("noop when already at desired state", () => {
const result = pickTransition("order_transaction", "paid", "paid", ORDER_TRANSACTION_TRANSITIONS);
assert.deepEqual(result, { action: "noop" });
});
test("finds direct transition edge", () => {
const result = pickTransition("order_transaction", "open", "paid", ORDER_TRANSACTION_TRANSITIONS);
assert.equal(result.action, "transition");
assert.ok(["do_pay", "paid"].includes(result.transition));
});
test("unsupported when no direct edge", () => {
const result = pickTransition("order_transaction", "refunded_partially", "cancelled", ORDER_TRANSACTION_TRANSITIONS);
assert.deepEqual(result, { action: "unsupported" });
});
test("order open to in_progress uses process", () => {
const result = pickTransition("order", "open", "in_progress", ORDER_TRANSITIONS);
assert.deepEqual(result, { action: "transition", transition: "process" });
});
test("completed order has no outgoing edges", () => {
const result = pickTransition("order", "completed", "cancelled", ORDER_TRANSITIONS);
assert.deepEqual(result, { action: "unsupported" });
});
test("unknown current state is unsupported", () => {
const result = pickTransition("order", "made_up_state", "cancelled", ORDER_TRANSITIONS);
assert.deepEqual(result, { action: "unsupported" });
});
Case studies
The nightly sync that kept failing on stateId
A furniture retailer ran a nightly job that pulled fulfillment confirmations from its ERP and pushed them back to Shopware with a generic entity upsert, including a stateId field copied straight from a lookup table. Every run logged a wall of 400 errors, and the team assumed the integration was flaky rather than fundamentally wrong.
Rewriting the write step to look up the current technicalName, run it through the same graph lookup this article uses, and call the matching _action transition endpoint fixed every failure in one deploy. The sync now moves orders through process, ship, and complete exactly the way the Administration UI would.
Orders paid at the gateway but stuck open in Shopware
A subscription store noticed a growing pile of orders where the payment provider's dashboard showed the charge as captured, but the Shopware order sat on open and the transaction on open too, because a webhook handler that used to patch stateId directly had started failing silently after a platform upgrade.
Searching orders by joining transactions and comparing stateMachineState.technicalName found the exact set. Running the transition-based repair script in dry run first showed the plan clearly: do_pay on the transaction, then process on the order. Turning off dry run cleared the backlog without touching a single stateId.
After this runs, no tool in the stack ever writes stateId directly again. Every state change goes through the same _action transition endpoints the Administration UI uses, so stock, documents, and mail stay in sync exactly as Shopware intends. Orders that genuinely need more than one hop, or that hit a state the graph does not model, get flagged for a human instead of silently mishandled.
FAQ
Why does PATCH /api/order/{id} reject a stateId change in Shopware 6?
Shopware 6 enforces every order, order_transaction, and order_delivery state change through the StateMachineRegistry so only transitions defined in that entity's state machine graph can happen. A generic PATCH to stateId is write protected for scope crud and returns a 400 error telling you to change the state through a state transition or use a different scope, because a direct write would skip the side effects a transition triggers, like stock, documents, and mails.
How do I change an order's state in Shopware 6 through the API?
Call the dedicated transition action endpoint instead of a generic PATCH. Use POST /api/_action/order/{orderId}/state/{transition} for the order itself, POST /api/_action/order_transaction/{orderTransactionId}/state/{transition} for the payment state, and POST /api/_action/order_delivery/{orderDeliveryId}/state/{transition} for the shipment state, each with an empty JSON body and a bearer token. The transition name must be a valid edge from the entity's current technicalName in its state machine, for example paid or cancel for order_transaction.
What happens to orders left stuck by a failed direct PATCH attempt?
Nothing changes on the order, since the rejected PATCH never writes anything, but the underlying business problem it was trying to fix stays unresolved. That usually shows up as the order, transaction, and delivery states drifting out of sync with each other, for example a transaction already paid while the order itself is still open. Searching for orders where those three technicalName values do not agree finds the stuck records so you can repair them with the correct transition instead.
Related field notes
Citations
On the problem:
- Changing the state-machine-state of this entity is not allowed for scope crud (Shopware 6). sandyinfocom.wordpress.com/2024/04/06/shopware-6-changing-the-state-machine-state-of-this-entity-is-not-allowed-for-scope-crud
- Shopware Community Forum: Bestellstatus (order.state) per API ändern. forum.shopware.com/t/bestellstatus-order-state-per-api-andern/70559
- Shopware Community Forum: API to update payment status for order in SW 6? forum.shopware.com/t/api-to-update-payment-status-for-order-in-sw-6/95426
On the solution:
- Shopware Developer Documentation: Using the State Machine. developer.shopware.com/docs/guides/plugins/plugins/checkout/order/using-the-state-machine.html
- Shopware Admin API Reference (Stoplight): Order Management. shopware.stoplight.io/docs/admin-api/fdd24cc76f22d-order-management
- Shopware Developer Documentation: Orders (Checkout Concept). developer.shopware.com/docs/concepts/commerce/checkout-concept/orders.html
Stuck on a tricky one?
If you have a problem in Shopware orders, the state machine, stock, 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 clear a stuck order state?
If this saved you a wall of 400 errors or an order that would not budge, 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