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.
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.
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:
- Staff toggle a legacy plugin's
globalConfiguration.activeto true, or activate it through one channel's settings screen, and assume every channel now has it, when each channel keeps its own entry inchannelConfigurations. - A new channel is created for a new storefront or region, and nobody runs
pluginUpdateagain to add achannelConfigurationsentry for it, so the new channel silently has none. - A payment app is installed and marked active, but its
PAYMENT_LIST_GATEWAYSwebhook handler was written against the store's original currency and never updated when a second channel launched in a different currency. - The app's webhook target URL is failing or timing out, so Saleor treats the response as empty gateways for every channel, which looks identical to a currency mismatch from the storefront's point of view.
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.
"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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
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.
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
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;
}
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.
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,
}
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,
};
}
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.
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"])
]
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")));
}
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.
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.
"""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()
/**
* 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.
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"]
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
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.
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.
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:
- Error: No available payment gateways. Issue #17500, saleor/saleor. github.com/saleor/saleor/issues/17500
- Configuring plugin. Discussion #9301, saleor/saleor. github.com/saleor/saleor/discussions/9301
- Saleor Developer Docs: Channel Configuration. docs.saleor.io/developer/channels/configuration
On the solution:
- Saleor Docs: Using Payment Apps. docs.saleor.io/developer/payments/payment-apps
- Saleor API Reference: the
Pluginobject. docs.saleor.io/api-reference/miscellaneous/objects/plugin - Saleor API Reference: the
PaymentGatewayobject. 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.
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