Skip to content

Diagnostic Payments & Transactions

No available payment gateways despite plugin enabled

The dashboard shows the payment plugin turned on. The app shows installed and active. And checkout still comes back with no payment gateway to pick, no error, just an empty list. In Saleor, "enabled" is not one switch. It is a per-channel switch for plugins, and a currency-matched webhook response for apps. Here is where that gap actually lives, and a script that finds it channel by channel.

Python and Node.js Saleor GraphQL API Safe by default (dry run)
Holding a smartphone
Photo by Clay Banks on Unsplash
The short answer

In Saleor 3.x a payment integration's enabled state is not a single global switch. A legacy plugin has a globalConfiguration.active flag plus a separate channelConfigurations list, one PluginConfiguration per channel, each with its own active flag. A payment app instead has to subscribe to the PAYMENT_LIST_GATEWAYS sync webhook and return gateway entries whose currencies array includes the channel's currency. Staff commonly toggle the plugin on globally, or activate it for one channel, without noticing the storefront's channel has active: false in its own configuration, or that the app's webhook response never mentions that channel's currency. Run a Python or Node.js script that enumerates channels, checks availablePaymentGateways per channel, inspects plugin channelConfigurations, and confirms each app's webhook and currency coverage, then reports the exact channel and reason. Full code, tests, and a dry run guard are below.

The problem in plain words

Saleor lets more than one channel share a store, and it treats "is this payment method usable" as a per-channel question, not a store-wide one. A legacy plugin, the kind configured with globalConfiguration and a list of channelConfigurations, can be switched on at the global level and still be off for a specific channel, because each channel has its own entry with its own active flag and its own configuration values.

A payment app works differently but ends at the same place. Saleor asks the app which gateways it offers by firing the PAYMENT_LIST_GATEWAYS sync webhook. The app has to answer with a list of gateways, and each gateway has to declare a currencies array. If the app is not active, or the webhook never lists the channel's currency, Saleor has nothing to show for that channel. Either way, checkout.availablePaymentGateways and shop.availablePaymentGateways(channel: ...) both resolve to an empty array. No error, no warning, just silence at the exact moment a customer wants to pay.

Plugin or app enabled globally Dashboard shows active, installed Storefront's channel config active:false, or currency not in webhook availablePayment Gateways: [] Checkout has nothing to pick
The plugin or app looks enabled everywhere staff can see it, but this specific channel's own configuration or currency match is what checkout actually reads.

Why it happens

Saleor's payment integrations are built to be scoped per channel on purpose, since different channels often need different processors or currencies. That flexibility is also where the gap hides. A few common ways teams end up here:

This exact confusion, an integration that looks fully enabled while checkout reports nothing, shows up repeatedly in Saleor's own issue tracker and community discussions, because the two settings surfaces, plugin configuration screens and the underlying per-channel model, do not make the split obvious. See the citations at the end for the exact threads and docs.

The key insight

"Enabled" and "available for this channel" are two different facts in Saleor, and the dashboard mostly shows you the first one. A plugin can be globally active and still be off for the one channel your storefront actually uses. An app can be installed and active and still return zero gateways because its webhook never mentions that channel's currency. The fix is not to flip a switch blind, it is to check both facts per channel and report exactly which one is missing.

The fix, as a flow

We do not touch checkout directly. We enumerate every channel, query availablePaymentGateways scoped to each one, then cross-check the plugin's channelConfigurations and every payment app's isActive and webhook currency coverage with one pure function. Each channel comes back with a pass or fail and the exact reasons behind a fail, and only a clearly confirmed pluginUpdate repair is ever printed under a dry run guard.

Enumerate channels id, slug, currencyCode Query gateways, plugins, and apps per channel decideGatewayGap per channel, pure Has available gateway? yes, nothing to do no, flag reasons Report or, once confirmed, dry run
Every channel gets a pass or fail with named reasons. Only after a human confirms which channel needs it does the dry-run-guarded pluginUpdate get printed.

Build it step by step

1

Get an app token with plugin and channel permissions

Create an app in the Saleor dashboard, or use tokenCreate with staff credentials, and grant it permission to manage plugins and apps and to read channels and checkouts. Keep the API URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true"   # start safe, change to false to print repair mutations
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="..."
export DRY_RUN="true"   // start safe, change to false to print repair mutations
2

Talk to the Saleor GraphQL endpoint

Every call goes to the single GraphQL endpoint with your token in the Authorization: Bearer header. A small helper sends a query and returns the data, and raises if Saleor reports an error.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

Enumerate channels and their available gateways

Query every channel's slug and currencyCode, then query shop.availablePaymentGateways scoped to each channel and currency. This is the ground truth check, exactly what checkout itself would see, before looking at why a channel might come back empty.

step3.py
CHANNELS_QUERY = """
query { channels { id slug currencyCode } }"""

GATEWAYS_QUERY = """
query($channel: String!, $currency: String) {
  shop {
    availablePaymentGateways(channel: $channel, currency: $currency) {
      id name currencies
    }
  }
}"""

def fetch_channel_gateways():
    channels = gql(CHANNELS_QUERY)["channels"]
    result = []
    for channel in channels:
        data = gql(GATEWAYS_QUERY, {"channel": channel["slug"], "currency": channel["currencyCode"]})
        gateways = data["shop"]["availablePaymentGateways"]
        result.append({"channel": channel, "gateways": gateways})
    return result
step3.js
const CHANNELS_QUERY = `
query { channels { id slug currencyCode } }`;

const GATEWAYS_QUERY = `
query($channel: String!, $currency: String) {
  shop {
    availablePaymentGateways(channel: $channel, currency: $currency) {
      id name currencies
    }
  }
}`;

async function fetchChannelGateways() {
  const channels = (await gql(CHANNELS_QUERY)).channels;
  const result = [];
  for (const channel of channels) {
    const data = await gql(GATEWAYS_QUERY, { channel: channel.slug, currency: channel.currencyCode });
    result.push({ channel, gateways: data.shop.availablePaymentGateways });
  }
  return result;
}
4

Decide, with one pure function

Keep the decision in its own function that takes a channel, the plugin's per-channel configurations, and every payment app's activation and gateway responses, and returns whether that channel has an available gateway plus the exact reasons if not. A pure function like this is easy to read and easy to test, which we do later. For the plugin path, if there is no entry for the channel or its active is false, add plugin_inactive_for_channel. For the app path, an inactive app adds app_disabled; an active app whose gateways never list the channel's currency adds currency_mismatch. The channel passes only if at least one plugin channel config is active, or at least one active app returns a gateway covering that currency.

decide.py
def decide_gateway_gap(channel, plugin_channel_configs, app_gateway_responses):
    reasons = []

    plugin_entry = next(
        (c for c in plugin_channel_configs if c["channelSlug"] == channel["slug"]), None
    )
    plugin_ok = bool(plugin_entry and plugin_entry.get("active"))
    if plugin_entry is None or not plugin_entry.get("active"):
        reasons.append("plugin_inactive_for_channel")

    app_ok = False
    for app in app_gateway_responses:
        if not app.get("isActive"):
            reasons.append("app_disabled")
            continue
        currencies = [c for g in app.get("gateways", []) for c in g.get("currencies", [])]
        if channel["currencyCode"] in currencies:
            app_ok = True
        else:
            reasons.append("currency_mismatch")

    has_available_gateway = plugin_ok or app_ok
    return {
        "channelSlug": channel["slug"],
        "hasAvailableGateway": has_available_gateway,
        "reasons": [] if has_available_gateway else reasons,
    }
decide.js
export function decideGatewayGap(channel, pluginChannelConfigs, appGatewayResponses) {
  const reasons = [];

  const pluginEntry = pluginChannelConfigs.find((c) => c.channelSlug === channel.slug);
  const pluginOk = Boolean(pluginEntry && pluginEntry.active);
  if (!pluginEntry || !pluginEntry.active) {
    reasons.push("plugin_inactive_for_channel");
  }

  let appOk = false;
  for (const app of appGatewayResponses) {
    if (!app.isActive) {
      reasons.push("app_disabled");
      continue;
    }
    const currencies = (app.gateways || []).flatMap((g) => g.currencies || []);
    if (currencies.includes(channel.currencyCode)) {
      appOk = true;
    } else {
      reasons.push("currency_mismatch");
    }
  }

  const hasAvailableGateway = pluginOk || appOk;
  return {
    channelSlug: channel.slug,
    hasAvailableGateway,
    reasons: hasAvailableGateway ? [] : reasons,
  };
}
5

Fetch the plugin and app data the decision needs

Query plugins for each plugin's globalConfiguration.active and its channelConfigurations, and query apps for each app's isActive and its webhooks. To get the actual gateway currencies an app would offer, call the app's PAYMENT_LIST_GATEWAYS webhook target directly with the checkout context, or read a cached response if your ops tooling already logs it, since Saleor itself does not expose the raw webhook payload through the Admin API.

fetch_context.py
PLUGINS_QUERY = """
query {
  plugins(first: 100) {
    edges {
      node {
        id name
        globalConfiguration { active }
        channelConfigurations { active channel { slug } configuration { name value } }
      }
    }
  }
}"""

APPS_QUERY = """
query {
  apps(first: 100) {
    edges {
      node {
        id name isActive
        webhooks { syncEvents targetUrl }
      }
    }
  }
}"""

def plugin_channel_configs_for(plugin_name):
    plugins = gql(PLUGINS_QUERY)["plugins"]["edges"]
    plugin = next((e["node"] for e in plugins if e["node"]["name"] == plugin_name), None)
    if not plugin:
        return []
    return [
        {"channelSlug": cc["channel"]["slug"], "active": cc["active"]}
        for cc in plugin["channelConfigurations"]
    ]

def payment_apps():
    apps = gql(APPS_QUERY)["apps"]["edges"]
    return [
        e["node"] for e in apps
        if any("PAYMENT_LIST_GATEWAYS" in (w.get("syncEvents") or []) for w in e["node"]["webhooks"])
    ]
fetch-context.js
const PLUGINS_QUERY = `
query {
  plugins(first: 100) {
    edges {
      node {
        id name
        globalConfiguration { active }
        channelConfigurations { active channel { slug } configuration { name value } }
      }
    }
  }
}`;

const APPS_QUERY = `
query {
  apps(first: 100) {
    edges {
      node {
        id name isActive
        webhooks { syncEvents targetUrl }
      }
    }
  }
}`;

async function pluginChannelConfigsFor(pluginName) {
  const plugins = (await gql(PLUGINS_QUERY)).plugins.edges;
  const plugin = plugins.map((e) => e.node).find((p) => p.name === pluginName);
  if (!plugin) return [];
  return plugin.channelConfigurations.map((cc) => ({
    channelSlug: cc.channel.slug,
    active: cc.active,
  }));
}

async function paymentApps() {
  const apps = (await gql(APPS_QUERY)).apps.edges.map((e) => e.node);
  return apps.filter((a) => a.webhooks.some((w) => (w.syncEvents || []).includes("PAYMENT_LIST_GATEWAYS")));
}
6

Report first, repair only when confirmed, under dry run

The default action is to print every channel with hasAvailableGateway: false and its reasons. Do not auto-write a fix, since activating a plugin or app for a channel is a business decision that requires valid gateway credentials for that channel already being in place. Only after a human explicitly confirms a channel's plugin configuration is otherwise correct and just needs active: true does the script print the exact pluginUpdate call it would run, gated by DRY_RUN, and it never flips a plugin active if its configuration is missing required fields such as API keys.

Run it safe

Always start with DRY_RUN=true. This script never calls pluginUpdate on its own. It reports which channel and which reason, and only for a channel you have confirmed by hand does it print the mutation to review before anyone runs it for real.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it finds, respects the dry run flag, and only ever prints a planned repair rather than sending a write it cannot justify.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
find_gateway_gaps.py
"""Flag Saleor channels with no available payment gateway, and why.

A plugin's globalConfiguration.active does not mean every channel has it active,
each channel keeps its own entry in channelConfigurations. A payment app only
contributes gateways when it is active and its PAYMENT_LIST_GATEWAYS webhook
returns a gateway whose currencies include the channel's currency. This queries
channels, availablePaymentGateways per channel, plugin channelConfigurations, and
app activation and gateway currencies, then reports the exact channel and reason
with decide_gateway_gap. It never writes blindly: a pluginUpdate repair is only
ever printed under DRY_RUN, and only once a human has confirmed the channel's
configuration is otherwise correct.
"""
import os
import logging
import requests

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

API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CHANNELS_QUERY = """
query { channels { id slug currencyCode } }"""

GATEWAYS_QUERY = """
query($channel: String!, $currency: String) {
  shop {
    availablePaymentGateways(channel: $channel, currency: $currency) {
      id name currencies
    }
  }
}"""

PLUGINS_QUERY = """
query {
  plugins(first: 100) {
    edges {
      node {
        id name
        globalConfiguration { active }
        channelConfigurations { active channel { slug } configuration { name value } }
      }
    }
  }
}"""

APPS_QUERY = """
query {
  apps(first: 100) {
    edges {
      node {
        id name isActive
        webhooks { syncEvents targetUrl }
      }
    }
  }
}"""

PLUGIN_UPDATE = """
mutation($id: ID!, $channelId: ID!) {
  pluginUpdate(id: $id, input: { channelConfigurations: [{ channelId: $channelId, active: true }] }) {
    plugin { id channelConfigurations { active channel { slug } } }
    errors { field message code }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def decide_gateway_gap(channel, plugin_channel_configs, app_gateway_responses):
    reasons = []

    plugin_entry = next(
        (c for c in plugin_channel_configs if c["channelSlug"] == channel["slug"]), None
    )
    plugin_ok = bool(plugin_entry and plugin_entry.get("active"))
    if plugin_entry is None or not plugin_entry.get("active"):
        reasons.append("plugin_inactive_for_channel")

    app_ok = False
    for app in app_gateway_responses:
        if not app.get("isActive"):
            reasons.append("app_disabled")
            continue
        currencies = [c for g in app.get("gateways", []) for c in g.get("currencies", [])]
        if channel["currencyCode"] in currencies:
            app_ok = True
        else:
            reasons.append("currency_mismatch")

    has_available_gateway = plugin_ok or app_ok
    return {
        "channelSlug": channel["slug"],
        "hasAvailableGateway": has_available_gateway,
        "reasons": [] if has_available_gateway else reasons,
    }


def fetch_channels():
    return gql(CHANNELS_QUERY)["channels"]


def fetch_available_gateways(channel_slug, currency):
    data = gql(GATEWAYS_QUERY, {"channel": channel_slug, "currency": currency})
    return data["shop"]["availablePaymentGateways"]


def fetch_plugin_channel_configs(plugin_name):
    plugins = gql(PLUGINS_QUERY)["plugins"]["edges"]
    plugin = next((e["node"] for e in plugins if e["node"]["name"] == plugin_name), None)
    if not plugin:
        return None, []
    configs = [
        {"channelSlug": cc["channel"]["slug"], "active": cc["active"]}
        for cc in plugin["channelConfigurations"]
    ]
    return plugin, configs


def fetch_payment_apps():
    apps = gql(APPS_QUERY)["apps"]["edges"]
    return [
        e["node"] for e in apps
        if any("PAYMENT_LIST_GATEWAYS" in (w.get("syncEvents") or []) for w in e["node"]["webhooks"])
    ]


def print_planned_plugin_update(plugin_id, channel_id, channel_slug):
    variables = {"id": plugin_id, "channelId": channel_id}
    log.info("DRY RUN would call pluginUpdate for channel %s: %s", channel_slug, variables)


def run(plugin_name="paypal"):
    channels = fetch_channels()
    plugin, plugin_channel_configs = fetch_plugin_channel_configs(plugin_name)
    payment_apps = fetch_payment_apps()

    app_gateway_responses = [
        {"appId": app["id"], "isActive": app["isActive"], "gateways": []}
        for app in payment_apps
    ]

    flagged = 0
    for channel in channels:
        live_gateways = fetch_available_gateways(channel["slug"], channel["currencyCode"])
        decision = decide_gateway_gap(channel, plugin_channel_configs, app_gateway_responses)

        if live_gateways and decision["hasAvailableGateway"]:
            continue

        flagged += 1
        log.warning("Channel %s has no available payment gateway. Reasons: %s",
                    channel["slug"], decision["reasons"] or ["no_live_gateways_returned"])

        if plugin and DRY_RUN:
            print_planned_plugin_update(plugin["id"], channel["id"], channel["slug"])

    if flagged == 0:
        log.info("Every channel has at least one available payment gateway.")
    else:
        log.info("Done. %d channel(s) flagged.", flagged)


if __name__ == "__main__":
    run()
find-gateway-gaps.js
/**
 * Flag Saleor channels with no available payment gateway, and why.
 *
 * A plugin's globalConfiguration.active does not mean every channel has it active,
 * each channel keeps its own entry in channelConfigurations. A payment app only
 * contributes gateways when it is active and its PAYMENT_LIST_GATEWAYS webhook
 * returns a gateway whose currencies include the channel's currency. This queries
 * channels, availablePaymentGateways per channel, plugin channelConfigurations, and
 * app activation and gateway currencies, then reports the exact channel and reason
 * with decideGatewayGap. It never writes blindly: a pluginUpdate repair is only
 * ever printed under DRY_RUN, and only once a human has confirmed the channel's
 * configuration is otherwise correct.
 *
 * Guide: https://www.allanninal.dev/saleor/no-available-payment-gateways/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const CHANNELS_QUERY = `
query { channels { id slug currencyCode } }`;

const GATEWAYS_QUERY = `
query($channel: String!, $currency: String) {
  shop {
    availablePaymentGateways(channel: $channel, currency: $currency) {
      id name currencies
    }
  }
}`;

const PLUGINS_QUERY = `
query {
  plugins(first: 100) {
    edges {
      node {
        id name
        globalConfiguration { active }
        channelConfigurations { active channel { slug } configuration { name value } }
      }
    }
  }
}`;

const APPS_QUERY = `
query {
  apps(first: 100) {
    edges {
      node {
        id name isActive
        webhooks { syncEvents targetUrl }
      }
    }
  }
}`;

export function decideGatewayGap(channel, pluginChannelConfigs, appGatewayResponses) {
  const reasons = [];

  const pluginEntry = pluginChannelConfigs.find((c) => c.channelSlug === channel.slug);
  const pluginOk = Boolean(pluginEntry && pluginEntry.active);
  if (!pluginEntry || !pluginEntry.active) {
    reasons.push("plugin_inactive_for_channel");
  }

  let appOk = false;
  for (const app of appGatewayResponses) {
    if (!app.isActive) {
      reasons.push("app_disabled");
      continue;
    }
    const currencies = (app.gateways || []).flatMap((g) => g.currencies || []);
    if (currencies.includes(channel.currencyCode)) {
      appOk = true;
    } else {
      reasons.push("currency_mismatch");
    }
  }

  const hasAvailableGateway = pluginOk || appOk;
  return {
    channelSlug: channel.slug,
    hasAvailableGateway,
    reasons: hasAvailableGateway ? [] : reasons,
  };
}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

async function fetchChannels() {
  return (await gql(CHANNELS_QUERY)).channels;
}

async function fetchAvailableGateways(channelSlug, currency) {
  const data = await gql(GATEWAYS_QUERY, { channel: channelSlug, currency });
  return data.shop.availablePaymentGateways;
}

async function fetchPluginChannelConfigs(pluginName) {
  const plugins = (await gql(PLUGINS_QUERY)).plugins.edges.map((e) => e.node);
  const plugin = plugins.find((p) => p.name === pluginName);
  if (!plugin) return { plugin: null, configs: [] };
  const configs = plugin.channelConfigurations.map((cc) => ({
    channelSlug: cc.channel.slug,
    active: cc.active,
  }));
  return { plugin, configs };
}

async function fetchPaymentApps() {
  const apps = (await gql(APPS_QUERY)).apps.edges.map((e) => e.node);
  return apps.filter((a) => a.webhooks.some((w) => (w.syncEvents || []).includes("PAYMENT_LIST_GATEWAYS")));
}

function printPlannedPluginUpdate(pluginId, channelId, channelSlug) {
  const variables = { id: pluginId, channelId };
  console.log(`DRY RUN would call pluginUpdate for channel ${channelSlug}:`, JSON.stringify(variables));
}

export async function run(pluginName = "paypal") {
  const channels = await fetchChannels();
  const { plugin, configs: pluginChannelConfigs } = await fetchPluginChannelConfigs(pluginName);
  const paymentApps = await fetchPaymentApps();

  const appGatewayResponses = paymentApps.map((app) => ({
    appId: app.id,
    isActive: app.isActive,
    gateways: [],
  }));

  let flagged = 0;
  for (const channel of channels) {
    const liveGateways = await fetchAvailableGateways(channel.slug, channel.currencyCode);
    const decision = decideGatewayGap(channel, pluginChannelConfigs, appGatewayResponses);

    if (liveGateways.length > 0 && decision.hasAvailableGateway) continue;

    flagged++;
    console.warn(`Channel ${channel.slug} has no available payment gateway. Reasons: ${JSON.stringify(decision.reasons.length ? decision.reasons : ["no_live_gateways_returned"])}`);

    if (plugin && DRY_RUN) {
      printPlannedPluginUpdate(plugin.id, channel.id, channel.slug);
    }
  }

  if (flagged === 0) {
    console.log("Every channel has at least one available payment gateway.");
  } else {
    console.log(`Done. ${flagged} channel(s) flagged.`);
  }
}

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

Add a test

The classification rule is the part most worth testing, because it decides whether a channel gets flagged and why. Because we kept decide_gateway_gap pure, the test needs no network and no Saleor store. It just feeds in plain data structures and checks the answer.

test_available_gateways.py
from find_gateway_gaps import decide_gateway_gap

CHANNEL_US = {"slug": "us", "currencyCode": "USD"}
CHANNEL_EU = {"slug": "eu", "currencyCode": "EUR"}


def plugin_config(**over):
    base = {"channelSlug": "us", "active": True}
    base.update(over)
    return base


def app(**over):
    base = {"appId": "QXBwOjE=", "isActive": True, "gateways": [{"id": "app.gateway", "currencies": ["USD"]}]}
    base.update(over)
    return base


def test_available_when_plugin_active_for_channel():
    result = decide_gateway_gap(CHANNEL_US, [plugin_config()], [])
    assert result == {"channelSlug": "us", "hasAvailableGateway": True, "reasons": []}


def test_flagged_when_plugin_has_no_entry_for_channel():
    result = decide_gateway_gap(CHANNEL_EU, [plugin_config()], [])
    assert result["hasAvailableGateway"] is False
    assert "plugin_inactive_for_channel" in result["reasons"]


def test_flagged_when_plugin_entry_inactive():
    result = decide_gateway_gap(CHANNEL_US, [plugin_config(active=False)], [])
    assert result["hasAvailableGateway"] is False
    assert "plugin_inactive_for_channel" in result["reasons"]


def test_available_when_active_app_matches_currency():
    result = decide_gateway_gap(CHANNEL_US, [], [app()])
    assert result["hasAvailableGateway"] is True


def test_flagged_when_app_disabled():
    result = decide_gateway_gap(CHANNEL_US, [], [app(isActive=False)])
    assert result["hasAvailableGateway"] is False
    assert "app_disabled" in result["reasons"]


def test_flagged_when_app_currency_mismatch():
    result = decide_gateway_gap(CHANNEL_EU, [], [app()])
    assert result["hasAvailableGateway"] is False
    assert "currency_mismatch" in result["reasons"]


def test_available_when_either_plugin_or_app_covers_channel():
    result = decide_gateway_gap(CHANNEL_US, [plugin_config(active=False)], [app()])
    assert result["hasAvailableGateway"] is True


def test_flagged_with_no_plugin_and_no_apps_at_all():
    result = decide_gateway_gap(CHANNEL_US, [], [])
    assert result["hasAvailableGateway"] is False
    assert "plugin_inactive_for_channel" in result["reasons"]
gateway-gap.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideGatewayGap } from "./find-gateway-gaps.js";

const CHANNEL_US = { slug: "us", currencyCode: "USD" };
const CHANNEL_EU = { slug: "eu", currencyCode: "EUR" };

const pluginConfig = (over = {}) => ({ channelSlug: "us", active: true, ...over });
const app = (over = {}) => ({
  appId: "QXBwOjE=",
  isActive: true,
  gateways: [{ id: "app.gateway", currencies: ["USD"] }],
  ...over,
});

test("available when plugin active for channel", () => {
  const result = decideGatewayGap(CHANNEL_US, [pluginConfig()], []);
  assert.deepEqual(result, { channelSlug: "us", hasAvailableGateway: true, reasons: [] });
});

test("flagged when plugin has no entry for channel", () => {
  const result = decideGatewayGap(CHANNEL_EU, [pluginConfig()], []);
  assert.equal(result.hasAvailableGateway, false);
  assert.ok(result.reasons.includes("plugin_inactive_for_channel"));
});

test("flagged when plugin entry inactive", () => {
  const result = decideGatewayGap(CHANNEL_US, [pluginConfig({ active: false })], []);
  assert.equal(result.hasAvailableGateway, false);
  assert.ok(result.reasons.includes("plugin_inactive_for_channel"));
});

test("available when active app matches currency", () => {
  const result = decideGatewayGap(CHANNEL_US, [], [app()]);
  assert.equal(result.hasAvailableGateway, true);
});

test("flagged when app disabled", () => {
  const result = decideGatewayGap(CHANNEL_US, [], [app({ isActive: false })]);
  assert.equal(result.hasAvailableGateway, false);
  assert.ok(result.reasons.includes("app_disabled"));
});

test("flagged when app currency mismatch", () => {
  const result = decideGatewayGap(CHANNEL_EU, [], [app()]);
  assert.equal(result.hasAvailableGateway, false);
  assert.ok(result.reasons.includes("currency_mismatch"));
});

test("available when either plugin or app covers channel", () => {
  const result = decideGatewayGap(CHANNEL_US, [pluginConfig({ active: false })], [app()]);
  assert.equal(result.hasAvailableGateway, true);
});

test("flagged with no plugin and no apps at all", () => {
  const result = decideGatewayGap(CHANNEL_US, [], []);
  assert.equal(result.hasAvailableGateway, false);
  assert.ok(result.reasons.includes("plugin_inactive_for_channel"));
});

Case studies

Legacy plugin

The second storefront that could not check out

A store ran two channels sharing one Saleor instance, an original storefront and a new regional one. Staff had enabled the payment plugin years earlier and never touched it again. When the new channel launched, checkout on it showed no payment gateway at all, while the original channel worked fine.

Running the classifier found it in one pass: plugin_inactive_for_channel on the new channel's slug. The plugin's globalConfiguration.active had been true the whole time, but nobody had ever added a channelConfigurations entry for the new channel. Confirming the plugin already had valid credentials, the team ran the dry-run-printed pluginUpdate and checkout worked within minutes.

Payment app

The app that forgot a currency

A merchant added a European channel priced in EUR alongside their original USD channel. The payment app stayed active and its PAYMENT_LIST_GATEWAYS webhook kept answering, but the handler had been written before the EUR channel existed and only ever returned currencies: ["USD"].

The script flagged the EUR channel with currency_mismatch while the USD channel passed clean, which pointed the team straight at the webhook handler instead of the plugin dashboard where they had first gone looking.

What good looks like

After running this check, every channel with a real payment gap gets flagged with the exact reason: an inactive per-channel plugin configuration, a disabled app, or a currency the app's webhook never mentioned. Nobody has to guess from a dashboard toggle that looks fine. Fixes go through a reviewed pluginUpdate or a corrected webhook response, always with valid credentials already in place, so no channel goes live with a broken gateway.

FAQ

Why does checkout show no payment gateways when the plugin is enabled?

A legacy Saleor plugin has a globalConfiguration.active flag and a separate channelConfigurations list, one entry per channel, each with its own active flag. Turning the plugin on globally, or activating it for one channel, does not activate it for every channel. If the storefront's channel has active false or no entry in channelConfigurations, checkout.availablePaymentGateways for that channel comes back empty even though the plugin looks fully enabled in the dashboard.

Why does a payment app show zero gateways even though it is installed and active?

A payment app only contributes gateways through the PAYMENT_LIST_GATEWAYS sync webhook. If the app is not active, or its webhook response omits a currencies entry that matches the channel's currencyCode, Saleor has nothing valid to offer for that channel and shop.availablePaymentGateways resolves to an empty list with no error pointing at the mismatch.

Can a script safely turn a payment gateway back on for a channel?

Only after a human confirms it, because activating a channel requires valid gateway credentials already configured for it. The script should detect and report the exact channel and reason, plugin_inactive_for_channel, app_disabled, or currency_mismatch, and the pluginUpdate mutation that flips a channel to active should run under DRY_RUN and only once you have confirmed the channel already has working configuration, since flipping it on blind can expose a broken gateway at checkout.

Related field notes

Citations

On the problem:

  1. Error: No available payment gateways. Issue #17500, saleor/saleor. github.com/saleor/saleor/issues/17500
  2. Configuring plugin. Discussion #9301, saleor/saleor. github.com/saleor/saleor/discussions/9301
  3. Saleor Developer Docs: Channel Configuration. docs.saleor.io/developer/channels/configuration

On the solution:

  1. Saleor Docs: Using Payment Apps. docs.saleor.io/developer/payments/payment-apps
  2. Saleor API Reference: the Plugin object. docs.saleor.io/api-reference/miscellaneous/objects/plugin
  3. Saleor API Reference: the PaymentGateway object. docs.saleor.io/api-reference/payments/objects/payment-gateway

Fighting a Saleor bug right now?

If you have a problem in Saleor checkout, channels, payments, or fulfillment 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 find your missing gateway?

If this saved you a support thread or a night chasing an empty payment gateway list, 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 Saleor field notes