Skip to content

Diagnostic Pricing & Promotions

Draft orders reject valid promotion codes

The promo code is real. It is active, it applies cleanly to a normal storefront cart, and there is nothing wrong with it. But the moment a script or an admin call tries to add that same code to a draft order, Medusa throws back "An active Order Change is required to proceed," and the code never attaches. Here is why draft orders check for something a regular cart never needs, and a script that tells apart a missing edit session from a genuinely inactive promotion, then repairs only the safe case.

Python and Node.js Medusa Admin API DRY_RUN guarded repair
A red sale sign
Photo by Claudio Schwarz on Unsplash
The short answer

In Medusa v2, adding a promotion to a draft order does not go through the same addPromotionsToCartWorkflow and computeActions path a regular cart uses. It goes through addDraftOrderPromotionWorkflow, called via POST /admin/draft-orders/:id/edit/promotions. That workflow first runs validateDraftOrderChangeStep, which requires an active order_change record on the order, with status pending or requested, and throws "An active Order Change is required to proceed" if none exists. A script that applies a promo code straight after creating or fetching a draft order, without first calling POST /admin/draft-orders/:id/edit to open an edit session, gets that otherwise valid code rejected, even though the identical code applies without any fuss to a storefront cart. There is a second, unrelated rejection too: validatePromoCodesToAddStep also rejects codes whose promotion.status is not active, with a different message, "is not active." Run a small Python or Node.js script that checks both conditions separately, classifies the rejection reason, and only opens the missing edit session automatically, since forcing an inactive promotion live is a merchant decision. Full code and tests below.

The problem in plain words

A storefront cart and a draft order both end up with a promotion code attached to them, but Medusa gets there through two completely different roads. A cart calls addPromotionsToCartWorkflow, which recomputes discounts with computeActions and has no idea what an "edit session" even is. There is nothing to open first. You send the code, Medusa checks it, and it either applies or it does not.

A draft order is different because it can be edited after it already exists, sometimes hours or days later, and Medusa needs a safe, trackable way to stage those edits before they become final. That mechanism is the order_change record. Every edit to a draft order, including adding a promotion, has to happen inside one of these change records, and it has to be opened first with POST /admin/draft-orders/:id/edit. Only after that call exists does the order carry an order_change with status pending or requested.

The endpoint for adding a promo code, POST /admin/draft-orders/:id/edit/promotions, runs addDraftOrderPromotionWorkflow, and the very first step in that workflow is validateDraftOrderChangeStep, which calls throwIfOrderChangeIsNotActive. If the draft order has no order_change, or the one it has was already canceled, confirmed, or declined, that step throws immediately with "An active Order Change is required to proceed." The promo code itself is never even looked at. A script that fetches a draft order and calls the promotions endpoint right away, the same shape of call that works fine against a cart, hits this every time.

Draft order exists no order_change yet POST edit/promotions promo_codes: [CODE] no active order_change validateDraftOrder ChangeStep throws first Code never even checked
The code is valid and active, but the workflow rejects the request before it ever reaches the promo code check, because the draft order has no open edit session.

Why it happens

Draft order edits and cart promotions are built on different foundations inside Medusa v2, and the create validation on the draft order side does not warn you about it. A few concrete ways teams hit this:

This is a common source of confusion because both failures return a similar-looking 400 with a MedusaError of type INVALID_DATA, and it is easy to assume every promo rejection on a draft order means the code is broken. Medusa's own issue tracker and a merged fix both confirm this is a known rough edge in how the draft order edit endpoints validate promotions. See the citations at the end for the exact threads and docs.

The key insight

These are two different failures with two different fixes, and conflating them leads to the wrong repair. "No active edit session" is a workflow ordering problem, and it is safe to fix automatically, since opening an edit session with POST /admin/draft-orders/:id/edit only stages a pending change, it does not alter pricing by itself. "Code not active" is a business decision about which promotions a merchant wants live, and a script should never silently flip that switch. The safe pattern is to classify the exact reason with a pure, testable function, repair only the edit session case, and flag the promotion status case for a human.

The fix, as a flow

We never guess at pricing and we never activate a promotion on our own. We read the draft order's order_change state and the promotion's own status, classify exactly why the code would be or was rejected, and only when the reason is a missing or inactive edit session do we open one, apply the code, then request and confirm the change. If the reason is instead that the promotion itself is not active, we stop and flag it for a human.

Read order_change and promotion status Classify the reason pure classifyPromoRejection No edit session? yes no, code not active: flag human DRY_RUN=false? else log the plan only and stop Open edit, add code, request, confirm
Only the missing edit session gets repaired automatically. A promotion that is genuinely not active is reported for a human to activate, never forced.

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 and update draft orders and read promotions. 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, and default to a dry run.

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, change to false only after reviewing the plan
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, change to false only after reviewing the plan
2

Authenticate against the Admin API

Every call after this sends the returned token as a Bearer header. A small helper keeps the request shape in one place for reads and for the edit session calls.

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

Read the draft order's order_change and the promotion's status

Ask the draft order for id, status, is_draft_order, and the expanded order_change, which tells us whether an edit session is already open. Ask the promotions endpoint for the code's id, code, and status, which tells us whether the code is actually active. This is everything the classifier needs, and nothing here writes anything yet.

step3.py
DRAFT_ORDER_FIELDS = "id,status,is_draft_order,*order_change"

def get_draft_order(token, draft_order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/draft-orders/{draft_order_id}",
        params={"fields": DRAFT_ORDER_FIELDS},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["draft_order"]

def find_promotions_by_codes(token, codes):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/promotions",
        params={"code[]": codes, "fields": "id,code,status,campaign_id"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["promotions"]
step3.js
const DRAFT_ORDER_FIELDS = "id,status,is_draft_order,*order_change";

async function getDraftOrder(token, draftOrderId) {
  const url = new URL(`${BASE_URL}/admin/draft-orders/${draftOrderId}`);
  url.searchParams.set("fields", DRAFT_ORDER_FIELDS);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.draft_order;
}

async function findPromotionsByCodes(token, codes) {
  const url = new URL(`${BASE_URL}/admin/promotions`);
  for (const code of codes) url.searchParams.append("code[]", code);
  url.searchParams.set("fields", "id,code,status,campaign_id");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.promotions;
}
4

Decide, with one pure function

Keep the classification in its own function that takes the draft order, the known promotions, and the requested codes, and returns a reason per code. A pure function like this is easy to read and easy to test, which we do later. It mirrors the exact checks Medusa itself runs, in the exact order: first whether this is really a draft order, then whether an edit session is open and still active, then whether each code even resolves to a promotion, then whether that promotion's status is active.

decide.py
def classify_promo_rejection(order, promotions, requested_codes):
    by_code = {p["code"]: p for p in promotions}
    results = []
    for code in requested_codes:
        if order.get("status") != "draft" and not order.get("is_draft_order"):
            results.append({"code": code, "reason": "not_draft_order"})
            continue

        order_change = order.get("order_change")
        if order_change is None:
            results.append({"code": code, "reason": "no_active_edit_session"})
            continue
        if order_change.get("canceled_at") or order_change.get("confirmed_at") or order_change.get("declined_at"):
            results.append({"code": code, "reason": "edit_session_inactive"})
            continue

        promo = by_code.get(code)
        if promo is None:
            results.append({"code": code, "reason": "code_not_found"})
            continue
        if promo.get("status") != "active":
            results.append({"code": code, "reason": "code_not_active"})
            continue

        results.append({"code": code, "reason": "ok"})
    return results
decide.js
export function classifyPromoRejection(order, promotions, requestedCodes) {
  const byCode = new Map(promotions.map((p) => [p.code, p]));
  return requestedCodes.map((code) => {
    if (order.status !== "draft" && !order.is_draft_order) {
      return { code, reason: "not_draft_order" };
    }

    const orderChange = order.order_change;
    if (orderChange === null || orderChange === undefined) {
      return { code, reason: "no_active_edit_session" };
    }
    if (orderChange.canceled_at || orderChange.confirmed_at || orderChange.declined_at) {
      return { code, reason: "edit_session_inactive" };
    }

    const promo = byCode.get(code);
    if (!promo) return { code, reason: "code_not_found" };
    if (promo.status !== "active") return { code, reason: "code_not_active" };

    return { code, reason: "ok" };
  });
}
5

Open the edit session, then add the promo code

Only when the reason is no_active_edit_session or edit_session_inactive do we call POST /admin/draft-orders/:id/edit, which stages a fresh pending order_change without touching pricing on its own. With that in place, POST /admin/draft-orders/:id/edit/promotions with the code now succeeds, since validateDraftOrderChangeStep finds an active change to attach it to.

apply.py
def open_edit_session(token, draft_order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/draft-orders/{draft_order_id}/edit",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def add_promo_codes(token, draft_order_id, codes):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/draft-orders/{draft_order_id}/edit/promotions",
        json={"promo_codes": codes},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
apply.js
async function openEditSession(token, draftOrderId) {
  const res = await fetch(`${BASE_URL}/admin/draft-orders/${draftOrderId}/edit`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

async function addPromoCodes(token, draftOrderId, codes) {
  const res = await fetch(`${BASE_URL}/admin/draft-orders/${draftOrderId}/edit/promotions`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ promo_codes: codes }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}
6

Request and confirm, wired together with a dry run guard

Once the code is attached inside the edit session, POST /admin/draft-orders/:id/edit/request and then POST /admin/draft-orders/:id/edit/confirm persist the change onto the order. If the classifier instead reports code_not_active, we stop and log it for a human to activate the promotion in Medusa Admin, we never flip its status ourselves. In dry run, everything is logged and nothing is written.

Run it safe

Always start with DRY_RUN=true. It only reports the classified reason per draft order and code, and what it would do about it. Only flip it to false once you have confirmed the plan is right, and never let this script or any script silently activate a promotion whose status is not active, that decision belongs to a human in Medusa Admin.

The full code

Here is the complete script in one file for each language. It authenticates, reads the draft order and the requested promotion codes, classifies the exact rejection reason with the pure rule, and, only when writing is explicitly enabled and the reason is a missing or inactive edit session, opens one, adds the codes, and confirms the change.

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.
fix_draft_order_promo_code.py
"""Classify and safely repair Medusa draft orders that reject a valid
promotion code because no order_change edit session is open yet. Never
activates a promotion whose status is not active, that is flagged for a
human. Safe to run again and again.
"""
import os
import logging

import requests

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

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"

DRAFT_ORDER_FIELDS = "id,status,is_draft_order,*order_change"

# Reasons that are safe to repair automatically: only a missing or inactive
# edit session. A promotion that is genuinely not active is never forced.
REPAIRABLE_REASONS = {"no_active_edit_session", "edit_session_inactive"}


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 get_draft_order(token, draft_order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/draft-orders/{draft_order_id}",
        params={"fields": DRAFT_ORDER_FIELDS},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["draft_order"]


def find_promotions_by_codes(token, codes):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/promotions",
        params={"code[]": codes, "fields": "id,code,status,campaign_id"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["promotions"]


def classify_promo_rejection(order, promotions, requested_codes):
    """Pure: no I/O. Mirrors throwIfNotDraftOrder, throwIfOrderChangeIsNotActive,
    throwIfCodesAreMissing, and throwIfCodesAreInactive, in that exact order.
    """
    by_code = {p["code"]: p for p in promotions}
    results = []
    for code in requested_codes:
        if order.get("status") != "draft" and not order.get("is_draft_order"):
            results.append({"code": code, "reason": "not_draft_order"})
            continue

        order_change = order.get("order_change")
        if order_change is None:
            results.append({"code": code, "reason": "no_active_edit_session"})
            continue
        if order_change.get("canceled_at") or order_change.get("confirmed_at") or order_change.get("declined_at"):
            results.append({"code": code, "reason": "edit_session_inactive"})
            continue

        promo = by_code.get(code)
        if promo is None:
            results.append({"code": code, "reason": "code_not_found"})
            continue
        if promo.get("status") != "active":
            results.append({"code": code, "reason": "code_not_active"})
            continue

        results.append({"code": code, "reason": "ok"})
    return results


def open_edit_session(token, draft_order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/draft-orders/{draft_order_id}/edit",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def add_promo_codes(token, draft_order_id, codes):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/draft-orders/{draft_order_id}/edit/promotions",
        json={"promo_codes": codes},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def request_edit(token, draft_order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/draft-orders/{draft_order_id}/edit/request",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def confirm_edit(token, draft_order_id):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/draft-orders/{draft_order_id}/edit/confirm",
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def run(draft_order_id=None, codes=None):
    draft_order_id = draft_order_id or os.environ["DRAFT_ORDER_ID"]
    codes = codes or [c.strip() for c in os.environ.get("PROMO_CODES", "").split(",") if c.strip()]
    if not codes:
        raise ValueError("No promo codes provided. Set PROMO_CODES as a comma separated list.")

    token = get_token()
    order = get_draft_order(token, draft_order_id)
    promotions = find_promotions_by_codes(token, codes)

    classified = classify_promo_rejection(order, promotions, codes)
    repairable_codes = []
    for item in classified:
        code, reason = item["code"], item["reason"]
        if reason == "ok":
            log.info("Code %s already ok, nothing to do.", code)
        elif reason in REPAIRABLE_REASONS:
            log.warning("Code %s rejected: %s. %s", code, reason,
                        "would open edit session and add it" if DRY_RUN else "opening edit session and adding it")
            repairable_codes.append(code)
        elif reason == "code_not_active":
            log.warning("Code %s rejected: promotion is not active. Flagging for a human to activate it in Medusa Admin.", code)
        else:
            log.warning("Code %s rejected: %s. Not auto-repairable.", code, reason)

    if not repairable_codes:
        log.info("Done. Nothing to repair for draft order %s.", draft_order_id)
        return

    if DRY_RUN:
        log.info("Dry run. Would repair %d code(s) on draft order %s.", len(repairable_codes), draft_order_id)
        return

    open_edit_session(token, draft_order_id)
    add_promo_codes(token, draft_order_id, repairable_codes)
    request_edit(token, draft_order_id)
    confirm_edit(token, draft_order_id)
    log.info("Done. Repaired %d code(s) on draft order %s.", len(repairable_codes), draft_order_id)


if __name__ == "__main__":
    run()
fix-draft-order-promo-code.js
/**
 * Classify and safely repair Medusa draft orders that reject a valid
 * promotion code because no order_change edit session is open yet. Never
 * activates a promotion whose status is not active, that is flagged for a
 * human. Safe to run again and again.
 */
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 DRAFT_ORDER_FIELDS = "id,status,is_draft_order,*order_change";

// Reasons that are safe to repair automatically: only a missing or inactive
// edit session. A promotion that is genuinely not active is never forced.
const REPAIRABLE_REASONS = new Set(["no_active_edit_session", "edit_session_inactive"]);

/**
 * Pure: no I/O. Mirrors throwIfNotDraftOrder, throwIfOrderChangeIsNotActive,
 * throwIfCodesAreMissing, and throwIfCodesAreInactive, in that exact order.
 */
export function classifyPromoRejection(order, promotions, requestedCodes) {
  const byCode = new Map(promotions.map((p) => [p.code, p]));
  return requestedCodes.map((code) => {
    if (order.status !== "draft" && !order.is_draft_order) {
      return { code, reason: "not_draft_order" };
    }

    const orderChange = order.order_change;
    if (orderChange === null || orderChange === undefined) {
      return { code, reason: "no_active_edit_session" };
    }
    if (orderChange.canceled_at || orderChange.confirmed_at || orderChange.declined_at) {
      return { code, reason: "edit_session_inactive" };
    }

    const promo = byCode.get(code);
    if (!promo) return { code, reason: "code_not_found" };
    if (promo.status !== "active") return { code, reason: "code_not_active" };

    return { code, reason: "ok" };
  });
}

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 getDraftOrder(token, draftOrderId) {
  const url = new URL(`${BASE_URL}/admin/draft-orders/${draftOrderId}`);
  url.searchParams.set("fields", DRAFT_ORDER_FIELDS);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.draft_order;
}

async function findPromotionsByCodes(token, codes) {
  const url = new URL(`${BASE_URL}/admin/promotions`);
  for (const code of codes) url.searchParams.append("code[]", code);
  url.searchParams.set("fields", "id,code,status,campaign_id");
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.promotions;
}

async function openEditSession(token, draftOrderId) {
  const res = await fetch(`${BASE_URL}/admin/draft-orders/${draftOrderId}/edit`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

async function addPromoCodes(token, draftOrderId, codes) {
  const res = await fetch(`${BASE_URL}/admin/draft-orders/${draftOrderId}/edit/promotions`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ promo_codes: codes }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

async function requestEdit(token, draftOrderId) {
  const res = await fetch(`${BASE_URL}/admin/draft-orders/${draftOrderId}/edit/request`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

async function confirmEdit(token, draftOrderId) {
  const res = await fetch(`${BASE_URL}/admin/draft-orders/${draftOrderId}/edit/confirm`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  return res.json();
}

export async function run(draftOrderIdArg, codesArg) {
  const draftOrderId = draftOrderIdArg || process.env.DRAFT_ORDER_ID;
  const codes = codesArg || (process.env.PROMO_CODES || "").split(",").map((c) => c.trim()).filter(Boolean);
  if (!codes.length) {
    throw new Error("No promo codes provided. Set PROMO_CODES as a comma separated list.");
  }

  const token = await getToken();
  const order = await getDraftOrder(token, draftOrderId);
  const promotions = await findPromotionsByCodes(token, codes);

  const classified = classifyPromoRejection(order, promotions, codes);
  const repairableCodes = [];
  for (const { code, reason } of classified) {
    if (reason === "ok") {
      console.log(`Code ${code} already ok, nothing to do.`);
    } else if (REPAIRABLE_REASONS.has(reason)) {
      console.warn(
        `Code ${code} rejected: ${reason}. ${DRY_RUN ? "would open edit session and add it" : "opening edit session and adding it"}`
      );
      repairableCodes.push(code);
    } else if (reason === "code_not_active") {
      console.warn(`Code ${code} rejected: promotion is not active. Flagging for a human to activate it in Medusa Admin.`);
    } else {
      console.warn(`Code ${code} rejected: ${reason}. Not auto-repairable.`);
    }
  }

  if (!repairableCodes.length) {
    console.log(`Done. Nothing to repair for draft order ${draftOrderId}.`);
    return;
  }

  if (DRY_RUN) {
    console.log(`Dry run. Would repair ${repairableCodes.length} code(s) on draft order ${draftOrderId}.`);
    return;
  }

  await openEditSession(token, draftOrderId);
  await addPromoCodes(token, draftOrderId, repairableCodes);
  await requestEdit(token, draftOrderId);
  await confirmEdit(token, draftOrderId);
  console.log(`Done. Repaired ${repairableCodes.length} code(s) on draft order ${draftOrderId}.`);
}

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 above everything else is the classifier, because it decides whether a draft order gets an edit session opened for it, or gets flagged for a human instead. Because classify_promo_rejection is pure, the tests feed in plain order and promotion objects that match every branch Medusa itself checks, no Medusa backend required.

test_draftorder_promo_classify.py
from fix_draft_order_promo_code import classify_promo_rejection


def draft_order(**over):
    base = {
        "status": "draft",
        "is_draft_order": True,
        "order_change": {"status": "pending", "canceled_at": None, "confirmed_at": None, "declined_at": None},
    }
    base.update(over)
    return base


def promo(**over):
    base = {"code": "SAVE10", "status": "active"}
    base.update(over)
    return base


def test_ok_when_edit_session_active_and_promo_active():
    result = classify_promo_rejection(draft_order(), [promo()], ["SAVE10"])
    assert result == [{"code": "SAVE10", "reason": "ok"}]


def test_no_active_edit_session_when_order_change_missing():
    order = draft_order(order_change=None)
    result = classify_promo_rejection(order, [promo()], ["SAVE10"])
    assert result == [{"code": "SAVE10", "reason": "no_active_edit_session"}]


def test_edit_session_inactive_when_confirmed():
    order = draft_order(order_change={"status": "confirmed", "canceled_at": None, "confirmed_at": "2026-07-01T00:00:00Z", "declined_at": None})
    result = classify_promo_rejection(order, [promo()], ["SAVE10"])
    assert result == [{"code": "SAVE10", "reason": "edit_session_inactive"}]


def test_edit_session_inactive_when_canceled():
    order = draft_order(order_change={"status": "canceled", "canceled_at": "2026-07-01T00:00:00Z", "confirmed_at": None, "declined_at": None})
    result = classify_promo_rejection(order, [promo()], ["SAVE10"])
    assert result == [{"code": "SAVE10", "reason": "edit_session_inactive"}]


def test_edit_session_inactive_when_declined():
    order = draft_order(order_change={"status": "declined", "canceled_at": None, "confirmed_at": None, "declined_at": "2026-07-01T00:00:00Z"})
    result = classify_promo_rejection(order, [promo()], ["SAVE10"])
    assert result == [{"code": "SAVE10", "reason": "edit_session_inactive"}]


def test_not_draft_order_when_status_not_draft_and_flag_false():
    order = draft_order(status="completed", is_draft_order=False)
    result = classify_promo_rejection(order, [promo()], ["SAVE10"])
    assert result == [{"code": "SAVE10", "reason": "not_draft_order"}]


def test_code_not_found_when_promotion_missing():
    result = classify_promo_rejection(draft_order(), [], ["MISSING10"])
    assert result == [{"code": "MISSING10", "reason": "code_not_found"}]


def test_code_not_active_when_promotion_status_draft():
    result = classify_promo_rejection(draft_order(), [promo(status="draft")], ["SAVE10"])
    assert result == [{"code": "SAVE10", "reason": "code_not_active"}]


def test_multiple_codes_classified_independently():
    order = draft_order()
    promotions = [promo(code="SAVE10", status="active"), promo(code="OFF20", status="draft")]
    result = classify_promo_rejection(order, promotions, ["SAVE10", "OFF20", "MISSING"])
    assert result == [
        {"code": "SAVE10", "reason": "ok"},
        {"code": "OFF20", "reason": "code_not_active"},
        {"code": "MISSING", "reason": "code_not_found"},
    ]


def test_no_active_edit_session_checked_before_code_lookup():
    order = draft_order(order_change=None)
    result = classify_promo_rejection(order, [], ["ANY"])
    assert result == [{"code": "ANY", "reason": "no_active_edit_session"}]
draftorder-promo-classify.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyPromoRejection } from "./fix-draft-order-promo-code.js";

const draftOrder = (over = {}) => ({
  status: "draft",
  is_draft_order: true,
  order_change: { status: "pending", canceled_at: null, confirmed_at: null, declined_at: null },
  ...over,
});

const promo = (over = {}) => ({ code: "SAVE10", status: "active", ...over });

test("ok when edit session active and promo active", () => {
  const result = classifyPromoRejection(draftOrder(), [promo()], ["SAVE10"]);
  assert.deepEqual(result, [{ code: "SAVE10", reason: "ok" }]);
});

test("no_active_edit_session when order_change missing", () => {
  const order = draftOrder({ order_change: null });
  const result = classifyPromoRejection(order, [promo()], ["SAVE10"]);
  assert.deepEqual(result, [{ code: "SAVE10", reason: "no_active_edit_session" }]);
});

test("edit_session_inactive when confirmed", () => {
  const order = draftOrder({ order_change: { status: "confirmed", canceled_at: null, confirmed_at: "2026-07-01T00:00:00Z", declined_at: null } });
  const result = classifyPromoRejection(order, [promo()], ["SAVE10"]);
  assert.deepEqual(result, [{ code: "SAVE10", reason: "edit_session_inactive" }]);
});

test("edit_session_inactive when canceled", () => {
  const order = draftOrder({ order_change: { status: "canceled", canceled_at: "2026-07-01T00:00:00Z", confirmed_at: null, declined_at: null } });
  const result = classifyPromoRejection(order, [promo()], ["SAVE10"]);
  assert.deepEqual(result, [{ code: "SAVE10", reason: "edit_session_inactive" }]);
});

test("edit_session_inactive when declined", () => {
  const order = draftOrder({ order_change: { status: "declined", canceled_at: null, confirmed_at: null, declined_at: "2026-07-01T00:00:00Z" } });
  const result = classifyPromoRejection(order, [promo()], ["SAVE10"]);
  assert.deepEqual(result, [{ code: "SAVE10", reason: "edit_session_inactive" }]);
});

test("not_draft_order when status not draft and flag false", () => {
  const order = draftOrder({ status: "completed", is_draft_order: false });
  const result = classifyPromoRejection(order, [promo()], ["SAVE10"]);
  assert.deepEqual(result, [{ code: "SAVE10", reason: "not_draft_order" }]);
});

test("code_not_found when promotion missing", () => {
  const result = classifyPromoRejection(draftOrder(), [], ["MISSING10"]);
  assert.deepEqual(result, [{ code: "MISSING10", reason: "code_not_found" }]);
});

test("code_not_active when promotion status draft", () => {
  const result = classifyPromoRejection(draftOrder(), [promo({ status: "draft" })], ["SAVE10"]);
  assert.deepEqual(result, [{ code: "SAVE10", reason: "code_not_active" }]);
});

test("multiple codes classified independently", () => {
  const order = draftOrder();
  const promotions = [promo({ code: "SAVE10", status: "active" }), promo({ code: "OFF20", status: "draft" })];
  const result = classifyPromoRejection(order, promotions, ["SAVE10", "OFF20", "MISSING"]);
  assert.deepEqual(result, [
    { code: "SAVE10", reason: "ok" },
    { code: "OFF20", reason: "code_not_active" },
    { code: "MISSING", reason: "code_not_found" },
  ]);
});

test("no_active_edit_session checked before code lookup", () => {
  const order = draftOrder({ order_change: null });
  const result = classifyPromoRejection(order, [], ["ANY"]);
  assert.deepEqual(result, [{ code: "ANY", reason: "no_active_edit_session" }]);
});

Case studies

No edit session

The support script that always failed on draft orders

A support team built a small internal tool that reused the same promotion apply call for both storefront carts and draft orders created for phone orders. It worked perfectly on carts and failed every single time on draft orders with "An active Order Change is required to proceed," which the team initially assumed meant the codes had expired.

Reading the draft order's order_change field showed it was always null, since the tool never called POST /admin/draft-orders/:id/edit first. Adding that call before the promotions call, then requesting and confirming the change, fixed every one of these draft orders without touching a single valid promotion.

Code not active

The seasonal code that outlived its campaign

A merchandiser tried to add a holiday promo code to a batch of draft orders being rebuilt from old quotes, and about a third of them failed. At first glance it looked like the same edit session issue, since the error message from the draft order endpoint looked similar either way.

Running the classifier separated the two groups cleanly. Most were the ordinary missing edit session, safely repaired. But the holiday code itself had been switched to disabled once the campaign ended, so those were reported as code_not_active and left untouched, flagged for the merchandiser to decide whether to reactivate the campaign or point those orders at a current code instead.

What good looks like

Run this any time a draft order rejects a promotion code that you know is otherwise valid. It never guesses at pricing and it never activates a promotion on your behalf. It classifies the exact reason, repairs only the safe case of a missing or inactive edit session by opening one the same way the Medusa Admin UI would, and reports every genuinely inactive code for a human to decide on.

FAQ

Why does my Medusa draft order reject a promo code that works on the storefront?

A storefront cart applies a promotion through addPromotionsToCartWorkflow, which has no concept of an edit session. A draft order applies it through addDraftOrderPromotionWorkflow, called via POST /admin/draft-orders/:id/edit/promotions, and that workflow first runs validateDraftOrderChangeStep, which requires an active order_change record with status pending or requested. If you call the promotions endpoint before opening an edit session with POST /admin/draft-orders/:id/edit, Medusa throws An active Order Change is required to proceed, even though the code itself is completely valid.

How do I tell the two different draft order promotion rejection reasons apart?

Fetch the draft order with fields=id,status,is_draft_order,*order_change. If order_change is null, or its canceled_at, confirmed_at, or declined_at is set, the rejection is No active edit session, and the fix is to open one first. Separately, fetch the promotion by code with fields=id,code,status,campaign_id. If its status is not active, the rejection is Code not active, a business decision, not a session problem, and the promotion itself needs to be activated in Medusa Admin.

Is it safe to auto-open an edit session and apply a promo code on a live draft order?

Yes, when the only reason the code is being rejected is the missing edit session, because opening one with POST /admin/draft-orders/:id/edit only creates a pending order_change, it does not change pricing on its own. The unsafe move is forcing a promotion whose status is not active, since that silently overrides a merchant decision about which promotions are live, so the script should flag that case for a human instead of forcing it.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #11915: Bug, Discount code is not recognized by Draft Order. github.com/medusajs/medusa/issues/11915
  2. medusajs/medusa GitHub issue #3332: On draft orders, promo codes cannot be applied, it just loads. github.com/medusajs/medusa/issues/3332
  3. medusajs/medusa pull request #11398: fix(medusa), Fix draft order validator, and endpoint. github.com/medusajs/medusa/pull/11398

On the solution:

  1. Medusa Admin API Reference, draft order edit and promotion endpoints. docs.medusajs.com/api/admin
  2. Medusa Documentation: Draft Orders architecture overview, including edit sessions. docs.medusajs.com/v1/modules/orders/draft-orders
  3. Medusa Documentation: Promotion module concepts, including promotion status. docs.medusajs.com/resources/commerce-modules/promotion/concepts

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 unblock your draft order?

If this saved you a confusing support ticket or a stuck quote, 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