Skip to content

Diagnostic Customer & Auth

Admin invite fails when the email already belongs to a customer

You send the invite through POST /admin/invites, the email arrives, and everything looks fine. Then the person you invited clicks the link, fills in a password, hits accept, and gets a 401 that says the identity with that email already exists. The invite is dead on arrival, and it will stay dead no matter how many times they retry, because the email was already used to register as a customer. Here is why Medusa cannot tell those two identities apart and a script that flags the collision before you ever send the invite.

Python and Node.js Medusa Admin API Flag and block, never auto-fix
A headset near a laptop
Photo by Petr Machacek on Unsplash
The short answer

In Medusa v2, both customer registration and admin invite acceptance ultimately call the same auth provider register method, for example POST /auth/user/emailpass/register, which looks up a single AuthIdentity row by email. When a customer already registered with that email, the identity already exists with app_metadata linking it to a customer actor, and the register flow has no actor_type awareness, so it cannot tell "this email already has a customer identity" apart from "this email needs a new admin identity." It short-circuits with a 401 Identity with email already exists instead of creating a separate identity for the admin actor type. POST /admin/invites succeeds and the email goes out, but the invitee's POST /admin/invites/accept fails at that same register step, forever, for that email. There is no safe API fix, only detection. Before sending an invite, check GET /admin/customers?email=...&fields=id,email,has_account: if has_account is true, the invite will fail at accept time and you should use a different email instead. Full code and tests below.

The problem in plain words

Medusa keeps authentication in one table, the AuthIdentity, and it is keyed by the auth provider and the email. A customer who registers through the storefront gets a row there, and Medusa attaches app_metadata that says this identity belongs to a customer actor. That is the whole account, one row, one actor link.

An admin invite is a separate concept on paper. You call POST /admin/invites with an email and a role, Medusa creates an invite record and emails a token. But accepting that invite still has to create a login, and Medusa creates admin logins the exact same way it creates customer logins: it calls the auth provider's register method for that email. The register method does one thing, look up the email, and if a row is already there, refuse. It never asks which actor type the existing row belongs to. So the moment a customer has already claimed an email, any later admin invite to that same email is set up to fail, and nothing about sending the invite warns you.

Customer registers jane@example.com AuthIdentity row actor_type: customer POST /admin/invites same email, looks fine Invite accept calls register(), no actor check 401 Identity with email already exists
The customer identity was created first and claimed the email. The admin invite sends fine, but accepting it hits the same register call, finds the row, and refuses, permanently, for that email.

Why it happens

This is a gap in how Medusa v2's auth flows share one lookup, not a bug in any single endpoint:

This is a common source of confusion because POST /admin/invites returns 200 and the email genuinely sends. Everything about the invite creation step looks successful. The failure only shows up one step later, on the invitee's machine, at accept time, which makes it look like a client-side or email problem rather than what it really is: an email that was never available to invite as an admin in the first place. See the citations at the end for the exact issues and docs.

The key insight

You cannot ask Medusa's admin API directly whether an email already has a customer or admin AuthIdentity, because there is no exposed auth-identity or actor_type listing route. So detection has to use a reliable proxy: GET /admin/customers?email=...&fields=id,email,has_account. A has_account: true hit means that email owns a customer identity, and any admin invite to it is guaranteed to fail at accept time with the exact same 401. Check that before you call POST /admin/invites, not after the invitee complains.

The fix, as a flow

We do not try to make Medusa merge or delete identities, because there is no safe way to do that without risking a real customer's account. Instead we check the target email against customers, admin users, and pending invites before the invite is ever created, and block the invite outright when a collision is found.

Target email to invite as admin Check customers, users, and pending invites Collision found? yes, block Report and stop no invite created no, safe POST /admin/invites only when email is clear
The script never mutates an existing identity. It only decides whether the invite is safe to send, and blocks it before creation when the email already belongs to a customer, an admin, or a pending invite.

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 customers, users, and invites. 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 DRY_RUN=true so the script only reports, it never sends an invite on your behalf.

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 TARGET_EMAIL="jane@example.com"   # the email you want to invite as admin
export INVITE_ROLE="admin"
export DRY_RUN="true"   # start safe, only reports the collision check
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 TARGET_EMAIL="jane@example.com"   // the email you want to invite as admin
export INVITE_ROLE="admin"
export DRY_RUN="true"   // start safe, only reports the collision check
2

Authenticate against the Admin API

Exchange the admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every following call. Both languages do this the same way.

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

Look up customers, admin users, and pending invites

There is no direct auth-identity or actor_type listing route, so we use has_account on /admin/customers as the reliable proxy signal. We also check /admin/users for an existing admin on that email, and /admin/invites for a pending, unaccepted invite already sitting there.

step3.py
def find_customers(token, email):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/customers",
        params={"email": email, "fields": "id,email,has_account"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["customers"]


def find_admin_users(token, email):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/users",
        params={"email": email, "fields": "id,email"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["users"]


def list_pending_invites(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/invites",
        params={"fields": "id,email,accepted,expires_at"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["invites"]
step3.js
async function apiGet(token, path, params) {
  const url = new URL(`${BASE_URL}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
  return res.json();
}

async function findCustomers(token, email) {
  const body = await apiGet(token, "/admin/customers", { email, fields: "id,email,has_account" });
  return body.customers;
}

async function findAdminUsers(token, email) {
  const body = await apiGet(token, "/admin/users", { email, fields: "id,email" });
  return body.users;
}

async function listPendingInvites(token) {
  const body = await apiGet(token, "/admin/invites", { fields: "id,email,accepted,expires_at" });
  return body.invites;
}
4

Decide, with one pure function

Keep the decision in a function with no network calls. It normalizes the target email, then checks it against the three lists in order, a matching customer with has_account: true first, then a matching admin user, then a pending, unaccepted invite. Anything found there means an admin invite to that email is guaranteed to fail at accept time, so we say so and stop before creation.

decide.py
def will_invite_collide(target_email, customers, admin_users, pending_invites):
    email = target_email.strip().lower()

    for customer in customers:
        if customer.get("email", "").strip().lower() == email and customer.get("has_account") is True:
            return {"safe": False, "reason": "customer_account_exists"}

    for admin_user in admin_users:
        if admin_user.get("email", "").strip().lower() == email:
            return {"safe": False, "reason": "admin_user_exists"}

    for invite in pending_invites:
        if invite.get("email", "").strip().lower() == email and invite.get("accepted") is not True:
            return {"safe": False, "reason": "invite_pending"}

    return {"safe": True, "reason": "ok"}
decide.js
export function willInviteCollide(targetEmail, customers, adminUsers, pendingInvites) {
  const email = targetEmail.trim().toLowerCase();

  const customerHit = customers.find(
    (c) => (c.email || "").trim().toLowerCase() === email && c.has_account === true
  );
  if (customerHit) return { safe: false, reason: "customer_account_exists" };

  const adminHit = adminUsers.find((u) => (u.email || "").trim().toLowerCase() === email);
  if (adminHit) return { safe: false, reason: "admin_user_exists" };

  const inviteHit = pendingInvites.find(
    (i) => (i.email || "").trim().toLowerCase() === email && i.accepted !== true
  );
  if (inviteHit) return { safe: false, reason: "invite_pending" };

  return { safe: true, reason: "ok" };
}
5

Only create the invite when the check is clear

When willInviteCollide comes back safe, call POST /admin/invites with the email and role, the same way you would by hand. When it is not safe, never call that endpoint at all, report the reason, and point the operator at the documented workaround: invite a different email address. Never touch the existing customer or admin identity.

apply.py
def create_invite(token, email, role):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/invites",
        json={"email": email, "role": role},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["invite"]
apply.js
async function createInvite(token, email, role) {
  const res = await fetch(`${BASE_URL}/admin/invites`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ email, role }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.invite;
}
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 whether the target email would collide and why. Read the output, and if it says ok, switch DRY_RUN off to let it actually call POST /admin/invites. If it flags a collision, do not force it, invite a different email instead.

Run it safe

Never delete, edit, or otherwise force a customer's existing AuthIdentity to free up an email for an admin invite. That risks locking a real storefront customer out of their own account. Treat a collision purely as flag and block. The only supported repair is to invite a different email address for the admin user.

The full code

Here is the complete script in one file for each language. It authenticates, checks the target email against customers, admin users, and pending invites with a pure decision function, and either blocks the invite and reports the reason, or creates it when the check is clear, all gated by DRY_RUN.

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.
check_invite_collision.py
"""Flag a Medusa v2 admin invite that will fail at accept time because the
target email already has a customer AuthIdentity. Both customer registration
and invite acceptance call the same auth provider register method, which is
keyed only on email with no actor_type awareness, so a customer identity on
that email guarantees a 401 Identity with email already exists when the
invite is accepted. This never mutates an existing identity. DRY_RUN=true
only reports the collision check. 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("check_invite_collision")

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")
TARGET_EMAIL = os.environ.get("TARGET_EMAIL", "jane@example.com")
INVITE_ROLE = os.environ.get("INVITE_ROLE", "admin")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


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 find_customers(token, email):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/customers",
        params={"email": email, "fields": "id,email,has_account"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["customers"]


def find_admin_users(token, email):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/users",
        params={"email": email, "fields": "id,email"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["users"]


def list_pending_invites(token):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.get(
        f"{BASE_URL}/admin/invites",
        params={"fields": "id,email,accepted,expires_at"},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["invites"]


def will_invite_collide(target_email, customers, admin_users, pending_invites):
    """Pure: no I/O. Returns {"safe": bool, "reason": str}."""
    email = target_email.strip().lower()

    for customer in customers:
        if customer.get("email", "").strip().lower() == email and customer.get("has_account") is True:
            return {"safe": False, "reason": "customer_account_exists"}

    for admin_user in admin_users:
        if admin_user.get("email", "").strip().lower() == email:
            return {"safe": False, "reason": "admin_user_exists"}

    for invite in pending_invites:
        if invite.get("email", "").strip().lower() == email and invite.get("accepted") is not True:
            return {"safe": False, "reason": "invite_pending"}

    return {"safe": True, "reason": "ok"}


def create_invite(token, email, role):
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(
        f"{BASE_URL}/admin/invites",
        json={"email": email, "role": role},
        headers=headers,
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["invite"]


def run():
    token = get_token()
    customers = find_customers(token, TARGET_EMAIL)
    admin_users = find_admin_users(token, TARGET_EMAIL)
    pending_invites = list_pending_invites(token)

    decision = will_invite_collide(TARGET_EMAIL, customers, admin_users, pending_invites)

    if not decision["safe"]:
        log.warning(
            "Blocked: invite to %s would collide (%s). Invite a different email instead.",
            TARGET_EMAIL, decision["reason"],
        )
        return

    log.info("Email %s is clear. %s", TARGET_EMAIL, "would create invite" if DRY_RUN else "creating invite")
    if not DRY_RUN:
        invite = create_invite(token, TARGET_EMAIL, INVITE_ROLE)
        log.info("Invite created: %s", invite["id"])


if __name__ == "__main__":
    run()
check-invite-collision.js
/**
 * Flag a Medusa v2 admin invite that will fail at accept time because the
 * target email already has a customer AuthIdentity. Both customer registration
 * and invite acceptance call the same auth provider register method, which is
 * keyed only on email with no actor_type awareness, so a customer identity on
 * that email guarantees a 401 Identity with email already exists when the
 * invite is accepted. This never mutates an existing identity. DRY_RUN=true
 * only reports the collision check. 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 TARGET_EMAIL = process.env.TARGET_EMAIL || "jane@example.com";
const INVITE_ROLE = process.env.INVITE_ROLE || "admin";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function willInviteCollide(targetEmail, customers, adminUsers, pendingInvites) {
  // Pure: no I/O. Returns { safe, reason }.
  const email = targetEmail.trim().toLowerCase();

  const customerHit = customers.find(
    (c) => (c.email || "").trim().toLowerCase() === email && c.has_account === true
  );
  if (customerHit) return { safe: false, reason: "customer_account_exists" };

  const adminHit = adminUsers.find((u) => (u.email || "").trim().toLowerCase() === email);
  if (adminHit) return { safe: false, reason: "admin_user_exists" };

  const inviteHit = pendingInvites.find(
    (i) => (i.email || "").trim().toLowerCase() === email && i.accepted !== true
  );
  if (inviteHit) return { safe: false, reason: "invite_pending" };

  return { safe: true, 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 apiGet(token, path, params) {
  const url = new URL(`${BASE_URL}${path}`);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  if (!res.ok) throw new Error(`Medusa ${path} ${res.status}`);
  return res.json();
}

async function findCustomers(token, email) {
  const body = await apiGet(token, "/admin/customers", { email, fields: "id,email,has_account" });
  return body.customers;
}

async function findAdminUsers(token, email) {
  const body = await apiGet(token, "/admin/users", { email, fields: "id,email" });
  return body.users;
}

async function listPendingInvites(token) {
  const body = await apiGet(token, "/admin/invites", { fields: "id,email,accepted,expires_at" });
  return body.invites;
}

async function createInvite(token, email, role) {
  const res = await fetch(`${BASE_URL}/admin/invites`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ email, role }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.invite;
}

export async function run() {
  const token = await getToken();
  const [customers, adminUsers, pendingInvites] = await Promise.all([
    findCustomers(token, TARGET_EMAIL),
    findAdminUsers(token, TARGET_EMAIL),
    listPendingInvites(token),
  ]);

  const decision = willInviteCollide(TARGET_EMAIL, customers, adminUsers, pendingInvites);

  if (!decision.safe) {
    console.warn(
      `Blocked: invite to ${TARGET_EMAIL} would collide (${decision.reason}). Invite a different email instead.`
    );
    return;
  }

  console.log(`Email ${TARGET_EMAIL} is clear. ${DRY_RUN ? "would create invite" : "creating invite"}`);
  if (!DRY_RUN) {
    const invite = await createInvite(token, TARGET_EMAIL, INVITE_ROLE);
    console.log(`Invite created: ${invite.id}`);
  }
}

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, will_invite_collide. It is pure, no network and no Medusa instance, just plain arrays in and a decision out, so the tests feed in fixture customers, admin users, and invites and check the answer.

test_admin_invite_collision.py
from check_invite_collision import will_invite_collide


def test_ok_when_no_matches_anywhere():
    result = will_invite_collide("new@example.com", [], [], [])
    assert result == {"safe": True, "reason": "ok"}


def test_blocked_when_customer_has_account():
    customers = [{"email": "jane@example.com", "has_account": True}]
    result = will_invite_collide("jane@example.com", customers, [], [])
    assert result == {"safe": False, "reason": "customer_account_exists"}


def test_not_blocked_when_customer_has_no_account():
    customers = [{"email": "jane@example.com", "has_account": False}]
    result = will_invite_collide("jane@example.com", customers, [], [])
    assert result == {"safe": True, "reason": "ok"}


def test_blocked_when_admin_user_already_exists():
    admin_users = [{"email": "jane@example.com"}]
    result = will_invite_collide("jane@example.com", [], admin_users, [])
    assert result == {"safe": False, "reason": "admin_user_exists"}


def test_blocked_when_invite_already_pending():
    invites = [{"email": "jane@example.com", "accepted": False}]
    result = will_invite_collide("jane@example.com", [], [], invites)
    assert result == {"safe": False, "reason": "invite_pending"}


def test_not_blocked_when_invite_already_accepted():
    invites = [{"email": "jane@example.com", "accepted": True}]
    result = will_invite_collide("jane@example.com", [], [], invites)
    assert result == {"safe": True, "reason": "ok"}


def test_normalizes_case_and_whitespace():
    customers = [{"email": "Jane@Example.com", "has_account": True}]
    result = will_invite_collide("  jane@example.com  ", customers, [], [])
    assert result == {"safe": False, "reason": "customer_account_exists"}


def test_customer_check_wins_over_other_reasons():
    customers = [{"email": "jane@example.com", "has_account": True}]
    admin_users = [{"email": "jane@example.com"}]
    result = will_invite_collide("jane@example.com", customers, admin_users, [])
    assert result["reason"] == "customer_account_exists"
collision.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { willInviteCollide } from "./check-invite-collision.js";

test("ok when no matches anywhere", () => {
  const result = willInviteCollide("new@example.com", [], [], []);
  assert.deepEqual(result, { safe: true, reason: "ok" });
});

test("blocked when customer has account", () => {
  const customers = [{ email: "jane@example.com", has_account: true }];
  const result = willInviteCollide("jane@example.com", customers, [], []);
  assert.deepEqual(result, { safe: false, reason: "customer_account_exists" });
});

test("not blocked when customer has no account", () => {
  const customers = [{ email: "jane@example.com", has_account: false }];
  const result = willInviteCollide("jane@example.com", customers, [], []);
  assert.deepEqual(result, { safe: true, reason: "ok" });
});

test("blocked when admin user already exists", () => {
  const adminUsers = [{ email: "jane@example.com" }];
  const result = willInviteCollide("jane@example.com", [], adminUsers, []);
  assert.deepEqual(result, { safe: false, reason: "admin_user_exists" });
});

test("blocked when invite already pending", () => {
  const invites = [{ email: "jane@example.com", accepted: false }];
  const result = willInviteCollide("jane@example.com", [], [], invites);
  assert.deepEqual(result, { safe: false, reason: "invite_pending" });
});

test("not blocked when invite already accepted", () => {
  const invites = [{ email: "jane@example.com", accepted: true }];
  const result = willInviteCollide("jane@example.com", [], [], invites);
  assert.deepEqual(result, { safe: true, reason: "ok" });
});

test("normalizes case and whitespace", () => {
  const customers = [{ email: "Jane@Example.com", has_account: true }];
  const result = willInviteCollide("  jane@example.com  ", customers, [], []);
  assert.deepEqual(result, { safe: false, reason: "customer_account_exists" });
});

test("customer check wins over other reasons", () => {
  const customers = [{ email: "jane@example.com", has_account: true }];
  const adminUsers = [{ email: "jane@example.com" }];
  const result = willInviteCollide("jane@example.com", customers, adminUsers, []);
  assert.equal(result.reason, "customer_account_exists");
});

Case studies

Support handoff

The support lead who was already a customer

A store promoted its most active customer support volunteer to an admin role. The invite went out through POST /admin/invites, HR confirmed the email in the inbox, and everyone assumed it was done. The volunteer clicked accept, set a password, and got a 401 that made no sense to anyone on the team, because nothing in the invite flow had ever complained.

Running the collision check against that email showed customer_account_exists, tied to the account they had used for years to place orders. The team invited their work email instead, which was clear on every check, and the admin role landed in minutes instead of another afternoon of confused back and forth with support.

Bulk onboarding

The agency onboarding a batch of client admins

An agency running Medusa storefronts for clients invited a batch of ten client contacts as admins in one sitting. Two of those emails had, months earlier, been used to place a test order on the storefront during a demo, quietly creating customer identities nobody remembered.

Running the check across the whole batch before sending anything flagged those two immediately with customer_account_exists, while the other eight came back ok. The agency invited alias addresses for those two contacts instead of guessing why two invites out of ten would fail later, and no invite went out that was destined to fail.

What good looks like

Run this check before every admin invite, not after a confused support ticket. It never touches an existing AuthIdentity and never guesses at a merge, it only tells you, using the same proxy signal Medusa itself would eventually fail on, whether the email is safe to invite. When it says ok, send the invite exactly as you always have. When it flags a collision, the fix is always the same and always safe: use a different email address for the admin account, and leave the existing customer alone.

FAQ

Why does accepting a Medusa admin invite fail with Identity with email already exists?

Both customer registration and admin invite acceptance call the same auth provider register method, which looks up a single AuthIdentity row by email. If that email already registered as a customer, the AuthIdentity already exists with app_metadata pointing to a customer actor, and the register flow has no actor_type awareness, so it cannot tell an admin identity apart from the existing customer one. It returns a 401 instead of creating a separate admin identity, so the invite was sent but can never be accepted.

How do I detect that an admin invite will fail before I send it?

Medusa's admin API has no direct route to list auth identities by actor_type, so check the reliable proxy signal instead. Call GET /admin/customers with the target email and fields=id,email,has_account. If has_account is true, that email already owns a customer AuthIdentity and any admin invite to it will fail at accept time. Also check GET /admin/users for an existing admin, and GET /admin/invites for a pending unaccepted invite to the same email.

Can I fix the collision by deleting the customer's auth identity to free up the email?

No, that is not a safe API-level fix. Medusa does not support one email owning both a customer and an admin AuthIdentity in the affected versions, and forcing a delete or edit of the customer's identity risks locking a real storefront customer out of their account. The documented workaround is to invite a different email address for the admin user, never to mutate the existing customer identity.

Related field notes

Citations

On the problem:

  1. medusajs/medusa GitHub issue #12521: Admin user invitation fails if email is already used by a customer (Identity with email already exists). github.com/medusajs/medusa/issues/12521
  2. medusajs/medusa GitHub issue #10607: Can not register a customer which already has an admin account, and vice versa, with the same authentication provider. github.com/medusajs/medusa/issues/10607
  3. Medusa Documentation: Customer Accounts. docs.medusajs.com/resources/commerce-modules/customer/customer-accounts

On the solution:

  1. Medusa Documentation: Authentication Flows with the Auth Module. docs.medusajs.com/resources/commerce-modules/auth/auth-flows
  2. Medusa Documentation: Manage Invites in Medusa Admin. docs.medusajs.com/modules/users/admin/manage-invites
  3. Medusa Documentation: Admin API Reference. docs.medusajs.com/api/admin

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 save you a dead-on-arrival invite?

If this saved you from a confusing support ticket or a wasted afternoon chasing a 401 that made no sense, 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