Skip to content

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-only key Python and Node.js Tests included
Man in white button up shirt smiling
Photo by LumenSoft Technologies on Unsplash
The short answer

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.

example.comregisteredwallets renderCheckout movescheckout.example.comHostunregisteredno newregistrationWallet filteredoutno error, norequestMobile dropsblamed on layout
Registration is per host, so the apex domain next to it stays registered and healthy the whole time.

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.

GET /v1/payment_method_domainsagainst the hosts you serveNothing registeredevery wallet darkRegistered in test onlyno effect on liveLive but a wallet inactiveread status_detailsHost serving, not listedregister that hostLive, enabled, all activewallets render
A test mode registration is what people find when they check, and it is why they stop checking.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_wallet_domains.py
"""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())
stripe-wallet-domains.mjs
/**
 * 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.

test_stripe_wallet_domains.py
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"]) == []
stripe-wallet-domains.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.