Skip to content

Diagnostic Events & Notifications

A leftover workaround subscriber duplicates order confirmation emails

A customer emails support asking why they got two order confirmations for the same purchase. Nothing in the order looks wrong, the total is right, the payment captured once. But somewhere in the events pipeline, order.placed is firing, or being acted on, twice, and the notification subscriber is happily sending the confirmation email each time it runs. The usual cause is not a bug in the notification code at all. It is an old workaround subscriber that nobody removed after the bug it was written for got fixed. Here is why that happens and a script that finds the orders it hit.

Python and Node.js Medusa Admin API Detect against the Notification module, then a guarded code fix
A file cabinet
Photo by Maksym Kaharlytskyi on Unsplash
The short answer

In Medusa v2, subscribers are plain files in src/subscribers that register for an event by exporting a config object. There is no built in idempotency key, no dedup, and no lifecycle tie between a subscriber and the bug it was written to patch, only a comment if someone left one. A team once added a workaround subscriber on order.placed that manually called capturePaymentWorkflow to cover for upstream bugs where Stripe webhooks fired but the order's payment status never advanced (Medusa issues #11766 and #13301). Medusa v2.11.1 fixed those bugs, but the workaround subscriber kept running unconditionally, nothing gated it on a version check or a feature flag. It duplicates work the core workflow already does, which causes order.placed to effectively fire twice for the order, and the notification subscriber sends the confirmation email a second time. The fix is detect first, repair second: cross-reference the Admin API's orders against /admin/notifications to find every order with more than one confirmation sent in a short window, report that as a dry run, then separately remove the leftover subscriber file behind a guarded, DRY_RUN-gated script. Full code, tests, and the guard are below.

The problem in plain words

A Medusa v2 subscriber is not registered anywhere in a database or an admin screen. It is a file in src/subscribers that exports a handler function and a config object naming the event it listens for, such as order.placed. Medusa picks it up from the filesystem when the server starts. That is convenient, but it also means a subscriber has no built in expiry, no version gate, and no awareness of why it was written in the first place, beyond whatever comment is sitting above it.

Some time ago, this team hit two real upstream bugs where a Stripe webhook fired and the payment succeeded, but the order's payment_status and paid_total never advanced to reflect it. Rather than wait, they added a subscriber on order.placed that manually called capturePaymentWorkflow to force the capture through. It worked. Then Medusa v2.11.1 shipped and fixed both of those upstream bugs at the source. Nobody removed the workaround. It kept firing on every single order.placed event, still calling capturePaymentWorkflow, duplicating work the core order and payment workflow already does correctly on its own now. That duplicate processing is what causes order.placed to effectively re-run for the order, and any other subscriber listening for that same event, including the one that sends the confirmation email, runs a second time along with it.

Order placed emits order.placed Leftover workaround still calls capturePaymentWorkflow no version gate order.placed re-processed Confirmation email sent twice
The workaround was correct once, when the upstream bug was real. Once Medusa fixed the upstream bug, the same subscriber became the thing causing duplicate work.

Why it happens

A few real facts about the Medusa v2 subscriber model line up to produce exactly this:

This is a common source of confusion because nothing about the order itself looks wrong. The payment captured once, the total is correct, the order status is correct. The only visible symptom lives entirely outside the order record, in a customer's inbox and in the Notification module's own delivery log.

The key insight

You cannot ask the order whether order.placed fired twice, because the order record does not track how many times an event handler ran against it. But the Notification module's /admin/notifications resource does, since its resource_id holds the order id and every send is written independently of whichever subscriber triggered it. Group notifications by resource_id and cluster the ones sent to the same recipient within a short window, and a cluster of more than one is the exact signature of order.placed firing more than once for that order. That detection needs no code change. The actual repair, deleting or gating the leftover subscriber file, is a separate, deliberate step that this field note keeps behind a dry run guard, because Notification records are an audit trail and should never be resent or deleted automatically.

The fix, as a flow

We do not touch the live checkout or the notification subscriber. We list recent orders, list the notifications Medusa actually recorded for each one, and cluster consecutive sends to the same recipient that land within a short window of each other. Any cluster larger than one is a duplicate-send incident. That produces a DRY_RUN report of order ids, notification ids, and timestamps. The code-level fix, removing or gating the leftover workaround subscriber file, is a separate step with its own DRY_RUN guard that only deletes the file once a human sets DRY_RUN=false explicitly.

List recent orders id, email, created_at List notifications resource_id, to, created_at Cluster by window same recipient, within windowMs Cluster size > 1? yes no, leave alone Report, then guarded file removal if approved
Detection is read only and safe to run anytime. Removing the leftover subscriber file is a separate, deliberate step gated by its own DRY_RUN flag.

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 reports the duplicate clusters found
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

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 reports the duplicate clusters found
2

Authenticate against the Admin API

Every call after login carries the token in an Authorization: Bearer header. A small helper does the login once and hands back the token string that every later request reuses.

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
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;

async function getToken() {
  const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}
3

List recent orders, then the notifications recorded for each

Ask for recent orders sorted newest first with the fields the decision needs: id, display_id, email, created_at. Then, for every order, list the notifications Medusa actually recorded against it with resource_id=<order_id>. resource_type is order for the notifications an order.placed handler generates, and this log is written independently of whichever subscriber triggered it, so it tells the truth even when a leftover subscriber is the one causing the duplicate.

step3.py
ORDER_FIELDS = "id,display_id,email,created_at"
NOTIFICATION_FIELDS = "id,to,resource_id,resource_type,created_at,data"

def list_recent_orders(token, limit=100):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/orders",
        params={"fields": ORDER_FIELDS, "limit": limit, "order": "-created_at"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["orders"]


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, "fields": NOTIFICATION_FIELDS},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["notifications"]
step3.js
const ORDER_FIELDS = "id,display_id,email,created_at";
const NOTIFICATION_FIELDS = "id,to,resource_id,resource_type,created_at,data";

async function listRecentOrders(token, limit = 100) {
  const res = await fetch(
    `${BASE_URL}/admin/orders?fields=${ORDER_FIELDS}&limit=${limit}&order=-created_at`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.orders;
}

async function listNotificationsForOrder(token, orderId) {
  const res = await fetch(
    `${BASE_URL}/admin/notifications?resource_id=${orderId}&fields=${NOTIFICATION_FIELDS}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.notifications;
}
4

Decide, with one pure function

Keep the decision in a function with no network calls. Group order-related notifications by resource_id, sort each group by created_at, then cluster consecutive sends to the same recipient within windowMs of each other. Any cluster with more than one notification is a duplicate-send incident, the signature of order.placed firing more than once for that order. The function returns one entry per affected order with the full set of notification ids for the report.

decide.py
from datetime import datetime
from collections import defaultdict

def find_duplicate_notifications(notifications, window_ms=60000):
    """Pure: no I/O. notifications is a plain list already fetched."""
    by_order = defaultdict(list)
    for n in notifications:
        if n.get("resource_type") != "order":
            continue
        by_order[n["resource_id"]].append(n)

    results = []
    for order_id, group in by_order.items():
        group.sort(key=lambda n: n["created_at"])
        cluster = [group[0]]
        clusters = []
        for prev, cur in zip(group, group[1:]):
            same_recipient = prev.get("to") == cur.get("to")
            gap_ms = (
                datetime.fromisoformat(cur["created_at"].replace("Z", "+00:00")).timestamp()
                - datetime.fromisoformat(prev["created_at"].replace("Z", "+00:00")).timestamp()
            ) * 1000
            if same_recipient and gap_ms <= window_ms:
                cluster.append(cur)
            else:
                clusters.append(cluster)
                cluster = [cur]
        clusters.append(cluster)

        for c in clusters:
            if len(c) > 1:
                results.append({
                    "order_id": order_id,
                    "count": len(c),
                    "notification_ids": [n["id"] for n in c],
                })
    return results
decide.js
export function findDuplicateNotifications(notifications, windowMs = 60000) {
  // Pure: no I/O. notifications is a plain array already fetched.
  const byOrder = new Map();
  for (const n of notifications) {
    if (n.resource_type !== "order") continue;
    if (!byOrder.has(n.resource_id)) byOrder.set(n.resource_id, []);
    byOrder.get(n.resource_id).push(n);
  }

  const results = [];
  for (const [orderId, group] of byOrder) {
    group.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
    const clusters = [];
    let cluster = [group[0]];
    for (let i = 1; i < group.length; i++) {
      const prev = group[i - 1];
      const cur = group[i];
      const sameRecipient = prev.to === cur.to;
      const gapMs = Date.parse(cur.created_at) - Date.parse(prev.created_at);
      if (sameRecipient && gapMs <= windowMs) {
        cluster.push(cur);
      } else {
        clusters.push(cluster);
        cluster = [cur];
      }
    }
    clusters.push(cluster);

    for (const c of clusters) {
      if (c.length > 1) {
        results.push({
          order_id: orderId,
          count: c.length,
          notification_ids: c.map((n) => n.id),
        });
      }
    }
  }
  return results;
}
5

Report the duplicates, never resend or delete anything

Notification records are an audit trail. This script only reads them and prints a DRY_RUN report of order ids, notification ids, and timestamps. It never resends a missing notification and never deletes an existing one, because deleting a Notification record would destroy the exact evidence you need to confirm the duplicate happened and when.

report.py
def report_duplicates(duplicates, orders_by_id):
    for dup in duplicates:
        order = orders_by_id.get(dup["order_id"], {})
        log.warning(
            "Order %s (display_id=%s) got %d confirmation notifications. ids=%s",
            dup["order_id"], order.get("display_id"), dup["count"], dup["notification_ids"],
        )
report.js
function reportDuplicates(duplicates, ordersById) {
  for (const dup of duplicates) {
    const order = ordersById.get(dup.order_id) || {};
    console.warn(
      `Order ${dup.order_id} (display_id=${order.display_id}) got ${dup.count} confirmation notifications. ids=${JSON.stringify(dup.notification_ids)}`
    );
  }
}
6

Wire it together with a dry run guard

The loop ties detection together. It always runs read only, since finding duplicates never needs a write. On the first few runs, leave DRY_RUN on so it only logs the affected orders. The separate, code-level repair, finding and removing the leftover src/subscribers file that still calls capturePaymentWorkflow on order.placed, is its own guarded step. It prints the subscriber file path and a diff first, and only deletes the file when a human explicitly sets DRY_RUN=false on that step.

Run it safe

This is not a data mutation you can safely automate. Detection is read only and safe to run anytime. Never resend or delete a Notification record to clean up the report, they are your audit trail. The actual repair is a code change, deleting or version-gating the leftover subscriber file, and it should be reviewed like any other code change, with the DRY_RUN script only showing you the file and its diff until DRY_RUN=false is set on purpose.

The full code

Here is the complete script in one file for each language. It authenticates, lists recent orders and their notifications, clusters duplicate sends with a pure function, and always reports, since detection never writes anything. A second, separate script shows how the subscriber file removal itself is gated behind its own DRY_RUN flag.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
find_duplicate_confirmations.py
"""Find Medusa v2 orders that received more than one order confirmation
notification because order.placed fired, or was acted on, more than once.
The usual cause is a leftover workaround subscriber that manually called
capturePaymentWorkflow to patch an old payment-status bug (Medusa issues
#11766 and #13301), left running unconditionally after Medusa v2.11.1 fixed
those bugs upstream. This script only reads orders and notifications, and
only ever reports, DRY_RUN=true or not, because Notification records are an
audit trail and must never be resent or deleted automatically. Repairing the
leftover subscriber file is a separate, code-level step with its own guard.
"""
import os
import logging
from datetime import datetime
from collections import defaultdict

import requests

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

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"
WINDOW_MS = int(os.environ.get("DUPLICATE_WINDOW_MS", "60000"))
ORDER_LIMIT = int(os.environ.get("ORDER_LIMIT", "100"))

ORDER_FIELDS = "id,display_id,email,created_at"
NOTIFICATION_FIELDS = "id,to,resource_id,resource_type,created_at,data"


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=ORDER_LIMIT):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/orders",
        params={"fields": ORDER_FIELDS, "limit": limit, "order": "-created_at"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["orders"]


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, "fields": NOTIFICATION_FIELDS},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["notifications"]


def find_duplicate_notifications(notifications, window_ms=60000):
    """Pure: no I/O. notifications is a plain list already fetched."""
    by_order = defaultdict(list)
    for n in notifications:
        if n.get("resource_type") != "order":
            continue
        by_order[n["resource_id"]].append(n)

    results = []
    for order_id, group in by_order.items():
        group.sort(key=lambda n: n["created_at"])
        cluster = [group[0]]
        clusters = []
        for prev, cur in zip(group, group[1:]):
            same_recipient = prev.get("to") == cur.get("to")
            gap_ms = (
                datetime.fromisoformat(cur["created_at"].replace("Z", "+00:00")).timestamp()
                - datetime.fromisoformat(prev["created_at"].replace("Z", "+00:00")).timestamp()
            ) * 1000
            if same_recipient and gap_ms <= window_ms:
                cluster.append(cur)
            else:
                clusters.append(cluster)
                cluster = [cur]
        clusters.append(cluster)

        for c in clusters:
            if len(c) > 1:
                results.append({
                    "order_id": order_id,
                    "count": len(c),
                    "notification_ids": [n["id"] for n in c],
                })
    return results


def run():
    token = get_token()
    orders = list_recent_orders(token)
    orders_by_id = {o["id"]: o for o in orders}

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

    duplicates = find_duplicate_notifications(all_notifications, WINDOW_MS)

    if not duplicates:
        log.info("No duplicate confirmation notifications across %d order(s).", len(orders))
        return

    for dup in duplicates:
        order = orders_by_id.get(dup["order_id"], {})
        log.warning(
            "DRY_RUN report: order %s (display_id=%s) got %d confirmation notifications. ids=%s",
            dup["order_id"], order.get("display_id"), dup["count"], dup["notification_ids"],
        )

    log.info(
        "Done. %d order(s) with duplicate confirmation sends. Report only, DRY_RUN=%s. "
        "No notification was resent or deleted. The code fix is removing or gating the "
        "leftover order.placed subscriber that calls capturePaymentWorkflow.",
        len(duplicates), DRY_RUN,
    )


if __name__ == "__main__":
    run()
find-duplicate-confirmations.js
/**
 * Find Medusa v2 orders that received more than one order confirmation
 * notification because order.placed fired, or was acted on, more than once.
 * The usual cause is a leftover workaround subscriber that manually called
 * capturePaymentWorkflow to patch an old payment-status bug (Medusa issues
 * #11766 and #13301), left running unconditionally after Medusa v2.11.1
 * fixed those bugs upstream. This script only reads orders and
 * notifications, and only ever reports, DRY_RUN=true or not, because
 * Notification records are an audit trail and must never be resent or
 * deleted automatically. Repairing the leftover subscriber file is a
 * separate, code-level step with its own guard.
 */
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 WINDOW_MS = Number(process.env.DUPLICATE_WINDOW_MS || 60000);
const ORDER_LIMIT = Number(process.env.ORDER_LIMIT || 100);

const ORDER_FIELDS = "id,display_id,email,created_at";
const NOTIFICATION_FIELDS = "id,to,resource_id,resource_type,created_at,data";

export function findDuplicateNotifications(notifications, windowMs = 60000) {
  // Pure: no I/O. notifications is a plain array already fetched.
  const byOrder = new Map();
  for (const n of notifications) {
    if (n.resource_type !== "order") continue;
    if (!byOrder.has(n.resource_id)) byOrder.set(n.resource_id, []);
    byOrder.get(n.resource_id).push(n);
  }

  const results = [];
  for (const [orderId, group] of byOrder) {
    group.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
    const clusters = [];
    let cluster = [group[0]];
    for (let i = 1; i < group.length; i++) {
      const prev = group[i - 1];
      const cur = group[i];
      const sameRecipient = prev.to === cur.to;
      const gapMs = Date.parse(cur.created_at) - Date.parse(prev.created_at);
      if (sameRecipient && gapMs <= windowMs) {
        cluster.push(cur);
      } else {
        clusters.push(cluster);
        cluster = [cur];
      }
    }
    clusters.push(cluster);

    for (const c of clusters) {
      if (c.length > 1) {
        results.push({
          order_id: orderId,
          count: c.length,
          notification_ids: c.map((n) => n.id),
        });
      }
    }
  }
  return results;
}

async function getToken() {
  const res = await fetch(`${BASE_URL}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa auth ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function listRecentOrders(token, limit = ORDER_LIMIT) {
  const res = await fetch(
    `${BASE_URL}/admin/orders?fields=${ORDER_FIELDS}&limit=${limit}&order=-created_at`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.orders;
}

async function listNotificationsForOrder(token, orderId) {
  const res = await fetch(
    `${BASE_URL}/admin/notifications?resource_id=${orderId}&fields=${NOTIFICATION_FIELDS}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.notifications;
}

export async function run() {
  const token = await getToken();
  const orders = await listRecentOrders(token);
  const ordersById = new Map(orders.map((o) => [o.id, o]));

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

  const duplicates = findDuplicateNotifications(allNotifications, WINDOW_MS);

  if (duplicates.length === 0) {
    console.log(`No duplicate confirmation notifications across ${orders.length} order(s).`);
    return;
  }

  for (const dup of duplicates) {
    const order = ordersById.get(dup.order_id) || {};
    console.warn(
      `DRY_RUN report: order ${dup.order_id} (display_id=${order.display_id}) got ${dup.count} confirmation notifications. ids=${JSON.stringify(dup.notification_ids)}`
    );
  }

  console.log(
    `Done. ${duplicates.length} order(s) with duplicate confirmation sends. Report only, DRY_RUN=${DRY_RUN}. ` +
    "No notification was resent or deleted. The code fix is removing or gating the leftover " +
    "order.placed subscriber that calls capturePaymentWorkflow."
  );
}

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_duplicate_notifications. It is pure, no network and no Medusa account, so the tests feed in plain notification arrays and check which orders get flagged.

test_duplicate_confirmations.py
from find_duplicate_confirmations import find_duplicate_notifications


def notification(**over):
    base = {
        "id": "notif_1",
        "resource_id": "order_1",
        "resource_type": "order",
        "to": "buyer@example.com",
        "created_at": "2026-07-10T12:00:00Z",
    }
    base.update(over)
    return base


def test_flags_two_sends_within_window():
    notifications = [
        notification(id="notif_1", created_at="2026-07-10T12:00:00Z"),
        notification(id="notif_2", created_at="2026-07-10T12:00:20Z"),
    ]
    result = find_duplicate_notifications(notifications, window_ms=60000)
    assert result == [{"order_id": "order_1", "count": 2, "notification_ids": ["notif_1", "notif_2"]}]


def test_does_not_flag_a_single_send():
    result = find_duplicate_notifications([notification()], window_ms=60000)
    assert result == []


def test_does_not_flag_sends_outside_the_window():
    notifications = [
        notification(id="notif_1", created_at="2026-07-10T12:00:00Z"),
        notification(id="notif_2", created_at="2026-07-10T12:05:00Z"),
    ]
    result = find_duplicate_notifications(notifications, window_ms=60000)
    assert result == []


def test_ignores_non_order_resource_type():
    notifications = [
        notification(id="notif_1", resource_type="customer"),
        notification(id="notif_2", resource_type="customer", created_at="2026-07-10T12:00:10Z"),
    ]
    result = find_duplicate_notifications(notifications, window_ms=60000)
    assert result == []


def test_does_not_cluster_different_recipients():
    notifications = [
        notification(id="notif_1", to="buyer@example.com"),
        notification(id="notif_2", to="other@example.com", created_at="2026-07-10T12:00:10Z"),
    ]
    result = find_duplicate_notifications(notifications, window_ms=60000)
    assert result == []


def test_handles_multiple_orders_independently():
    notifications = [
        notification(id="notif_1", resource_id="order_1", created_at="2026-07-10T12:00:00Z"),
        notification(id="notif_2", resource_id="order_1", created_at="2026-07-10T12:00:10Z"),
        notification(id="notif_3", resource_id="order_2", created_at="2026-07-10T13:00:00Z"),
    ]
    result = find_duplicate_notifications(notifications, window_ms=60000)
    assert result == [{"order_id": "order_1", "count": 2, "notification_ids": ["notif_1", "notif_2"]}]


def test_three_sends_in_one_cluster():
    notifications = [
        notification(id="notif_1", created_at="2026-07-10T12:00:00Z"),
        notification(id="notif_2", created_at="2026-07-10T12:00:10Z"),
        notification(id="notif_3", created_at="2026-07-10T12:00:20Z"),
    ]
    result = find_duplicate_notifications(notifications, window_ms=60000)
    assert result == [{"order_id": "order_1", "count": 3, "notification_ids": ["notif_1", "notif_2", "notif_3"]}]
duplicate-confirmations.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateNotifications } from "./find-duplicate-confirmations.js";

const notification = (over = {}) => ({
  id: "notif_1",
  resource_id: "order_1",
  resource_type: "order",
  to: "buyer@example.com",
  created_at: "2026-07-10T12:00:00Z",
  ...over,
});

test("flags two sends within window", () => {
  const notifications = [
    notification({ id: "notif_1", created_at: "2026-07-10T12:00:00Z" }),
    notification({ id: "notif_2", created_at: "2026-07-10T12:00:20Z" }),
  ];
  const result = findDuplicateNotifications(notifications, 60000);
  assert.deepEqual(result, [{ order_id: "order_1", count: 2, notification_ids: ["notif_1", "notif_2"] }]);
});

test("does not flag a single send", () => {
  const result = findDuplicateNotifications([notification()], 60000);
  assert.deepEqual(result, []);
});

test("does not flag sends outside the window", () => {
  const notifications = [
    notification({ id: "notif_1", created_at: "2026-07-10T12:00:00Z" }),
    notification({ id: "notif_2", created_at: "2026-07-10T12:05:00Z" }),
  ];
  const result = findDuplicateNotifications(notifications, 60000);
  assert.deepEqual(result, []);
});

test("ignores non-order resource type", () => {
  const notifications = [
    notification({ id: "notif_1", resource_type: "customer" }),
    notification({ id: "notif_2", resource_type: "customer", created_at: "2026-07-10T12:00:10Z" }),
  ];
  const result = findDuplicateNotifications(notifications, 60000);
  assert.deepEqual(result, []);
});

test("does not cluster different recipients", () => {
  const notifications = [
    notification({ id: "notif_1", to: "buyer@example.com" }),
    notification({ id: "notif_2", to: "other@example.com", created_at: "2026-07-10T12:00:10Z" }),
  ];
  const result = findDuplicateNotifications(notifications, 60000);
  assert.deepEqual(result, []);
});

test("handles multiple orders independently", () => {
  const notifications = [
    notification({ id: "notif_1", resource_id: "order_1", created_at: "2026-07-10T12:00:00Z" }),
    notification({ id: "notif_2", resource_id: "order_1", created_at: "2026-07-10T12:00:10Z" }),
    notification({ id: "notif_3", resource_id: "order_2", created_at: "2026-07-10T13:00:00Z" }),
  ];
  const result = findDuplicateNotifications(notifications, 60000);
  assert.deepEqual(result, [{ order_id: "order_1", count: 2, notification_ids: ["notif_1", "notif_2"] }]);
});

test("three sends in one cluster", () => {
  const notifications = [
    notification({ id: "notif_1", created_at: "2026-07-10T12:00:00Z" }),
    notification({ id: "notif_2", created_at: "2026-07-10T12:00:10Z" }),
    notification({ id: "notif_3", created_at: "2026-07-10T12:00:20Z" }),
  ];
  const result = findDuplicateNotifications(notifications, 60000);
  assert.deepEqual(result, [{ order_id: "order_1", count: 3, notification_ids: ["notif_1", "notif_2", "notif_3"] }]);
});

Case studies

Post-upgrade cleanup

The workaround that outlived its own bug

A team hit Medusa issues #11766 and #13301 hard during a launch, Stripe webhooks were firing but orders sat with a stale payment status, so they shipped an order.placed subscriber that force-called capturePaymentWorkflow as a stopgap. It worked, the launch went fine, and the subscriber quietly stayed in src/subscribers long after.

Months later, after upgrading to Medusa v2.11.1, support started getting occasional tickets about duplicate confirmation emails. Running the detection script against the last week of orders found eleven with two confirmation notifications each, all within seconds of each other. Grepping src/subscribers for a handler on order.placed that called capturePaymentWorkflow found the exact leftover file, comment and GitHub issue link still attached. Removing it, behind the DRY_RUN-gated deletion script and a normal code review, stopped the duplicates immediately.

Silent duplication

Nobody noticed until a customer complained

A smaller store never had a support queue dedicated to email issues, so a handful of customers getting two confirmation emails per order simply went unreported for weeks. Nothing about the orders, payments, or fulfillment looked wrong internally, since the duplicate emails were a side effect entirely outside the order record.

Once a customer finally asked why they got billed once but emailed twice, the team ran the detection script across the past month and found the pattern was present the entire time, dozens of orders, always two notifications, always seconds apart. The report gave them exact order ids and notification ids to reference in the eventual root cause writeup, without needing to resend or delete any notification to investigate.

What good looks like

Run the detection script on a schedule, or right after any suspicious spike in support tickets about duplicate emails. It never resends or deletes a notification, so it can never make the problem worse while you investigate, it only tells you exactly which orders got hit and when. The durable fix lives in the subscriber file itself: any subscriber written as a workaround for a specific upstream bug should carry a version check or a feature flag, not just a comment, so it stops running the moment the bug it was written for is actually fixed. Delete or gate the leftover file, confirm with the detection script that new duplicates stop appearing, and treat every workaround subscriber going forward as something with an expiry date, not a permanent fixture.

FAQ

Why is a customer getting two order confirmation emails from Medusa?

Something is causing order.placed to fire, or be acted on, more than once for the same order. In Medusa v2 a common cause is a leftover workaround subscriber that a team added to patch an old payment status bug, which still runs unconditionally after the upstream bug was fixed and ends up duplicating work the core order workflow already does, which in turn makes the notification subscriber send a second email.

Is a subscriber calling capturePaymentWorkflow on order.placed always a problem?

It is a problem once the reason it was added no longer applies. Medusa v2.11.1 fixed the upstream bugs where Stripe webhooks fired but the order payment status never advanced, so a subscriber that still manually calls capturePaymentWorkflow on every order.placed is redundant work with no version check or feature flag gating it, and it can cause order.placed to be re-processed, which duplicates any other subscriber's side effects, including the confirmation email.

How do I find which orders got a duplicate confirmation email without touching subscriber code first?

List recent orders from the Admin API, then for each order list its notifications at /admin/notifications filtered by resource_id. Group the notifications by resource_id and flag any order where more than one notification with resource_type order and the same recipient email landed within a short time window, such as the same minute. That pattern is the signature of order.placed firing more than once for that order.

Related field notes

Citations

On the problem:

  1. Medusa v2 in Production: Three Bugs That Each Ate a Weekend. dev.to/dbartalos/medusa-v2-in-production-three-bugs-that-each-ate-a-weekend-4e67
  2. Medusa Documentation: Events and Subscribers, the filesystem-based subscriber model. docs.medusajs.com/learn/fundamentals/events-and-subscribers
  3. medusajs/medusa GitHub: Subscriber is not stable, issue #7156. github.com/medusajs/medusa/issues/7156

On the solution:

  1. Medusa Documentation: Events and Subscribers, how subscribers register and run. docs.medusajs.com/learn/fundamentals/events-and-subscribers
  2. Medusa Documentation: How to Use Notification Module, the durable delivery log at /admin/notifications. docs.medusajs.com/resources/references/notification-service
  3. Medusa Core Workflows Reference: capturePaymentWorkflow. docs.medusajs.com/resources/references/medusa-workflows/capturePaymentWorkflow

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 leftover workaround subscriber?

If this saved you from a confusing support ticket or a customer getting two receipts, 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