Reconciler Message Queue and Scheduled Tasks

Install queue message for an app or service silently lost

You install an app, or a data-intelligence service, and the admin says it worked. A few minutes later it is still greyed out, still inactive, and there is no error anywhere in the log. Shopware installs extensions by dispatching an asynchronous message rather than installing them on the spot, and in rare cases that message is dispatched and then simply never handled. Here is why that happens and a small script that finds the stuck rows and repairs them the same way Shopware's own workaround does.

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

Shopware installs an app or service through an async command on Symfony Messenger, for example an InstallServicesMessage or the app lifecycle install path, instead of installing it inline. Shopware's own issue tracker documents that this message can be dispatched but never handled, leaving the row created with active set to false and no error surfaced anywhere. Run a small Python or Node.js script that lists apps with active:false older than a normal install window, and for each one, toggle it off then on once through the Admin API to force it back into the install and activate lifecycle. If a second pass still shows it inactive, stop retoggling and report it to a human instead.

The problem in plain words

When you install an app or a "service" (a data-intelligence style integration) in Shopware 6, the admin does not install it right there in your request. It hands the work off to the message queue: a command like InstallServicesMessage gets dispatched through Symfony Messenger, and a handler somewhere is supposed to pick it up, run the install steps, and flip the extension to active.

Most of the time that is invisible and fast. But Shopware's own GitHub issue tracker, issue #11052, documents a case reproduced on 6.7.1.0 where the message is dispatched and then the handler never runs. Nothing crashes. Nothing logs an error. The app or service row just sits there, created, but with active still false, forever, unless someone notices and does something about it.

Merchant clicks Install app InstallServicesMessage dispatched to Messenger Handler never runs App row created active: false No error shown merchant sees nothing
The install request returns fine because it only queued a message. If the handler never runs, the app is left created but inactive, with nothing telling the merchant it failed.

Why it happens

The install path is asynchronous by design, and the default transport setup makes the failure mode quiet instead of loud:

The only documented recovery path today is waiting for the next scheduled_task run, which can be up to 24 hours away, or manually deactivating and reactivating the integration so it re-enters the install and activate lifecycle and redispatches the message. See the citations at the end for the exact issue and docs.

The key insight

There is no supported Admin API endpoint that redispatches an install message by id. Installation is triggered internally, not through a documented public write route. So the safe repair is not to invent a new mechanism, it is to mirror Shopware's own workaround: toggle active off then on to force the extension back through install and activate. And because the maintainers themselves could not pin down the race, we only do that once per stuck row. If it is still inactive after that, we stop and hand it to a human instead of hammering it.

The fix, as a flow

We do not touch the live install button. We add a job that lists apps that are still inactive, classifies each one by age and by whether it has already been retried, retoggles the ones that look freshly stuck, and reports anything that survived a retry so a person can look at it.

Scheduled job runs on a timer List inactive apps POST /api/search/app Classify by age createdAt vs updatedAt Needs retoggle? yes already retried, escalate PATCH active false then true, once
The script only retoggles apps that look freshly stuck. Anything already retried and still inactive is reported, not retried again.

Build it step by step

1

Get an Admin API integration

Create an Integration under Settings, System, Integrations in your Shopware admin. Copy the access key id and secret access key. Keep them in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPWARE_URL="https://your-shop.example.com"
export SHOPWARE_CLIENT_ID="your access key id"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export STUCK_THRESHOLD_MINUTES="10"
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://your-shop.example.com"
export SHOPWARE_CLIENT_ID="your access key id"
export SHOPWARE_CLIENT_SECRET="your secret access key"
export STUCK_THRESHOLD_MINUTES="10"
export DRY_RUN="true"   // start safe, change to false to write
2

Authenticate against the Admin API

Exchange the client id and secret for a bearer token at POST /api/oauth/token with a client_credentials grant, then send that token as an Authorization: Bearer header on every following call.

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},
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]
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", Accept: "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;
}
3

List apps that are still inactive

Search the app entity for rows where active equals false, sorted newest first. This gives back every app or service that never finished activating, whether it is mid-install or truly stuck.

step3.py
def inactive_apps(token):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/app",
        json={
            "filter": [{"type": "equals", "field": "active", "value": False}],
            "associations": {},
            "sort": [{"field": "createdAt", "order": "DESC"}],
            "total-count-mode": 1,
            "page": 1,
            "limit": 100,
        },
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json().get("data", [])
step3.js
async function inactiveApps(token) {
  const res = await fetch(`${SHOPWARE_URL}/api/search/app`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      filter: [{ type: "equals", field: "active", value: false }],
      associations: {},
      sort: [{ field: "createdAt", order: "DESC" }],
      "total-count-mode": 1,
      page: 1,
      limit: 100,
    }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.data || [];
}
4

Decide, with one pure function

Keep the classification in its own function that takes a plain app record and an injected "now" timestamp and returns a state, never a boolean, because there are more than two outcomes here. A row younger than the threshold is still installing normally. A row past the threshold that has never been touched since creation is a good candidate for one retoggle. A row past the threshold that has already been touched once, meaning updatedAt moved past createdAt, and is still inactive gets escalated instead of retried again.

decide.py
from datetime import datetime, timezone

def _parse(iso):
    return datetime.fromisoformat(iso.replace("Z", "+00:00"))

def minutes_between(start_iso, end_iso):
    return (_parse(end_iso) - _parse(start_iso)).total_seconds() / 60

def classify_stuck_install(app, now_iso, stuck_threshold_minutes=10):
    if app.get("active"):
        return "ok"
    age_minutes = minutes_between(app["createdAt"], now_iso)
    if age_minutes < stuck_threshold_minutes:
        return "installing"
    if app.get("updatedAt") == app.get("createdAt"):
        return "stuck_needs_retoggle"
    return "stuck_escalate"
decide.js
export function minutesBetween(startIso, endIso) {
  return (Date.parse(endIso) - Date.parse(startIso)) / 60000;
}

export function classifyStuckInstall(app, nowIso, stuckThresholdMinutes = 10) {
  if (app.active) return "ok";
  const ageMinutes = minutesBetween(app.createdAt, nowIso);
  if (ageMinutes < stuckThresholdMinutes) return "installing";
  if (app.updatedAt === app.createdAt) return "stuck_needs_retoggle";
  return "stuck_escalate";
}
5

Retoggle the way Shopware's own workaround does

For a row that needs a retoggle, PATCH /api/app/{id} with active: false, then PATCH /api/app/{id} with active: true. That forces the extension back through the install and activate lifecycle and redispatches the install message. There is no documented endpoint that redispatches the message directly by id, so this is the safe path that matches how a merchant would fix it by hand.

apply.py
def set_active(token, app_id, active):
    r = requests.patch(
        f"{SHOPWARE_URL}/api/app/{app_id}",
        json={"active": active},
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()

def retoggle(token, app_id):
    set_active(token, app_id, False)
    set_active(token, app_id, True)
apply.js
async function setActive(token, appId, active) {
  const res = await fetch(`${SHOPWARE_URL}/api/app/${appId}`, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({ active }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
}

async function retoggle(token, appId) {
  await setActive(token, appId, false);
  await setActive(token, appId, true);
}
6

Wire it together with a dry run guard, and stop after one retry

The loop authenticates once, lists inactive apps, classifies each one, and only retoggles rows in stuck_needs_retoggle. Rows in stuck_escalate are logged and reported, never retoggled again, because the maintainers themselves could not reliably reproduce the race that causes the message to vanish. Leave DRY_RUN on for the first runs so you only see what it would do.

Run it safe

Always start with DRY_RUN=true. Retoggle a stuck row once. If it is still inactive after that, stop and hand it to a human rather than looping the PATCH calls, since the underlying handler race is not well understood even by Shopware's own maintainers.

The full code

Here is the complete script in one file for each language. It authenticates, lists inactive apps, classifies each with the pure function, retoggles the freshly stuck ones, and reports anything that needed a second look, all behind the dry run flag.

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

install_message_lost.py
"""Find Shopware 6 apps or services stuck inactive after a lost install message.

Shopware installs an app or service through an async message on Symfony Messenger.
In rare cases that message is dispatched but never handled, leaving the row created
with active=false and no error shown. This lists apps with active=false, classifies
each by age and by whether a retry already happened, retoggles active off then on
once for freshly stuck rows, and reports rows that are still inactive after a retry
instead of retoggling them again. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
from datetime import datetime, timezone

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

SHOPWARE_URL = os.environ.get("SHOPWARE_URL", "https://example.myshopware.test").rstrip("/")
CLIENT_ID = os.environ.get("SHOPWARE_CLIENT_ID", "dummy-client-id")
CLIENT_SECRET = os.environ.get("SHOPWARE_CLIENT_SECRET", "dummy-client-secret")
STUCK_THRESHOLD_MINUTES = float(os.environ.get("STUCK_THRESHOLD_MINUTES", "10"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def _parse(iso):
    return datetime.fromisoformat(iso.replace("Z", "+00:00"))


def minutes_between(start_iso, end_iso):
    return (_parse(end_iso) - _parse(start_iso)).total_seconds() / 60


def classify_stuck_install(app, now_iso, stuck_threshold_minutes=10):
    if app.get("active"):
        return "ok"
    age_minutes = minutes_between(app["createdAt"], now_iso)
    if age_minutes < stuck_threshold_minutes:
        return "installing"
    if app.get("updatedAt") == app.get("createdAt"):
        return "stuck_needs_retoggle"
    return "stuck_escalate"


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},
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["access_token"]


def inactive_apps(token):
    r = requests.post(
        f"{SHOPWARE_URL}/api/search/app",
        json={
            "filter": [{"type": "equals", "field": "active", "value": False}],
            "associations": {},
            "sort": [{"field": "createdAt", "order": "DESC"}],
            "total-count-mode": 1,
            "page": 1,
            "limit": 100,
        },
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json().get("data", [])


def set_active(token, app_id, active):
    r = requests.patch(
        f"{SHOPWARE_URL}/api/app/{app_id}",
        json={"active": active},
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()


def retoggle(token, app_id):
    set_active(token, app_id, False)
    set_active(token, app_id, True)


def run():
    now_iso = datetime.now(timezone.utc).isoformat()
    token = get_token()
    retoggled = 0
    escalated = 0
    for app in inactive_apps(token):
        state = classify_stuck_install(app, now_iso, STUCK_THRESHOLD_MINUTES)
        if state == "stuck_needs_retoggle":
            log.info("App %s stuck since %s. %s", app.get("name", app["id"]), app["createdAt"],
                      "would retoggle" if DRY_RUN else "retoggling")
            if not DRY_RUN:
                retoggle(token, app["id"])
            retoggled += 1
        elif state == "stuck_escalate":
            log.warning("App %s still inactive after a retry. Escalating to a human.", app.get("name", app["id"]))
            escalated += 1
    log.info("Done. %d app(s) %s, %d escalated.", retoggled, "to retoggle" if DRY_RUN else "retoggled", escalated)


if __name__ == "__main__":
    run()
install-message-lost.js
/**
 * Find Shopware 6 apps or services stuck inactive after a lost install message.
 *
 * Shopware installs an app or service through an async message on Symfony Messenger.
 * In rare cases that message is dispatched but never handled, leaving the row created
 * with active=false and no error shown. This lists apps with active=false, classifies
 * each by age and by whether a retry already happened, retoggles active off then on
 * once for freshly stuck rows, and reports rows still inactive after a retry instead
 * of retoggling them again. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/shopware/install-message-lost/
 */
import { pathToFileURL } from "node:url";

const SHOPWARE_URL = (process.env.SHOPWARE_URL || "https://example.myshopware.test").replace(/\/$/, "");
const CLIENT_ID = process.env.SHOPWARE_CLIENT_ID || "dummy-client-id";
const CLIENT_SECRET = process.env.SHOPWARE_CLIENT_SECRET || "dummy-client-secret";
const STUCK_THRESHOLD_MINUTES = Number(process.env.STUCK_THRESHOLD_MINUTES || 10);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function minutesBetween(startIso, endIso) {
  return (Date.parse(endIso) - Date.parse(startIso)) / 60000;
}

export function classifyStuckInstall(app, nowIso, stuckThresholdMinutes = 10) {
  if (app.active) return "ok";
  const ageMinutes = minutesBetween(app.createdAt, nowIso);
  if (ageMinutes < stuckThresholdMinutes) return "installing";
  if (app.updatedAt === app.createdAt) return "stuck_needs_retoggle";
  return "stuck_escalate";
}

async function getToken() {
  const res = await fetch(`${SHOPWARE_URL}/api/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "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 inactiveApps(token) {
  const res = await fetch(`${SHOPWARE_URL}/api/search/app`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      filter: [{ type: "equals", field: "active", value: false }],
      associations: {},
      sort: [{ field: "createdAt", order: "DESC" }],
      "total-count-mode": 1,
      page: 1,
      limit: 100,
    }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
  const body = await res.json();
  return body.data || [];
}

async function setActive(token, appId, active) {
  const res = await fetch(`${SHOPWARE_URL}/api/app/${appId}`, {
    method: "PATCH",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({ active }),
  });
  if (!res.ok) throw new Error(`Shopware ${res.status}`);
}

async function retoggle(token, appId) {
  await setActive(token, appId, false);
  await setActive(token, appId, true);
}

export async function run() {
  const nowIso = new Date().toISOString();
  const token = await getToken();
  let retoggled = 0;
  let escalated = 0;
  for (const app of await inactiveApps(token)) {
    const state = classifyStuckInstall(app, nowIso, STUCK_THRESHOLD_MINUTES);
    if (state === "stuck_needs_retoggle") {
      console.log(`App ${app.name || app.id} stuck since ${app.createdAt}. ${DRY_RUN ? "would retoggle" : "retoggling"}`);
      if (!DRY_RUN) await retoggle(token, app.id);
      retoggled++;
    } else if (state === "stuck_escalate") {
      console.warn(`App ${app.name || app.id} still inactive after a retry. Escalating to a human.`);
      escalated++;
    }
  }
  console.log(`Done. ${retoggled} app(s) ${DRY_RUN ? "to retoggle" : "retoggled"}, ${escalated} escalated.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classification rule is the part most worth testing, because it decides whether a row gets retoggled once, escalated, or left alone as still installing. Because we kept classify_stuck_install pure and gave it an injected "now" timestamp, the test needs no network and no Shopware store at all.

test_install_classify.py
from install_message_lost import classify_stuck_install

NOW = "2026-07-10T00:20:00+00:00"


def app(**over):
    base = {
        "active": False,
        "createdAt": "2026-07-10T00:00:00+00:00",
        "updatedAt": "2026-07-10T00:00:00+00:00",
    }
    base.update(over)
    return base


def test_ok_when_active():
    assert classify_stuck_install(app(active=True), NOW, 10) == "ok"


def test_installing_when_young():
    recent = app(createdAt="2026-07-10T00:15:00+00:00", updatedAt="2026-07-10T00:15:00+00:00")
    assert classify_stuck_install(recent, NOW, 10) == "installing"


def test_stuck_needs_retoggle_when_old_and_untouched():
    assert classify_stuck_install(app(), NOW, 10) == "stuck_needs_retoggle"


def test_stuck_escalate_when_already_retried():
    retried = app(updatedAt="2026-07-10T00:10:00+00:00")
    assert classify_stuck_install(retried, NOW, 10) == "stuck_escalate"


def test_exactly_at_threshold_is_stuck():
    exact = app(createdAt="2026-07-10T00:10:00+00:00", updatedAt="2026-07-10T00:10:00+00:00")
    assert classify_stuck_install(exact, NOW, 10) == "stuck_needs_retoggle"
install-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyStuckInstall } from "./install-message-lost.js";

const NOW = "2026-07-10T00:20:00Z";

const app = (over = {}) => ({
  active: false,
  createdAt: "2026-07-10T00:00:00Z",
  updatedAt: "2026-07-10T00:00:00Z",
  ...over,
});

test("ok when active", () => {
  assert.equal(classifyStuckInstall(app({ active: true }), NOW, 10), "ok");
});

test("installing when young", () => {
  const recent = app({ createdAt: "2026-07-10T00:15:00Z", updatedAt: "2026-07-10T00:15:00Z" });
  assert.equal(classifyStuckInstall(recent, NOW, 10), "installing");
});

test("stuck_needs_retoggle when old and untouched", () => {
  assert.equal(classifyStuckInstall(app(), NOW, 10), "stuck_needs_retoggle");
});

test("stuck_escalate when already retried", () => {
  const retried = app({ updatedAt: "2026-07-10T00:10:00Z" });
  assert.equal(classifyStuckInstall(retried, NOW, 10), "stuck_escalate");
});

test("exactly at threshold is stuck", () => {
  const exact = app({ createdAt: "2026-07-10T00:10:00Z", updatedAt: "2026-07-10T00:10:00Z" });
  assert.equal(classifyStuckInstall(exact, NOW, 10), "stuck_needs_retoggle");
});

Case studies

Data-intelligence service

The service that looked installed but was not

A merchant installed a data-intelligence service from the Extension store during a busy afternoon. The admin showed a success toast, but nobody had the panel open in another tab, and the install message sat unconsumed. Two days later someone noticed the service tile was still greyed out and filed a support ticket, sure the click had failed.

Running the script in dry run showed exactly one row past the threshold with updatedAt equal to createdAt, meaning a retoggle had never been tried. One retoggle later, the service activated on the next queue pass and the tile went green without anyone touching the admin.

Third-party app

The app that stayed stuck even after a manual retry

A developer had already tried toggling a third-party app off and on by hand once, following the community workaround, but it was still inactive the next morning. Retoggling it again blind felt like guessing.

The script's classification caught this: updatedAt was already newer than createdAt, so it reported the row as stuck_escalate instead of retoggling a second time. That pointed the team straight at opening a support case with the exact row age and history, instead of looping a fix that was not going to work.

What good looks like

After this runs on a schedule, a lost install message stops being a silent dead end. Freshly stuck apps and services get one safe retoggle automatically, and anything that survives that gets flagged for a human with the exact age and retry history attached, instead of getting hammered with repeated toggles or left inactive indefinitely.

FAQ

Why does a Shopware 6 app or service stay inactive after install?

Shopware installs an app or service by dispatching an asynchronous message through Symfony Messenger rather than installing it in the same request. In rare cases that message is dispatched but never handled, so the app row is created but left with active set to false and no error is shown to the merchant.

Is it safe to script a fix for a stuck Shopware install?

Yes, when the script only touches app rows that are still inactive well past a normal install window, and it retoggles active off then on at most once before it stops and reports the row to a human. Shopware's own maintainers could not reliably reproduce the underlying race, so repeated automatic retoggling is not safe.

How do I detect a lost install message with the Admin API?

Search the app entity for active equal to false and compare createdAt against the current time. Rows older than a normal install window, typically about ten minutes, with no further activity are the practical signal, since Shopware has no documented endpoint that reports a message as lost by id.

Related field notes

Citations

On the problem:

  1. Shopware GitHub: Queue message for installing services sometimes gets lost, issue #11052. github.com/shopware/shopware/issues/11052
  2. Shopware Documentation: Message Queue and Scheduled Tasks tutorial and FAQ. docs.shopware.com message-queue-and-scheduled-tasks
  3. Shopware GitHub: Messages get duplicated and added to the wrong queue, issue #7169. github.com/shopware/shopware/issues/7169

On the solution:

  1. Shopware Developer Documentation: Message Queue infrastructure. developer.shopware.com message-queue
  2. Shopware Developer Documentation: Add Message to Queue. developer.shopware.com add-message-to-queue
  3. Shopware Developer Documentation: Admin API concepts. developer.shopware.com admin-api

Stuck on a tricky one?

If you have a problem in Shopware apps, the message queue, order state, or stock 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 find your stuck install?

If this saved you a support ticket or a confusing afternoon staring at a greyed out app tile, 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