Repair Cart and checkout

Cart completion fails, no payment provider

The cart looks complete. Line items are priced, the shipping option is picked, the region matches the customer's country. Then the last click, the one that turns a cart into an order, fails with something like no payment provider available for this region. Here is why Medusa can let a cart get all the way to the final step and only then discover there is nothing to actually take the payment with, and a small script that finds every region missing a working payment provider before a real customer does.

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

In Medusa v2, completing a cart creates or reuses a payment_collection and asks the region's linked payment providers to open a payment session. A payment provider only shows up for a region when three things line up: the provider's module is registered in medusa-config, the provider is explicitly linked to that region through the Admin, and the provider's own credentials are valid so it can actually initiate a session. A merchant can set up a region with the right currency and countries and never link a payment provider to it, or link one whose module was never registered, or link one whose API keys are missing. In every case the cart builds fine and only fails at the very last step, when checkout tries to complete. Run a small Python or Node.js script that lists every region, cross-checks its payment providers against what is actually registered and reachable, and reports each gap. Full code, tests, and a dry run guard are below.

The problem in plain words

A Medusa v2 Region decides currency, countries, and tax behavior for checkout. It does not automatically come with a way to pay. Payment providers such as Stripe or a manual payment provider are separate modules that have to be registered once in medusa-config for the whole backend, and then linked to whichever regions should offer them, one region at a time, in the Admin.

Everything before payment works without that link. The storefront can add items, resolve a price in the region's currency, pick a shipping option, and even start a payment_collection on the cart. Only when checkout calls to initiate a payment session does Medusa look at which providers are actually available for that region. If the answer is none, or the one that is linked cannot start a session because its module never loaded or its keys are wrong, cart completion fails right there, after the customer has already done all the work of filling the cart.

Cart builds fine price, shipping, region Complete cart start payment session no provider for region Completion fails region and price were fine No order gets created
The region looks correct all the way through pricing and shipping. Payment provider coverage is decided separately, and no one confirmed it was actually there.

Why it happens

Payment provider registration and region linking are two separate steps in Medusa v2, and nothing forces them to be checked together after the initial setup. A few common ways stores end up with a cart that cannot complete:

This is a known seam between setup steps that Medusa itself does not cross-check for you. GitHub issue #7369 shows checkout failing with no payment providers available even though the region and store both look properly configured, and issue #6980 shows a payment session failing to initiate once a provider is linked but its own credentials are not valid. See the citations at the end for the exact issues and docs.

The key insight

A region missing payment coverage is not one bug, it is three different gaps that look identical from the storefront. The region can have no payment provider linked at all, a provider linked but never registered in medusa-config, or a provider registered and linked but unable to authenticate. Fixing this is not a guess, it is a decision about which gateway to use in which market and whose account credentials back it, so the safe pattern is to detect and report each layer separately and let a human decide what to link or fix.

The fix, as a flow

We do not link payment providers automatically. We add a check that lists every region, lists every payment provider actually registered and enabled on the backend, and diffs the two. A region with zero linked providers is flagged. A region whose only linked providers are not in the registered, enabled set is flagged with a different reason, so the report tells you exactly which layer broke.

List regions GET /admin/regions List payment providers registered and enabled Diff region links against registered set Region has a working provider? yes no, report gap provider works omitted from report
The script only reports a gap when a region has no linked payment provider at all, or every linked provider is not actually registered and enabled. A region with a working provider is left alone.

Build it step by step

1

Get an admin token

Exchange an admin email and password for a JWT at POST /auth/user/emailpass, then send it as Authorization: Bearer <token> on every /admin/* call. Keep the backend URL and credentials in environment variables, never in the file.

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 to write
setup (shell)
npm install @medusajs/js-sdk

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, change to false to write
2

List regions with their linked payment providers

Ask for regions with payment providers expanded. Each region has an id, a currency, and the set of payment providers linked to it through the Admin. A region with an empty list here has never had a provider linked at all.

step2.py
import os, requests

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]

def get_regions(token):
    r = requests.get(
        f"{BACKEND_URL}/admin/regions",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,name,currency_code,*payment_providers"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["regions"]
step2.js
import Medusa from "@medusajs/js-sdk";

const sdk = new Medusa({
  baseUrl: process.env.MEDUSA_BACKEND_URL,
  auth: { type: "jwt" },
});

async function login() {
  return sdk.auth.login("user", "emailpass", {
    email: process.env.MEDUSA_ADMIN_EMAIL,
    password: process.env.MEDUSA_ADMIN_PASSWORD,
  });
}

async function getRegions() {
  const { regions } = await sdk.admin.region.list({
    fields: "id,name,currency_code,*payment_providers",
  });
  return regions;
}
3

List the payment providers actually registered and enabled

Ask the payment providers endpoint for the providers the backend currently reports as enabled. This reflects what is really registered in medusa-config and reachable right now, which is not always the same as what a region's link table says.

step3.py
def get_enabled_provider_ids(token, region_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/payment-providers",
        headers={"Authorization": f"Bearer {token}"},
        params={"region_id": region_id},
        timeout=30,
    )
    r.raise_for_status()
    return {p["id"] for p in r.json().get("payment_providers", [])}
step3.js
async function getEnabledProviderIds(regionId) {
  const { payment_providers } = await sdk.admin.paymentProvider.list({
    region_id: regionId,
  });
  return new Set(payment_providers.map((p) => p.id));
}
4

Decide, with one pure function

Keep the decision in its own function that takes a region's linked provider ids and the set of ids the backend actually reports as enabled for that region, and returns the gap or nothing. If the region has no linked providers, the reason is no_provider_linked. If it has linked providers but none of them are in the enabled set, the reason is linked_provider_not_enabled, which covers both an unregistered module and a provider whose own credentials failed. Everything else is covered and left out of the result.

decide.py
def find_regions_without_working_payment(regions_with_enabled):
    """regions_with_enabled: [{"id": str, "name": str, "linkedProviderIds": [str],
                                "enabledProviderIds": [str]}, ...]"""
    results = []
    for region in regions_with_enabled:
        linked = region.get("linkedProviderIds") or []
        enabled = set(region.get("enabledProviderIds") or [])

        if not linked:
            results.append({
                "regionId": region["id"],
                "regionName": region.get("name"),
                "reason": "no_provider_linked",
            })
            continue

        if not any(pid in enabled for pid in linked):
            results.append({
                "regionId": region["id"],
                "regionName": region.get("name"),
                "reason": "linked_provider_not_enabled",
            })
    return results
decide.js
export function findRegionsWithoutWorkingPayment(regionsWithEnabled) {
  const results = [];
  for (const region of regionsWithEnabled) {
    const linked = region.linkedProviderIds || [];
    const enabled = new Set(region.enabledProviderIds || []);

    if (linked.length === 0) {
      results.push({ regionId: region.id, regionName: region.name, reason: "no_provider_linked" });
      continue;
    }

    if (!linked.some((pid) => enabled.has(pid))) {
      results.push({ regionId: region.id, regionName: region.name, reason: "linked_provider_not_enabled" });
    }
  }
  return results;
}
5

Report the gaps, one line per region

Turn each gap into a readable line: which region, and why. This is the deliverable for this issue. Linking a new provider, fixing an environment variable, or registering a module in medusa-config is a deploy-time change left to the operator, covered next as a guarded, opt in fix.

report.py
def log_gaps(gaps, log):
    if not gaps:
        log.info("No gaps found. Every region has at least one working payment provider.")
        return
    for gap in gaps:
        log.info(
            "Gap: region=%s (%s) reason=%s",
            gap["regionName"], gap["regionId"], gap["reason"],
        )
    log.info("Done. %d region(s) missing a working payment provider.", len(gaps))
report.js
function logGaps(gaps) {
  if (gaps.length === 0) {
    console.log("No gaps found. Every region has at least one working payment provider.");
    return;
  }
  for (const gap of gaps) {
    console.log(`Gap: region=${gap.regionName} (${gap.regionId}) reason=${gap.reason}`);
  }
  console.log(`Done. ${gaps.length} region(s) missing a working payment provider.`);
}
6

Wire it together with a dry run guard

The run loop fetches regions, fetches the enabled providers per region, computes the gaps with the pure function, and logs the report. This tool never writes by default. If DRY_RUN is turned off and a target provider is explicitly configured through environment variables, it prints the exact POST /admin/regions/{id} call it would make to link the provider, then only executes it when asked, and re-verifies by re-listing the region's payment providers before it ever reports success.

Run it safe

This script is report only unless you explicitly opt in to the repair path. Linking a payment provider to a region puts a live gateway in front of real customers, so never let a script pick which gateway to enable or invent credentials. Always start with DRY_RUN=true.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs every gap it finds, and only prints the planned write call for a repair, never executing it unless dry run is explicitly turned off with a target already chosen by a human.

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

find_regions_without_payment.py
"""Find Medusa regions that cannot complete a cart because of a payment provider gap, safely.

Completing a cart creates or reuses a payment_collection and asks the region's
linked payment providers to open a payment session. A provider only shows up
for a region when it is registered in medusa-config, linked to the region in
the Admin, and able to authenticate with its own credentials. A merchant can
set up a region with the right currency and countries and never link a
payment provider, or link one that is not actually registered, or link one
whose credentials are invalid. In every case the cart builds fine and only
fails when checkout tries to complete.

This script reports every region missing a working payment provider. It does
not link a payment provider automatically, since that is a business and
compliance decision tied to currency, licensing, and the merchant's account
with that provider. Run once, or on a schedule.

Guide: https://www.allanninal.dev/medusa/cart-completion-fails-no-payment-provider/
"""
import os
import logging
import requests

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

BACKEND_URL = os.environ["MEDUSA_BACKEND_URL"]
ADMIN_EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
ADMIN_PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

# Only used if an operator explicitly opts into the guarded repair path.
TARGET_PROVIDER_ID = os.environ.get("TARGET_PROVIDER_ID", "").strip() or None


def get_admin_token():
    r = requests.post(
        f"{BACKEND_URL}/auth/user/emailpass",
        json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def get_regions(token):
    r = requests.get(
        f"{BACKEND_URL}/admin/regions",
        headers={"Authorization": f"Bearer {token}"},
        params={"fields": "id,name,currency_code,*payment_providers"},
        timeout=30,
    )
    r.raise_for_status()
    regions = []
    for region in r.json()["regions"]:
        regions.append({
            "id": region["id"],
            "name": region.get("name"),
            "linkedProviderIds": [p["id"] for p in region.get("payment_providers", []) or []],
        })
    return regions


def get_enabled_provider_ids(token, region_id):
    r = requests.get(
        f"{BACKEND_URL}/admin/payment-providers",
        headers={"Authorization": f"Bearer {token}"},
        params={"region_id": region_id},
        timeout=30,
    )
    r.raise_for_status()
    return {p["id"] for p in r.json().get("payment_providers", [])}


def find_regions_without_working_payment(regions_with_enabled):
    """Pure decision function. No I/O.

    regions_with_enabled: [{"id": str, "name": str, "linkedProviderIds": [str],
                             "enabledProviderIds": [str]}, ...]

    Returns a list of {"regionId": str, "regionName": str, "reason": str} for
    every region that has no linked payment provider, or whose linked
    providers are all missing from the enabled set. Covered regions are
    omitted.
    """
    results = []
    for region in regions_with_enabled:
        linked = region.get("linkedProviderIds") or []
        enabled = set(region.get("enabledProviderIds") or [])

        if not linked:
            results.append({
                "regionId": region["id"],
                "regionName": region.get("name"),
                "reason": "no_provider_linked",
            })
            continue

        if not any(pid in enabled for pid in linked):
            results.append({
                "regionId": region["id"],
                "regionName": region.get("name"),
                "reason": "linked_provider_not_enabled",
            })
    return results


def print_planned_repair(gap):
    log.info(
        "  [DRY RUN] would POST /admin/regions/%s {payment_providers: ['%s']}",
        gap["regionId"], TARGET_PROVIDER_ID,
    )


def apply_repair(token, gap):
    r = requests.post(
        f"{BACKEND_URL}/admin/regions/{gap['regionId']}",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json={"payment_providers": [TARGET_PROVIDER_ID]},
        timeout=30,
    )
    r.raise_for_status()


def verify_region_has_provider(token, region_id, provider_id):
    enabled = get_enabled_provider_ids(token, region_id)
    return provider_id in enabled


def run():
    token = get_admin_token()
    regions = get_regions(token)

    regions_with_enabled = []
    for region in regions:
        enabled_ids = get_enabled_provider_ids(token, region["id"])
        regions_with_enabled.append({**region, "enabledProviderIds": list(enabled_ids)})

    gaps = find_regions_without_working_payment(regions_with_enabled)

    if not gaps:
        log.info("No gaps found. Every region has at least one working payment provider.")
        return

    for gap in gaps:
        log.info(
            "Gap: region=%s (%s) reason=%s",
            gap["regionName"], gap["regionId"], gap["reason"],
        )
        if TARGET_PROVIDER_ID:
            print_planned_repair(gap)
            if not DRY_RUN:
                apply_repair(token, gap)
                ok = verify_region_has_provider(token, gap["regionId"], TARGET_PROVIDER_ID)
                log.info("  Applied. Re-verified provider is enabled: %s", ok)

    log.info("Done. %d region(s) missing a working payment provider.", len(gaps))


if __name__ == "__main__":
    run()
find-regions-without-payment.js
/**
 * Find Medusa regions that cannot complete a cart because of a payment provider gap, safely.
 *
 * Completing a cart creates or reuses a payment_collection and asks the region's
 * linked payment providers to open a payment session. A provider only shows up
 * for a region when it is registered in medusa-config, linked to the region in
 * the Admin, and able to authenticate with its own credentials. A merchant can
 * set up a region with the right currency and countries and never link a
 * payment provider, or link one that is not actually registered, or link one
 * whose credentials are invalid. In every case the cart builds fine and only
 * fails when checkout tries to complete.
 *
 * This script reports every region missing a working payment provider. It does
 * not link a payment provider automatically, since that is a business and
 * compliance decision tied to currency, licensing, and the merchant's account
 * with that provider. Run once, or on a schedule.
 *
 * Guide: https://www.allanninal.dev/medusa/cart-completion-fails-no-payment-provider/
 */
import { pathToFileURL } from "node:url";

const BACKEND_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const ADMIN_EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const ADMIN_PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

// Only used if an operator explicitly opts into the guarded repair path.
const TARGET_PROVIDER_ID = (process.env.TARGET_PROVIDER_ID || "").trim() || null;

export function findRegionsWithoutWorkingPayment(regionsWithEnabled) {
  const results = [];
  for (const region of regionsWithEnabled) {
    const linked = region.linkedProviderIds || [];
    const enabled = new Set(region.enabledProviderIds || []);

    if (linked.length === 0) {
      results.push({ regionId: region.id, regionName: region.name, reason: "no_provider_linked" });
      continue;
    }

    if (!linked.some((pid) => enabled.has(pid))) {
      results.push({ regionId: region.id, regionName: region.name, reason: "linked_provider_not_enabled" });
    }
  }
  return results;
}

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

async function getRegions(sdk) {
  const { regions } = await sdk.admin.region.list({
    fields: "id,name,currency_code,*payment_providers",
  });
  return regions.map((region) => ({
    id: region.id,
    name: region.name,
    linkedProviderIds: (region.payment_providers || []).map((p) => p.id),
  }));
}

async function getEnabledProviderIds(sdk, regionId) {
  const { payment_providers } = await sdk.admin.paymentProvider.list({ region_id: regionId });
  return new Set(payment_providers.map((p) => p.id));
}

function printPlannedRepair(gap) {
  console.log(`  [DRY RUN] would POST /admin/regions/${gap.regionId} {payment_providers: ["${TARGET_PROVIDER_ID}"]}`);
}

async function applyRepair(sdk, gap) {
  return sdk.admin.region.update(gap.regionId, {
    payment_providers: [TARGET_PROVIDER_ID],
  });
}

export async function run() {
  const sdk = await getSdk();
  const regions = await getRegions(sdk);

  const regionsWithEnabled = [];
  for (const region of regions) {
    const enabledIds = await getEnabledProviderIds(sdk, region.id);
    regionsWithEnabled.push({ ...region, enabledProviderIds: [...enabledIds] });
  }

  const gaps = findRegionsWithoutWorkingPayment(regionsWithEnabled);

  if (gaps.length === 0) {
    console.log("No gaps found. Every region has at least one working payment provider.");
    return;
  }

  for (const gap of gaps) {
    console.log(`Gap: region=${gap.regionName} (${gap.regionId}) reason=${gap.reason}`);
    if (TARGET_PROVIDER_ID) {
      printPlannedRepair(gap);
      if (!DRY_RUN) {
        await applyRepair(sdk, gap);
        const enabledIds = await getEnabledProviderIds(sdk, gap.regionId);
        console.log(`  Applied. Re-verified provider is enabled: ${enabledIds.has(TARGET_PROVIDER_ID)}`);
      }
    }
  }

  console.log(`Done. ${gaps.length} region(s) missing a working payment provider.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides which region silently cannot take money. Because we kept find_regions_without_working_payment pure, the test needs no network and no Medusa backend. It just feeds in plain arrays and objects and checks the answer.

test_cart_completion.py
from find_regions_without_payment import find_regions_without_working_payment


def region(**over):
    base = {
        "id": "reg_1",
        "name": "Europe",
        "linkedProviderIds": ["pp_stripe_stripe"],
        "enabledProviderIds": ["pp_stripe_stripe"],
    }
    base.update(over)
    return base


def test_region_with_working_provider_is_covered():
    gaps = find_regions_without_working_payment([region()])
    assert gaps == []


def test_region_with_no_linked_provider_is_reported():
    gaps = find_regions_without_working_payment([region(linkedProviderIds=[])])
    assert gaps == [{"regionId": "reg_1", "regionName": "Europe", "reason": "no_provider_linked"}]


def test_region_with_linked_provider_not_enabled_is_reported():
    gaps = find_regions_without_working_payment(
        [region(linkedProviderIds=["pp_stripe_stripe"], enabledProviderIds=[])]
    )
    assert gaps == [{"regionId": "reg_1", "regionName": "Europe", "reason": "linked_provider_not_enabled"}]


def test_region_with_one_of_several_providers_working_is_covered():
    gaps = find_regions_without_working_payment(
        [region(linkedProviderIds=["pp_stripe_stripe", "pp_manual_manual"], enabledProviderIds=["pp_manual_manual"])]
    )
    assert gaps == []


def test_multiple_regions_each_get_their_own_verdict():
    regions = [
        region(id="reg_1", name="Europe"),
        region(id="reg_2", name="Asia", linkedProviderIds=[], enabledProviderIds=[]),
    ]
    gaps = find_regions_without_working_payment(regions)
    assert gaps == [{"regionId": "reg_2", "regionName": "Asia", "reason": "no_provider_linked"}]


def test_missing_keys_default_to_no_provider_linked():
    gaps = find_regions_without_working_payment([{"id": "reg_3", "name": "Oceania"}])
    assert gaps == [{"regionId": "reg_3", "regionName": "Oceania", "reason": "no_provider_linked"}]
find-regions-without-payment.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findRegionsWithoutWorkingPayment } from "./find-regions-without-payment.js";

const region = (over = {}) => ({
  id: "reg_1",
  name: "Europe",
  linkedProviderIds: ["pp_stripe_stripe"],
  enabledProviderIds: ["pp_stripe_stripe"],
  ...over,
});

test("region with working provider is covered", () => {
  const gaps = findRegionsWithoutWorkingPayment([region()]);
  assert.deepEqual(gaps, []);
});

test("region with no linked provider is reported", () => {
  const gaps = findRegionsWithoutWorkingPayment([region({ linkedProviderIds: [] })]);
  assert.deepEqual(gaps, [{ regionId: "reg_1", regionName: "Europe", reason: "no_provider_linked" }]);
});

test("region with linked provider not enabled is reported", () => {
  const gaps = findRegionsWithoutWorkingPayment([
    region({ linkedProviderIds: ["pp_stripe_stripe"], enabledProviderIds: [] }),
  ]);
  assert.deepEqual(gaps, [{ regionId: "reg_1", regionName: "Europe", reason: "linked_provider_not_enabled" }]);
});

test("region with one of several providers working is covered", () => {
  const gaps = findRegionsWithoutWorkingPayment([
    region({ linkedProviderIds: ["pp_stripe_stripe", "pp_manual_manual"], enabledProviderIds: ["pp_manual_manual"] }),
  ]);
  assert.deepEqual(gaps, []);
});

test("multiple regions each get their own verdict", () => {
  const regions = [
    region({ id: "reg_1", name: "Europe" }),
    region({ id: "reg_2", name: "Asia", linkedProviderIds: [], enabledProviderIds: [] }),
  ];
  const gaps = findRegionsWithoutWorkingPayment(regions);
  assert.deepEqual(gaps, [{ regionId: "reg_2", regionName: "Asia", reason: "no_provider_linked" }]);
});

test("missing keys default to no_provider_linked", () => {
  const gaps = findRegionsWithoutWorkingPayment([{ id: "reg_3", name: "Oceania" }]);
  assert.deepEqual(gaps, [{ regionId: "reg_3", regionName: "Oceania", reason: "no_provider_linked" }]);
});

Case studies

New market launch

The region that could shop but never pay

A team cloned their EU region's settings to spin up a new region for a market with a different currency, copying countries and tax rules in minutes. They pushed a campaign, and orders came in all the way to the last click, where checkout failed for every single customer with no obvious reason on the storefront.

Running the script found the exact gap right away, the new region had zero payment providers linked, because the clone only copied countries and currency, not the payment provider link. Linking the same Stripe provider that the EU region used fixed it in minutes once someone actually looked.

Rotated credentials

The provider that was linked but silently broken

A merchant rotated their Stripe API key during a routine security review and updated it in their staging environment, but the production deployment's environment variable was never touched. The provider stayed linked to every region in the Admin, so nothing about the setup looked wrong from the dashboard.

The detection step flagged every affected region with linked_provider_not_enabled, distinct from a missing link, which pointed the team straight at the credential instead of a wasted afternoon re-checking region settings that were actually fine.

What good looks like

After this runs, every gap between a region that looks ready for checkout and a region that can actually take a payment is a short, readable list, region name, id, and the precise reason. No script silently enables a gateway or invents credentials. A human decides which provider belongs in which market, and once that provider is linked and its credentials are valid, the same script reports the region as covered and moves on.

FAQ

Why does completing my Medusa cart fail with no payment provider available?

In Medusa v2, a region only offers the payment providers that are both installed in medusa-config and explicitly enabled for that region in the Admin. A region can have products priced correctly and still have zero payment providers enabled, so the storefront can build a cart and a payment collection, but completing the cart fails once Medusa looks for a provider to authorize the payment session and finds none configured for that region.

Is a missing payment provider always the same root cause?

No. It can mean the region has no payment provider linked to it at all, or a provider is linked but its module is not registered in medusa-config so it never loads, or the provider is registered and linked but its own credentials are missing or invalid so every session it creates errors out. All three end in the same failed checkout, so the detection step has to check each layer and report which one applies.

Should a script automatically enable a payment provider for a region?

Not automatically. Which payment providers to offer in a region is a business and compliance decision tied to currency, licensing, and the merchant's account with that provider, so the safe default is to report every region missing a working provider. If an operator confirms intent, a guarded script can run in dry run first, print the planned POST call to link the provider to the region, and only execute it when dry run is turned off, then re-verify with a real cart before reporting success.

Related field notes

Citations

On the problem:

  1. Medusa Documentation: Payment Concepts, Payment Provider, Payment Collection, Payment Session. docs.medusajs.com/resources/commerce-modules/payment/concepts
  2. GitHub Issue: Checkout fails with no payment providers available for the region. github.com/medusajs/medusa/issues/7369
  3. GitHub Issue: Payment session fails to initiate when provider credentials are invalid. github.com/medusajs/medusa/issues/6980

On the solution:

  1. Medusa Documentation: Payment Provider, Payment Module. docs.medusajs.com/resources/commerce-modules/payment/payment-provider
  2. Medusa Documentation: Region, updating a region's payment providers. docs.medusajs.com/resources/references/region
  3. Medusa V2 Admin API Reference. docs.medusajs.com/api/admin

Stuck on a tricky one?

If you have a problem in Medusa storefront access, pricing, inventory, orders, 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 get checkout completing again?

If this saved you a confusing afternoon chasing a phantom checkout bug, 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