Diagnostic Stripe
no payment method domain registered, so wallets never show
Apple Pay renders perfectly on localhost. It renders in Stripe's own demo. It is enabled in the Dashboard, the browser is Safari, the device has a card in the wallet — and on checkout.example.com the button is simply not there. No console error, no failed request, nothing in the Stripe logs. The wallet was filtered out before it had a chance to render, because Stripe does not recognise the domain asking for it.
Read GET /v1/payment_method_domains with a live key. An empty list is the answer on its own: no domain is registered, so every wallet is filtered out in production.
If the list is not empty, check each entry for enabled: true, livemode: true, and apple_pay.status, google_pay.status and link.status equal to "active". Any other status means that one wallet is dark, and the reason is in <wallet>.status_details.error_message.
The problem in plain words
Wallet registration is per-domain and per-mode, and both halves of that catch people. A domain verified in test mode does nothing for live traffic, so the wallet that worked all through development disappears at launch. And a registration for example.com does nothing for checkout.example.com, so moving checkout to a subdomain — a routine infrastructure change nobody thinks to mention — silently removes Apple Pay, Google Pay, Link and PayPal from the Payment Element.
Because the wallet is filtered rather than failed, there is nothing to find. The Element renders the methods it can offer, the page has no error, and the request never happens. The only visible trace is in the numbers: mobile conversion drops relative to desktop and stays there, which reads as a responsive-design problem for as long as anyone is willing to believe it.
Why it happens
Domain verification is an Apple requirement that Stripe carries out for you. Apple Pay on the web will not run on an unverified domain, so Stripe has to register and verify the exact host before it can offer the button. That is why the check is a Stripe object rather than a Dashboard toggle.
Modes are separate objects. livemode on the domain object is the thing to read. A perfectly healthy test-mode registration produces exactly the symptom you are investigating, and it looks correct in the test Dashboard everyone is checking.
Each wallet has its own status. One domain can serve Link happily while Apple Pay is dark, so a check that stops at “a domain is registered” passes on an account where the specific wallet you care about never renders. The reason lives in status_details.error_message, not in the status.
The fix, as a flow
The script checks livemode before it checks anything else, then reads each wallet status on its own, because one domain can serve Link happily while Apple Pay is dark.
How to fix it
List the registered domains with a live key
GET /v1/payment_method_domains?limit=100. An empty list ends the investigation immediately, and it is the single most common result on an account where wallets have never worked in production.
Confirm the mode on each object
livemode must be true. A test-mode registration is not a partial credit; it has no effect at all on live traffic, and it is what people find when they go looking and conclude the domain is registered.
Read each wallet's status separately
apple_pay, google_pay, link and paypal each carry their own status on the domain object. Anything other than active means that wallet is dark, and status_details.error_message says why — usually that the association file is not being served from the domain.
Compare against the hosts you actually serve
Pass the domains checkout runs on and subtract. This is where the subdomain case surfaces: example.com registered and healthy, checkout.example.com absent entirely, and every wallet missing on the page that matters.
Register each host and re-validate
One registration per host, in live mode, then serve /.well-known/apple-developer-merchantid-domain-association from it. Stripe serves the file automatically for Stripe-hosted flows; for your own pages it has to be reachable before validation will pass.
How to check it worked
Re-run with the same --domain arguments. Every host you serve should be present, live, enabled, and active on every wallet.
python3 stripe_wallet_domains.py --domain checkout.example.com
# active checkout.example.com every wallet active
The full code
One GET request and no writes: a restricted key with read access to Payment Method Domains is enough. Three pure functions, because the check has three separate questions in it — which wallets are dark on a domain, what one domain object as a whole means, and which of your hosts are not registered at all.
"""Report domains where Apple Pay, Google Pay, Link or PayPal will not render.
Read only. One GET request, no writes: give this a RESTRICTED key with read
access to Payment Method Domains. The repair is printed, never performed,
because this script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_wallet_domains")
API = "https://api.stripe.com/v1"
WALLETS = ("apple_pay", "google_pay", "link", "paypal")
def dark_wallets(domain):
"""Wallets on this domain that are not active, with Stripe's own reason.
Pure. Each wallet carries its own status on the domain object, so one domain
can serve Link happily while Apple Pay is dark. The reason is in
`<wallet>.status_details.error_message` rather than in the status itself,
which is why a check that only reads the status has nothing useful to print.
"""
out = []
for name in WALLETS:
w = domain.get(name)
if not isinstance(w, dict):
continue
if w.get("status") != "active":
details = w.get("status_details") or {}
out.append((name, w.get("status"),
details.get("error_message") or "no reason given"))
return out
def verdict(domain):
"""Classify one registered domain. Pure. Returns (state, detail, dark).
livemode is checked before anything else: a healthy test-mode registration
produces exactly the symptom being investigated and looks correct in the
test Dashboard, so it cannot be allowed to read as a pass.
"""
if not domain.get("livemode"):
return ("test_only",
"registered in test mode only, which has no effect on live "
"traffic: live visitors see no wallet at all", [])
if not domain.get("enabled"):
return ("disabled",
"registered but disabled, which filters the wallets out exactly "
"as if it had never been registered", [])
dark = dark_wallets(domain)
if dark:
return ("dark",
"%d wallet(s) not active on a live, enabled domain" % len(dark),
dark)
return ("active", "every wallet active", [])
def missing_domains(registered, serving):
"""Hosts you serve checkout from that have no registration at all. Pure.
Registration is per-host, not per-site, so checkout.example.com is invisible
to Stripe even when example.com beside it is registered and healthy.
"""
have = {d.get("domain_name") for d in registered}
return sorted(set(serving) - have)
def get(session, path, **params):
r = session.get(API + path, params=params, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
if r.status_code == 403:
raise SystemExit("403 from Stripe: the restricted key lacks read access to " + path)
r.raise_for_status()
return r.json()
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--domain", action="append", default=[],
help="a host you serve checkout from; repeatable")
args = ap.parse_args()
key = os.environ.get("STRIPE_API_KEY")
if not key:
log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
return 2
if "_live_" not in key:
log.warning("this is a test-mode key: registrations here say nothing "
"about what live visitors see")
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + key})
domains = get(s, "/payment_method_domains", limit=100).get("data", [])
if not domains:
log.warning("no payment method domains registered: every wallet is "
"filtered out in production")
log.warning(" repair: POST %s/payment_method_domains -d "
"domain_name=checkout.example.com in live mode", API)
return 1
bad = 0
for d in domains:
state, detail, dark = verdict(d)
line = "%-9s %s %s" % (state, d.get("domain_name", "?"), detail)
if state == "active":
log.info(line)
continue
bad += 1
log.warning(line)
for name, status, reason in dark:
log.warning(" %s is %s: %s", name, status, reason)
if state == "test_only":
log.warning(" repair: register the same host again with a live key")
elif state == "disabled":
log.warning(" repair: POST %s/payment_method_domains/%s -d enabled=true",
API, d.get("id"))
else:
log.warning(" repair: serve /.well-known/"
"apple-developer-merchantid-domain-association from the "
"host, then POST %s/payment_method_domains/%s/validate",
API, d.get("id"))
for name in missing_domains(domains, args.domain):
bad += 1
log.warning("missing %s serves checkout and is not registered at all", name)
log.warning(" repair: POST %s/payment_method_domains -d domain_name=%s",
API, name)
log.info("%d registered domain(s), %d needing attention", len(domains), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report domains where Apple Pay, Google Pay, Link or PayPal will not render.
*
* Read only. One GET request, no writes: give this a RESTRICTED key with read
* access to Payment Method Domains. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
const WALLETS = ['apple_pay', 'google_pay', 'link', 'paypal'];
/**
* Wallets on this domain that are not active, with Stripe's own reason. Pure.
*
* Each wallet carries its own status, and the reason lives in
* status_details.error_message rather than in the status.
*/
export function darkWallets(domain) {
const out = [];
for (const name of WALLETS) {
const w = domain[name];
if (!w || typeof w !== 'object') continue;
if (w.status !== 'active') {
const details = w.status_details ?? {};
out.push([name, w.status, details.error_message ?? 'no reason given']);
}
}
return out;
}
/**
* Classify one registered domain. Pure. Returns [state, detail, dark].
*
* livemode is checked first: a healthy test-mode registration produces exactly
* the symptom being investigated and must not read as a pass.
*/
export function verdict(domain) {
if (!domain.livemode) {
return ['test_only',
'registered in test mode only, which has no effect on live traffic: live ' +
'visitors see no wallet at all', []];
}
if (!domain.enabled) {
return ['disabled',
'registered but disabled, which filters the wallets out exactly as if it ' +
'had never been registered', []];
}
const dark = darkWallets(domain);
if (dark.length) {
return ['dark', `${dark.length} wallet(s) not active on a live, enabled domain`,
dark];
}
return ['active', 'every wallet active', []];
}
/**
* Hosts you serve checkout from that have no registration at all. Pure.
*/
export function missingDomains(registered, serving) {
const have = new Set(registered.map((d) => d.domain_name));
return [...new Set(serving)].filter((d) => !have.has(d)).sort();
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (res.status === 403) {
throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
if (!key.includes('_live_')) {
console.warn('this is a test-mode key: registrations here say nothing about ' +
'what live visitors see');
}
const serving = process.argv.slice(2).filter((a) => !a.startsWith('-'));
const { data: domains = [] } = await get(key, '/payment_method_domains',
{ limit: 100 });
if (domains.length === 0) {
console.warn('no payment method domains registered: every wallet is filtered ' +
'out in production');
console.warn(` repair: POST ${API}/payment_method_domains ` +
'-d domain_name=checkout.example.com in live mode');
process.exitCode = 1;
return;
}
let bad = 0;
for (const d of domains) {
const [state, detail, dark] = verdict(d);
const line = `${state.padEnd(9)} ${d.domain_name ?? '?'} ${detail}`;
if (state === 'active') { console.log(line); continue; }
bad += 1;
console.warn(line);
for (const [name, status, reason] of dark) {
console.warn(` ${name} is ${status}: ${reason}`);
}
if (state === 'test_only') {
console.warn(' repair: register the same host again with a live key');
} else if (state === 'disabled') {
console.warn(` repair: POST ${API}/payment_method_domains/${d.id} -d enabled=true`);
} else {
console.warn(' repair: serve /.well-known/' +
'apple-developer-merchantid-domain-association from the host, then ' +
`POST ${API}/payment_method_domains/${d.id}/validate`);
}
}
for (const name of missingDomains(domains, serving)) {
bad += 1;
console.warn(`missing ${name} serves checkout and is not registered at all`);
console.warn(` repair: POST ${API}/payment_method_domains -d domain_name=${name}`);
}
console.log(`${domains.length} registered domain(s), ${bad} needing attention`);
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main() and fail the suite on the missing key.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two cases decide whether this check is worth running. A test-mode registration must not read as a pass, because it is the exact thing people find when they go looking and it is why they stop looking. And a subdomain must be reported as missing even when the apex domain beside it is registered, live and completely healthy — that is the case a human eye skips over.
from stripe_wallet_domains import dark_wallets, verdict, missing_domains
ACTIVE = {"status": "active"}
def healthy(name="example.com"):
return {"domain_name": name, "livemode": True, "enabled": True,
"apple_pay": dict(ACTIVE), "google_pay": dict(ACTIVE),
"link": dict(ACTIVE), "paypal": dict(ACTIVE)}
def test_a_fully_active_domain_has_no_dark_wallets():
assert dark_wallets(healthy()) == []
def test_a_dark_wallet_carries_stripes_reason():
d = healthy()
d["apple_pay"] = {"status": "inactive", "status_details":
{"error_message": "association file not found"}}
assert dark_wallets(d) == [("apple_pay", "inactive",
"association file not found")]
def test_a_dark_wallet_without_a_message_still_reports():
d = healthy()
d["link"] = {"status": "inactive"}
assert dark_wallets(d) == [("link", "inactive", "no reason given")]
def test_a_test_mode_registration_is_not_a_pass():
# This is what people find when they check, and why they stop checking.
d = healthy()
d["livemode"] = False
state, _, _ = verdict(d)
assert state == "test_only"
def test_a_disabled_domain_is_not_a_pass():
d = healthy()
d["enabled"] = False
assert verdict(d)[0] == "disabled"
def test_one_dark_wallet_fails_the_whole_domain():
d = healthy()
d["google_pay"] = {"status": "inactive"}
state, detail, dark = verdict(d)
assert state == "dark"
assert "1" in detail and len(dark) == 1
def test_a_live_enabled_active_domain_passes():
assert verdict(healthy())[0] == "active"
def test_a_subdomain_is_missing_even_when_the_apex_is_healthy():
registered = [healthy("example.com")]
assert missing_domains(registered, ["example.com", "checkout.example.com"]) == \
["checkout.example.com"]
def test_nothing_is_missing_when_every_host_is_registered():
registered = [healthy("example.com"), healthy("checkout.example.com")]
assert missing_domains(registered, ["checkout.example.com"]) == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { darkWallets, verdict, missingDomains } from './stripe-wallet-domains.mjs';
const healthy = (name = 'example.com') => ({
domain_name: name, livemode: true, enabled: true,
apple_pay: { status: 'active' }, google_pay: { status: 'active' },
link: { status: 'active' }, paypal: { status: 'active' },
});
test('a fully active domain has no dark wallets', () => {
assert.deepEqual(darkWallets(healthy()), []);
});
test('a dark wallet carries stripe reason', () => {
const d = healthy();
d.apple_pay = { status: 'inactive',
status_details: { error_message: 'association file not found' } };
assert.deepEqual(darkWallets(d),
[['apple_pay', 'inactive', 'association file not found']]);
});
test('a dark wallet without a message still reports', () => {
const d = healthy();
d.link = { status: 'inactive' };
assert.deepEqual(darkWallets(d), [['link', 'inactive', 'no reason given']]);
});
test('a test mode registration is not a pass', () => {
const d = healthy();
d.livemode = false;
assert.equal(verdict(d)[0], 'test_only');
});
test('a disabled domain is not a pass', () => {
const d = healthy();
d.enabled = false;
assert.equal(verdict(d)[0], 'disabled');
});
test('one dark wallet fails the whole domain', () => {
const d = healthy();
d.google_pay = { status: 'inactive' };
const [state, detail, dark] = verdict(d);
assert.equal(state, 'dark');
assert.match(detail, /1/);
assert.equal(dark.length, 1);
});
test('a live enabled active domain passes', () => {
assert.equal(verdict(healthy())[0], 'active');
});
test('a subdomain is missing even when the apex is healthy', () => {
assert.deepEqual(missingDomains([healthy('example.com')],
['example.com', 'checkout.example.com']),
['checkout.example.com']);
});
test('nothing is missing when every host is registered', () => {
const registered = [healthy('example.com'), healthy('checkout.example.com')];
assert.deepEqual(missingDomains(registered, ['checkout.example.com']), []);
});
FAQ
Why does Apple Pay work on localhost but not in production?
Because localhost is exempt from domain verification and your production host is not. Apple Pay on the web requires the serving domain to be registered and verified with Stripe, and that registration is per-host and per-mode.
Does registering example.com cover checkout.example.com?
No. Registration is per-host, so a subdomain needs its own entry. Moving checkout to a subdomain removes every wallet from the page until that host is registered separately, and nothing anywhere reports an error when it happens.
Which wallets does this affect?
Apple Pay, Google Pay, Link and PayPal in Elements all depend on the domain registration, and each carries its own status on the domain object. One can be active while another is dark, so read them individually rather than treating the domain as one flag.
Why is there no error in the console?
Because nothing failed. The Payment Element asks Stripe which methods it may offer and renders those; an unregistered domain simply is not offered the wallet. There is no request to fail and nothing to log.
How do I find out why a wallet is inactive?
Read status_details.error_message on that wallet in the domain object. It is usually that /.well-known/apple-developer-merchantid-domain-association is not reachable from the host, which Stripe serves automatically only for Stripe-hosted flows.
Related field notes
- Intents hardcode payment_method_types to card only
- Most Checkout Sessions expire unpaid and nobody is told
- A deactivated Payment Link is still linked from your site
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- The payment method domain object — Stripe API reference
- Payment method domain registration — Stripe Docs
- Apple Pay — Stripe Docs
- API keys — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.