DEV Community

Cover image for Apple Pay and Google Pay in a Mobile Payment Sheet: Every Gate That Blocks the Button
Dinesh Wijethunga
Dinesh Wijethunga

Posted on • Originally published at dineshstack.com

Apple Pay and Google Pay in a Mobile Payment Sheet: Every Gate That Blocks the Button

A wallet button in a mobile payment sheet is behind three independent gates, and all of them must pass at once: the wallet is enabled on the Stripe account, the wallet configuration is passed when the sheet is initialized (it defaults to off and renders nothing), and the app is running on a real device — simulators and emulators never show wallet buttons. Apple Pay adds a fourth gate: a Payment Processing certificate that only exists in the Dashboard. Most "button not showing" reports are the second gate, because a missing client parameter fails silently.

We hit every one of these taking wallets live on a production app whose website showed the buttons perfectly — which is its own clue, covered below. This is Part 2 of the Stripe in Production series; Part 1 covered seven Stripe mistakes that cost real money, including why apple_pay is not a payment_method_type.

The gate model, from the SDK source

Stop guessing from docs — the SDKs state the conditions directly. Reduced to pseudocode:

iOS     show Apple Pay  =  deviceSupportsApplePay()
                        AND sheetConfiguration.applePay != null
                        AND account.isApplePayEnabled

Android show Google Pay =  account.isGooglePayEnabled
                        AND googlePayConfigIsPresentAndSupported

Read the Android line twice: mobile Google Pay does depend on the account-level toggle. A persistent myth says only Apple Pay checks the account — it cost us a day. Every gate below maps to one of these conditions.

Gate 1: the client parameters default to off

Our root cause was one missing argument. The payment sheet renders wallets only when told to, and null means never:

// BAD: compiles, runs, shows card only — no warning anywhere
await Stripe.instance.initPaymentSheet(
  paymentSheetParameters: SetupPaymentSheetParameters(
    paymentIntentClientSecret: clientSecret,
    merchantDisplayName: 'YourBrand',
  ),
);
// GOOD: wallets are explicit opt-in, per platform
Stripe.merchantIdentifier = 'merchant.com.example.app'; // Apple merchant id
await Stripe.instance.applySettings();

await Stripe.instance.initPaymentSheet(
  paymentSheetParameters: SetupPaymentSheetParameters(
    paymentIntentClientSecret: clientSecret,
    merchantDisplayName: 'YourBrand', // what BOTH wallet sheets display
    applePay: const PaymentSheetApplePay(merchantCountryCode: 'US'),
    googlePay: const PaymentSheetGooglePay(
      merchantCountryCode: 'US',
      currencyCode: 'USD',
      testEnv: true, // test tokens — more on shipping this safely below
    ),
  ),
);

Two details that fail silently: the Apple merchant identifier must be identical in three places — the Apple Developer portal, the Xcode Apple Pay capability, and this code. A mismatch renders no button and logs nothing. And merchantDisplayName is the name both wallet sheets show the customer — use the brand customers recognise, not the legal entity, or you recreate the statement descriptor dispute problem from Part 1.

Gate 2: the account toggle mobile silently depends on

In the Stripe Dashboard, Settings → Payment methods, both wallets must be available and on. On our account one wallet sat at available=false and nothing in the mobile SDK said so — the sheet just showed card.

Do not look for proof in the API's payment_method_types; wallets never appear there. We verified empirically:

explicit ['card']              => payment_method_types: ["card"]
automatic_payment_methods      => payment_method_types: ["card","link"]

Neither ever lists apple_pay or google_pay, because wallets ride the card rail — a wallet payment arrives as a card whose card.wallet.type is set. The account toggle feeds the wallet-enabled flags the SDKs read at sheet setup, and that is the only place it surfaces.

Gate 3: real devices only

The iOS Simulator has no Wallet app; Android emulators fail the Google Pay readiness check. Neither ever renders a wallet button, no matter how correct the configuration — so a "does it show?" test on a simulator tells you nothing. Budget a real device into the plan from day one. One more invisible suppressor: Stripe hides wallet display for IP addresses in regions it does not support, so a developer testing through a VPN can stare at a fully working build that shows card only.

Apple Pay's extra gate: the Payment Processing certificate

In-app Apple Pay needs a certificate chain between Apple and Stripe, and Apple's portal will happily sell you the wrong one. You want the Apple Pay Payment Processing certificate — not the Merchant Identity certificate, which is for web domain registration and does nothing for the app. The flow:

1. Stripe Dashboard → Settings → Apple Pay → iOS certificates → download CSR
2. Apple Developer portal → your Merchant ID → create Payment Processing
   certificate FROM STRIPE'S CSR (not one you generate locally)
3. Download the certificate → upload it back in the Stripe Dashboard

Two facts the docs scatter: there is no API for this — the certificate endpoints 404 and the Dashboard is the only path, so script nothing. And one Stripe account means one certificate covering test and live mode both; you do not repeat this per environment. The reverse also holds: web Apple Pay's domain registration does nothing for the app. The two are fully independent.

Why web worked but mobile didn't

The clue that misleads everyone: wallets on your website prove almost nothing about the app. The web payment element reads enabled wallets from the account configuration automatically and renders accordingly. The mobile sheet takes wallet buttons only from its initialization parameters. Same account, same keys, opposite defaults — web is opt-out, mobile is opt-in. If web shows wallets and mobile doesn't, you are almost certainly at Gate 1.

Going live with Google Pay: production access

Test-env Google Pay works with none of this. A live app needs production access from the Google Pay & Wallet Console, and its review is the longest pole in the whole rollout — file early, in parallel with development:

1. Register the business profile (brand name in the public field,
   legal entity in the payments profile — two different fields)
2. Add the Android app integration: your PRODUCTION package name,
   integration type: Gateway (your processor), not Direct
3. The app must already be released on a Play track — internal testing
   counts — or the request bounces and the review clock restarts
4. Attach screenshots from a real device: payment screen with the
   button, the Google Pay sheet, the confirmation screen

With a Gateway integration there is no Google merchant ID to wire into the app — the SDK supplies the gateway parameters itself. Do not register your dev-flavor package; test builds run on testEnv: true and need no approval at all.

Ship it dark: the two-flag rollout

Approval lands on Google's schedule, not your release train — so ship the button disabled behind a remote flag and flip it without an app update. Two flags, deliberately separate:

wallet_button_enabled   default OFF  — flips to on when approval lands
wallet_test_env         default FALSE in production builds

The second flag is the dangerous one. Test-env tokens in a live build cannot be charged — every payment fails at confirm. Two rules kept it safe: the production build's in-app default resolves test-env to false, so an empty flag console is structurally safe; and if the test-env flag is ever created remotely, its unconditional value must be false, with true delivered only through a condition scoped to the dev build. An unconditional true created "just for staging" becomes production's value the moment no condition matches.

Flip day: enable for 5–10% (a native percentage condition — no code), wait for the first real wallet charge, verify it (next section), then go to 100%. Set the flag refresh to fire on app foreground with a short interval — what matters is not how fast a good flip rolls out, but how fast a bad one reverts.

Verify decryption, not rendering

A rendered button proves your client config parses. It does not prove Apple's token decrypts, or that the gateway path works end to end. The only proof is a real charge:

$pi = $stripe->paymentIntents->retrieve($id, ['expand' => ['latest_charge']]);
$wallet = $pi->latest_charge->payment_method_details->card->wallet?->type;

// "apple_pay"  => the Payment Processing certificate decrypted a live token
// "google_pay" => the gateway integration carried a real wallet payment
// null         => a plain card — the wallet path was never exercised

Note the card details on a wallet charge show the device token's last4, not the customer's physical card — expect "those aren't my card digits" confusion if you display them.

The checklist

  • Account: both wallets available and on in payment method settings
  • Client: wallet parameters passed at sheet init; merchant identifier identical in all three places
  • Apple: Payment Processing certificate (not Merchant Identity), from Stripe's CSR, Dashboard-only
  • Google: production access filed early — Gateway type, production package, app on a Play track, real-device screenshots
  • Device: real hardware, no VPN, no simulator conclusions
  • Rollout: button behind a remote flag, test-env flag defaulting false in prod, staged flip
  • Proof: card.wallet.type on the first live charge — per wallet

Next in this series

Part 3 is the race condition that stranded a real customer's money: the authorize/cancel race — a payment sheet's client_secret outlives your order cancellation, the customer pays for an order that no longer exists, and every safety net you built checks state two seconds too early. [LINK WHEN LIVE: /en/stripe-authorize-cancel-race-stale-client-secret]

If you're mid-rollout right now, do the cheap thing first: check whether your sheet initialization passes wallet parameters at all. That was our entire mystery, and it took one line of reading to solve after a week of looking everywhere else.

I post each part natively on LinkedIn with the war story that didn't fit — follow there for Part 3, or tell me which gate ate your week. I read all of them. [INTERNAL LINK: contextual link to another related post goes here]

Top comments (0)