Reconciler Events, workflows, and jobs

Events lost without the Redis event bus

An order comes in, the payment captures, and by every log you can find the checkout worked. But the customer never got a confirmation email. Inventory never synced. Fulfillment never heard about it. Nothing crashed and nothing logged an error, because in Medusa v2 the default Event Module lives entirely in one process's memory, and a process that never saw the event has no way to complain that it missed it. Here is why that happens and a script that finds the orders it happened to.

Python and Node.js Medusa Admin API Reconcile against the Notification module
The short answer

Medusa v2's default Event Module is @medusajs/event-bus-local, backed by Node's EventEmitter and scoped to a single process's memory. In any multi-process or multi-instance deployment, rolling deploys, autoscaled workers, or a separate API and worker container, an event emitted by one process is never seen by a subscriber in another, and anything emitted during a deploy or restart window is dropped outright because nothing persists it. The fix in production is @medusajs/event-bus-redis plus @medusajs/workflow-engine-redis, but even with Redis configured, subscriber registration races and missing BullMQ retention settings can still lose events. You cannot safely replay a raw event after the fact, so the practical move is to reconcile: list recent orders from the Admin API, cross-reference each against the Notification module's delivery log at /admin/notifications, and flag any order past a grace window with zero notification rows for the expected event_name, such as order.placed. Full code, tests, and a dry run guard are below.

The problem in plain words

Medusa fires events for almost everything that happens to an order: it was placed, a fulfillment was created, a shipment went out. Subscribers listen for those events and do the side effects nobody wants to write inline into the checkout flow, sending the confirmation email, syncing inventory to a third party, notifying a warehouse.

That whole chain depends on the event actually reaching the subscriber. The default Event Module, event-bus-local, is just Node's built-in EventEmitter wrapped in a Medusa interface. An EventEmitter only knows about listeners registered in its own process. It keeps nothing on disk and nothing in a shared store. The moment you run more than one process, whether that is a rolling deploy replacing an old instance with a new one, an autoscaled pool of workers, or a separate API container and worker container, an event emitted in process A is invisible to a subscriber that only exists in process B. Worse, if a restart happens between the emit and the handler running, the event is gone. Nothing failed loudly. Nothing retried. It simply never happened as far as the rest of the system can tell.

Process A: API emits order.placed event-bus-local in-memory EventEmitter, scoped to process A only never crosses processes Process B: worker subscriber never runs Confirmation email: never sent
The order really was placed. The event that should trigger the confirmation email lived and died inside process A, and process B never heard about it.

Why it happens

Every one of these is a real gap in how the default setup is wired, not a single bug with one cause:

This is a common source of confusion because nothing in the logs says "event dropped." The order looks completely normal in the Admin, the payment captured fine, and the only sign anything went wrong is a customer support ticket asking where their confirmation email went. See the citations at the end for the exact issues and docs.

The key insight

You cannot safely re-emit a raw event after the fact. Medusa has no durable log of "this event was supposed to fire and did not," so replaying order.placed blindly risks re-running every subscriber attached to it, not just the one whose side effect actually failed. The safe pattern is reconciliation: compare what should have happened against what the Notification module actually recorded, since /admin/notifications persists every delivery attempt regardless of which Event Module emitted the trigger. Then repair narrowly, by re-invoking the one specific action that produces the missing side effect, guarded by an idempotency check so nothing sends twice.

The fix, as a flow

We do not touch checkout and we do not replay raw events. We list recent orders, list the notifications Medusa actually recorded for each one, and flag any order past a grace window with no notification row for the expected event_name. Behind DRY_RUN, the repair step re-triggers only the specific confirmation action for each flagged order, skipping any order that already has a matching notification to avoid a duplicate email.

List recent orders created_at, status List notifications resource_id, event_name Past grace window? created_at < now minus 10 min Notification missing? yes no, leave alone Flag, then guarded re-trigger if approved
The script flags first. Repair only re-triggers the specific confirmation action, guarded by an idempotency check against the Notification module's own log.

Build it step by step

1

Get an admin session and the base URL

Point the script at your Medusa backend and an admin user with rights to read orders and notifications. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, only logs the flagged order_id values
setup (shell)
npm install @medusajs/js-sdk

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, only logs the flagged order_id values
2

Authenticate against the Admin API

Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.

step2.py
import os, requests

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]
step2.js
import Medusa from "@medusajs/js-sdk";

const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;

const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });

async function login() {
  await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
  return sdk;
}
3

List recent orders, then the notifications recorded for each

Ask for orders sorted newest first with the fields the decision needs: id, display_id, status, fulfillment_status, payment_status, created_at, and the customer. Page through with limit and offset. Then, for every order, list the notifications Medusa actually recorded against it. /admin/notifications is a real v2 admin resource backed by the Notification Module, and it persists every notification attempt regardless of which Event Module triggered it, so it tells the truth even when the event bus itself lost the signal.

step3.py
ORDER_FIELDS = "id,display_id,status,fulfillment_status,payment_status,created_at,*customer"

def list_recent_orders(token, limit=100):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset = [], 0
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/orders",
            params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset, "order": "-created_at"},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["orders"])
        offset += limit
        if offset >= body["count"]:
            return out


def list_notifications_for_order(token, order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/notifications",
        params={"resource_id": order_id, "resource_type": "order", "limit": 50},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["notifications"]
step3.js
const ORDER_FIELDS = "id,display_id,status,fulfillment_status,payment_status,created_at,*customer";

async function listRecentOrders(sdk, limit = 100) {
  const out = [];
  let offset = 0;
  while (true) {
    const body = await sdk.admin.order.list({
      fields: ORDER_FIELDS, limit, offset, order: "-created_at",
    });
    out.push(...body.orders);
    offset += limit;
    if (offset >= body.count) return out;
  }
}

async function listNotificationsForOrder(sdk, orderId) {
  const body = await sdk.client.fetch("/admin/notifications", {
    method: "GET",
    query: { resource_id: orderId, resource_type: "order", limit: 50 },
  });
  return body.notifications;
}
4

Decide, with one pure function

Keep the decision in a function with no network calls. Build a set of order ids that already have a notification with the expected event_name, then flag any order older than a grace window, for example ten minutes, to allow for async delivery, that is not in that set. The function takes both arrays plus the time bounds and returns the minimal tuples for a flag, nothing more.

decide.py
from datetime import datetime, timezone

def find_orders_missing_notification(orders, notifications, expected_event, grace_ms, now_ms):
    """Pure: no I/O. orders and notifications are plain dicts/lists already fetched."""
    notified_ids = {
        n["resource_id"]
        for n in notifications
        if n.get("resource_type") == "order" and n.get("event_name") == expected_event
    }

    flagged = []
    for order in orders:
        created_ms = datetime.fromisoformat(
            order["created_at"].replace("Z", "+00:00")
        ).timestamp() * 1000
        if (now_ms - created_ms) > grace_ms and order["id"] not in notified_ids:
            flagged.append({"order_id": order["id"], "expected_event": expected_event})
    return flagged
decide.js
export function findOrdersMissingNotification(orders, notifications, expectedEvent, graceMs, nowMs) {
  // Pure: no I/O. orders and notifications are plain arrays already fetched.
  const notifiedIds = new Set(
    notifications
      .filter((n) => n.resource_type === "order" && n.event_name === expectedEvent)
      .map((n) => n.resource_id)
  );

  const flagged = [];
  for (const order of orders) {
    const createdMs = Date.parse(order.created_at);
    if (nowMs - createdMs > graceMs && !notifiedIds.has(order.id)) {
      flagged.push({ order_id: order.id, expected_event: expectedEvent });
    }
  }
  return flagged;
}
5

Repair by re-triggering the specific action, guarded and idempotent

Do not fabricate historical notifications and do not replay the raw event. For each flagged order, skip it if a notification for that event_name and resource_id already exists by the time the repair runs, someone else may have fixed it, and only then call the narrow admin action that re-sends the confirmation. This example re-triggers a custom subscriber endpoint your app exposes for this purpose, which is the safest shape since it isolates the one side effect instead of re-emitting an event with unknown subscribers attached.

apply.py
def already_notified(token, order_id, expected_event):
    notifications = list_notifications_for_order(token, order_id)
    return any(n.get("event_name") == expected_event for n in notifications)


def retrigger_order_confirmation(token, order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/orders/{order_id}/resend-confirmation",
        json={},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function alreadyNotified(sdk, orderId, expectedEvent) {
  const notifications = await listNotificationsForOrder(sdk, orderId);
  return notifications.some((n) => n.event_name === expectedEvent);
}

async function retriggerOrderConfirmation(sdk, orderId) {
  return sdk.client.fetch(`/admin/orders/${orderId}/resend-confirmation`, {
    method: "POST",
    body: {},
  });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports the flagged orders, one record per order, with the fields an ops queue needs: order id, display id, expected event, missing since, and customer email. Read the output, get a human to approve the batch, then switch DRY_RUN off to let it re-trigger for real, still guarded by the idempotency check on every single order.

Run it safe

Never auto-fabricate a historical notification and never replay a raw event blindly. Always start with DRY_RUN=true, always require a human to approve the batch before writing, and always re-check for an existing notification immediately before re-triggering, so a slow run or a second operator can never send the same confirmation twice.

The full code

Here is the complete script in one file for each language. It authenticates, lists recent orders and their notifications, flags every order missing the expected event with a pure function, and either reports the batch or re-triggers the specific confirmation action depending on DRY_RUN, skipping any order idempotency already covers.

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

find_missing_notifications.py
"""Find Medusa v2 orders whose expected notification, such as order.placed,
never fired because the default in-memory event-bus-local dropped the event
across processes or a restart. Never replays raw events and never fabricates
historical notifications. DRY_RUN=true only reports the flagged orders. Safe
to run again and again, because repair is guarded by an idempotency check
against the Notification module's own log.
"""
import os
import logging
from datetime import datetime

import requests

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

BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EXPECTED_EVENT = os.environ.get("EXPECTED_EVENT", "order.placed")
GRACE_MINUTES = float(os.environ.get("GRACE_MINUTES", "10"))

ORDER_FIELDS = "id,display_id,status,fulfillment_status,payment_status,created_at,*customer"


def get_token():
    r = requests.post(
        f"{BASE_URL}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def list_recent_orders(token, limit=100):
    headers = {"Authorization": f"Bearer {token}"}
    out, offset = [], 0
    while True:
        r = requests.get(
            f"{BASE_URL}/admin/orders",
            params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset, "order": "-created_at"},
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        body = r.json()
        out.extend(body["orders"])
        offset += limit
        if offset >= body["count"]:
            return out


def list_notifications_for_order(token, order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/notifications",
        params={"resource_id": order_id, "resource_type": "order", "limit": 50},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["notifications"]


def find_orders_missing_notification(orders, notifications, expected_event, grace_ms, now_ms):
    """Pure: no I/O. orders and notifications are plain dicts/lists already fetched."""
    notified_ids = {
        n["resource_id"]
        for n in notifications
        if n.get("resource_type") == "order" and n.get("event_name") == expected_event
    }

    flagged = []
    for order in orders:
        created_ms = datetime.fromisoformat(
            order["created_at"].replace("Z", "+00:00")
        ).timestamp() * 1000
        if (now_ms - created_ms) > grace_ms and order["id"] not in notified_ids:
            flagged.append({"order_id": order["id"], "expected_event": expected_event})
    return flagged


def already_notified(token, order_id, expected_event):
    notifications = list_notifications_for_order(token, order_id)
    return any(n.get("event_name") == expected_event for n in notifications)


def retrigger_order_confirmation(token, order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/orders/{order_id}/resend-confirmation",
        json={},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run():
    token = get_token()
    orders = list_recent_orders(token)

    all_notifications = []
    for order in orders:
        all_notifications.extend(list_notifications_for_order(token, order["id"]))

    now_ms = datetime.now().timestamp() * 1000
    grace_ms = GRACE_MINUTES * 60 * 1000
    flagged = find_orders_missing_notification(orders, all_notifications, EXPECTED_EVENT, grace_ms, now_ms)

    if not flagged:
        log.info("No orders missing %s across %d order(s).", EXPECTED_EVENT, len(orders))
        return

    by_id = {o["id"]: o for o in orders}
    for item in flagged:
        order = by_id[item["order_id"]]
        customer = order.get("customer") or {}
        log.warning(
            "Order %s (display_id=%s) missing %s since %s. customer_email=%s",
            order["id"], order.get("display_id"), item["expected_event"],
            order["created_at"], customer.get("email"),
        )

    if not DRY_RUN:
        for item in flagged:
            order_id = item["order_id"]
            if already_notified(token, order_id, item["expected_event"]):
                log.info("Order %s already notified since the scan ran. Skipping.", order_id)
                continue
            log.info("Order %s: re-triggering %s.", order_id, item["expected_event"])
            retrigger_order_confirmation(token, order_id)

    log.info("Done. %d order(s) %s.", len(flagged), "to review" if DRY_RUN else "processed")


if __name__ == "__main__":
    run()
find-missing-notifications.js
/**
 * Find Medusa v2 orders whose expected notification, such as order.placed,
 * never fired because the default in-memory event-bus-local dropped the
 * event across processes or a restart. Never replays raw events and never
 * fabricates historical notifications. DRY_RUN=true only reports the
 * flagged orders. Safe to run again and again, because repair is guarded
 * by an idempotency check against the Notification module's own log.
 */
import { pathToFileURL } from "node:url";

const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EXPECTED_EVENT = process.env.EXPECTED_EVENT || "order.placed";
const GRACE_MINUTES = Number(process.env.GRACE_MINUTES || 10);

const ORDER_FIELDS = "id,display_id,status,fulfillment_status,payment_status,created_at,*customer";

export function findOrdersMissingNotification(orders, notifications, expectedEvent, graceMs, nowMs) {
  // Pure: no I/O. orders and notifications are plain arrays already fetched.
  const notifiedIds = new Set(
    notifications
      .filter((n) => n.resource_type === "order" && n.event_name === expectedEvent)
      .map((n) => n.resource_id)
  );

  const flagged = [];
  for (const order of orders) {
    const createdMs = Date.parse(order.created_at);
    if (nowMs - createdMs > graceMs && !notifiedIds.has(order.id)) {
      flagged.push({ order_id: order.id, expected_event: expectedEvent });
    }
  }
  return flagged;
}

async function login() {
  const { default: Medusa } = await import("@medusajs/js-sdk");
  const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
  await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
  return sdk;
}

async function listRecentOrders(sdk, limit = 100) {
  const out = [];
  let offset = 0;
  while (true) {
    const body = await sdk.admin.order.list({
      fields: ORDER_FIELDS, limit, offset, order: "-created_at",
    });
    out.push(...body.orders);
    offset += limit;
    if (offset >= body.count) return out;
  }
}

async function listNotificationsForOrder(sdk, orderId) {
  const body = await sdk.client.fetch("/admin/notifications", {
    method: "GET",
    query: { resource_id: orderId, resource_type: "order", limit: 50 },
  });
  return body.notifications;
}

async function alreadyNotified(sdk, orderId, expectedEvent) {
  const notifications = await listNotificationsForOrder(sdk, orderId);
  return notifications.some((n) => n.event_name === expectedEvent);
}

async function retriggerOrderConfirmation(sdk, orderId) {
  return sdk.client.fetch(`/admin/orders/${orderId}/resend-confirmation`, {
    method: "POST",
    body: {},
  });
}

export async function run() {
  const sdk = await login();
  const orders = await listRecentOrders(sdk);

  const allNotifications = [];
  for (const order of orders) {
    allNotifications.push(...(await listNotificationsForOrder(sdk, order.id)));
  }

  const nowMs = Date.now();
  const graceMs = GRACE_MINUTES * 60 * 1000;
  const flagged = findOrdersMissingNotification(orders, allNotifications, EXPECTED_EVENT, graceMs, nowMs);

  if (flagged.length === 0) {
    console.log(`No orders missing ${EXPECTED_EVENT} across ${orders.length} order(s).`);
    return;
  }

  const byId = new Map(orders.map((o) => [o.id, o]));
  for (const item of flagged) {
    const order = byId.get(item.order_id);
    console.warn(
      `Order ${order.id} (display_id=${order.display_id}) missing ${item.expected_event} since ${order.created_at}. customer_email=${order.customer?.email}`
    );
  }

  if (!DRY_RUN) {
    for (const item of flagged) {
      if (await alreadyNotified(sdk, item.order_id, item.expected_event)) {
        console.log(`Order ${item.order_id} already notified since the scan ran. Skipping.`);
        continue;
      }
      console.log(`Order ${item.order_id}: re-triggering ${item.expected_event}.`);
      await retriggerOrderConfirmation(sdk, item.order_id);
    }
  }

  console.log(`Done. ${flagged.length} order(s) ${DRY_RUN ? "to review" : "processed"}.`);
}

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

Add a test

The function worth testing is the one that decides the outcome, find_orders_missing_notification. It is pure, no network and no database, so the tests feed in plain order and notification arrays plus a fixed clock and check the answer.

test_events_missing_notifications.py
from find_missing_notifications import find_orders_missing_notification

NOW_MS = 1_752_105_600_000  # 2026-07-10T00:00:00Z in epoch ms
GRACE_MS = 10 * 60 * 1000   # 10 minutes


def order(**over):
    base = {"id": "order_1", "created_at": "2026-07-09T23:00:00Z", "fulfillment_status": "not_fulfilled"}
    base.update(over)
    return base


def notification(**over):
    base = {"resource_id": "order_1", "resource_type": "order", "event_name": "order.placed"}
    base.update(over)
    return base


def test_flags_order_past_grace_with_no_notification():
    result = find_orders_missing_notification([order()], [], "order.placed", GRACE_MS, NOW_MS)
    assert result == [{"order_id": "order_1", "expected_event": "order.placed"}]


def test_does_not_flag_when_notification_exists():
    result = find_orders_missing_notification([order()], [notification()], "order.placed", GRACE_MS, NOW_MS)
    assert result == []


def test_does_not_flag_within_grace_window():
    recent = order(created_at="2026-07-09T23:58:00Z")
    result = find_orders_missing_notification([recent], [], "order.placed", GRACE_MS, NOW_MS)
    assert result == []


def test_ignores_notification_for_a_different_event():
    result = find_orders_missing_notification(
        [order()], [notification(event_name="order.fulfillment_created")], "order.placed", GRACE_MS, NOW_MS
    )
    assert result == [{"order_id": "order_1", "expected_event": "order.placed"}]


def test_ignores_notification_for_a_different_resource_type():
    result = find_orders_missing_notification(
        [order()], [notification(resource_type="customer")], "order.placed", GRACE_MS, NOW_MS
    )
    assert result == [{"order_id": "order_1", "expected_event": "order.placed"}]


def test_handles_multiple_orders_independently():
    orders = [order(), order(id="order_2", created_at="2026-07-09T22:00:00Z")]
    notifications = [notification(resource_id="order_2")]
    result = find_orders_missing_notification(orders, notifications, "order.placed", GRACE_MS, NOW_MS)
    assert result == [{"order_id": "order_1", "expected_event": "order.placed"}]
missing-notifications.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrdersMissingNotification } from "./find-missing-notifications.js";

const NOW_MS = Date.parse("2026-07-10T00:00:00Z");
const GRACE_MS = 10 * 60 * 1000;

const order = (over = {}) => ({
  id: "order_1",
  created_at: "2026-07-09T23:00:00Z",
  fulfillment_status: "not_fulfilled",
  ...over,
});

const notification = (over = {}) => ({
  resource_id: "order_1",
  resource_type: "order",
  event_name: "order.placed",
  ...over,
});

test("flags order past grace with no notification", () => {
  const result = findOrdersMissingNotification([order()], [], "order.placed", GRACE_MS, NOW_MS);
  assert.deepEqual(result, [{ order_id: "order_1", expected_event: "order.placed" }]);
});

test("does not flag when notification exists", () => {
  const result = findOrdersMissingNotification([order()], [notification()], "order.placed", GRACE_MS, NOW_MS);
  assert.deepEqual(result, []);
});

test("does not flag within grace window", () => {
  const recent = order({ created_at: "2026-07-09T23:58:00Z" });
  const result = findOrdersMissingNotification([recent], [], "order.placed", GRACE_MS, NOW_MS);
  assert.deepEqual(result, []);
});

test("ignores notification for a different event", () => {
  const result = findOrdersMissingNotification(
    [order()], [notification({ event_name: "order.fulfillment_created" })], "order.placed", GRACE_MS, NOW_MS
  );
  assert.deepEqual(result, [{ order_id: "order_1", expected_event: "order.placed" }]);
});

test("ignores notification for a different resource type", () => {
  const result = findOrdersMissingNotification(
    [order()], [notification({ resource_type: "customer" })], "order.placed", GRACE_MS, NOW_MS
  );
  assert.deepEqual(result, [{ order_id: "order_1", expected_event: "order.placed" }]);
});

test("handles multiple orders independently", () => {
  const orders = [order(), order({ id: "order_2", created_at: "2026-07-09T22:00:00Z" })];
  const notifications = [notification({ resource_id: "order_2" })];
  const result = findOrdersMissingNotification(orders, notifications, "order.placed", GRACE_MS, NOW_MS);
  assert.deepEqual(result, [{ order_id: "order_1", expected_event: "order.placed" }]);
});

Case studies

Rolling deploy

The confirmation emails that vanished during a release

A store deployed a small config change during a moderately busy afternoon. The deploy replaced the API container while the old one was still draining a handful of in-flight checkouts. Every order placed in that narrow window emitted order.placed into a process that was already shutting down, and the new process never had a subscriber listening in time to catch it.

Nobody noticed until three customers asked support where their receipt was. Running the reconciler against orders from that afternoon found nine orders with zero order.placed notification rows, all clustered in the same two minute deploy window. The team approved a batch re-trigger, the idempotency check confirmed none of the nine had a notification recorded since, and all nine got their confirmation email for the first time.

Redis configured, subscriber race

The worker that started consuming before the app finished loading

A team had already moved to @medusajs/event-bus-redis, expecting the durability problem to be solved. During a scaling event, a new worker instance came up, its BullMQ processor started pulling queued jobs almost immediately, but the application's own subscriber registration, which happens during Medusa's bootstrap, had not finished attaching handlers yet.

A batch of fulfillment notification events processed with no handler attached and were marked complete by BullMQ regardless, exactly the shape described in GitHub issue #10822. The reconciler caught it the same way, by comparing order.fulfillment_created notifications against orders whose fulfillment_status had moved to fulfilled. The durable fix was adding a startup delay before the processor began consuming, plus removeOnComplete and removeOnFail settings so future gaps would be visible instead of silently purged.

What good looks like

Run this reconciler on a schedule, or right after a deploy window. It never replays a raw event and never fabricates a notification record, so it can never send a customer two receipts or mask a real gap. It only tells you which orders never got the side effect Medusa itself was supposed to trigger, cross-referenced against the Notification module's own durable log, and it only writes when a human has approved the batch and the idempotency check confirms nothing changed underneath it. The durable fix stays infrastructure-level: configure @medusajs/event-bus-redis and @medusajs/workflow-engine-redis in every deployed environment, and set BullMQ's removeOnComplete and removeOnFail so completed events are purged instead of piling up.

FAQ

Why do order confirmation emails sometimes never send in Medusa?

Medusa v2's default Event Module, event-bus-local, is backed by Node's EventEmitter and lives entirely inside one process's memory. In a multi-process deployment, an event emitted by one process is never seen by a subscriber running in another, and any event emitted during a deploy or restart window is dropped outright because nothing persists it. If the subscriber that sends the confirmation email never receives order.placed, the email never sends and nothing in the database records that it was supposed to.

Does switching to the Redis event bus fully fix lost events?

It fixes the core problem, since @medusajs/event-bus-redis persists events in Redis through BullMQ instead of process memory. But it is not automatically perfect. Subscriber registration races, where the BullMQ processor starts consuming before all subscribers finish loading, and missing removeOnComplete or removeOnFail settings, which let queues grow unbounded, can still cause events to run before a handler attaches or to pile up silently.

How do I find orders that already missed a notification, without replaying every event?

Cross-reference the Admin API's orders list against the Notification module's delivery log at /admin/notifications, which records every notification attempt regardless of which Event Module emitted it. An order older than a short grace window with zero notification rows for the expected event_name, such as order.placed, is the signature of a dropped event. This needs no event replay, only two read-only Admin API calls.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #4089: Events not triggered consistently using Redis EventBusModule. github.com/medusajs/medusa/issues/4089
  2. medusajs/medusa GitHub issue #10822: event-bus-redis processor executes event before subscribers are loaded. github.com/medusajs/medusa/issues/10822
  3. medusajs/medusa GitHub issue #7850: Events in Redis event bus not triggering subscribers (notification service). github.com/medusajs/medusa/issues/7850

On the solution:

  1. Medusa Documentation: Redis Event Module. docs.medusajs.com/resources/infrastructure-modules/event/redis
  2. Medusa Documentation: Workflow Engine Module. docs.medusajs.com/resources/infrastructure-modules/workflow-engine
  3. Medusa Documentation: Events and Subscribers. docs.medusajs.com/learn/fundamentals/events-and-subscribers

Stuck on a tricky one?

If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows 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 catch a silently dropped event?

If this saved you from a customer support ticket or a confusing missing email, 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 Medusa field notes