Wallets & Click to Pay

Apple Pay, Google Pay, Click to Pay

This page covers the three “express” payment surfaces MVS Pay renders inside the embedded checkout: Apple Pay, Google Pay, and Click to Pay V2. All three ride on the same machinery — Hyperswitch’s HyperLoader.js Web SDK, loaded by @mvs/mvs-pay-ui, drawing the wallet/SRC tiles inside its <PaymentElement />. MVS Pay itself ships almost no wallet-specific code: the only first-party runtime surface is the Apple Pay domain-association route. The rest is configuration (certs, merchant IDs, connectors) that lives in Apple’s portal, Google’s console, Hyperswitch, and Infisical.

Native-Hyperswitch bias. Per ADR-007, wallets and Click to Pay are deliberately not re-implemented in MVS code. Apple Pay tokens are decrypted inside Hyperswitch’s key vault; Google Pay and Click to Pay tiles are rendered by HyperLoader. MVS Pay’s job is to serve one verification file and forward one env var. Everything else is upstream.

Nothing here is live yet. Per-org Apple Pay merchant certs are listed in docs/HANDOFF.md §6d under “Live infrastructure provisioning (NONE done — all code/manifests only)”. The code paths below exist and are tested at the unit level, but no production org has been provisioned, and the Click to Pay E2E (tests/e2e/click-to-pay.spec.ts) is wired but not run in CI (it skips silently without CTP_VISA_TEST_API_KEY). Treat this page as the contract for when provisioning begins, not a description of running prod.

How the three surfaces fit together

All three are surfaced by the same embedded widget. The merchant’s server mints a clientSecret (Apple Pay / Google Pay) or, for the saved-method widget, an sdkAuthorization token; the browser hands it to HyperLoader; HyperLoader draws whichever tiles the active business profile’s connectors support.

The key consequence of this design: adding a wallet or Click to Pay almost never touches the SDK or the UI. You configure a connector and/or paste a secret, and the tile appears. The one exception is Apple Pay’s domain association, which requires MVS Pay to serve a file — covered below.

The HyperLoader bridge

@mvs/mvs-pay-ui loads Hyperswitch’s Web SDK and mounts its payment element. The relevant files:

FileRole
packages/mvs-pay-ui/src/hyper-loader.tsIdempotent loader for HyperLoader.js + TypeScript bindings for the Hyper global.
packages/mvs-pay-ui/src/components/MvsPayCheckout.tsxThe payment widget — renders card + wallet + CTP tiles from a clientSecret.
packages/mvs-pay-ui/src/components/MvsPaySavedCardsWidget.tsxThe paymentMethodsManagement widget — saved-card management from an sdkAuthorization token.

loadHyperLoader injects a single shared <script id="mvs-pay-hyperloader"> tag, defaulting to https://beta.hyperswitch.io/v1/HyperLoader.js (DEFAULT_SCRIPT_SRC). It is idempotent — concurrent callers share one promise, and a rejected load nulls the cached promise so a transient script failure does not permanently break every widget on the page.

The default script source is the upstream beta. host. For a fully self-hosted bundle, pass scriptSrc to <MvsPayCheckout /> (forwarded to loadHyperLoader). This is the only place wallet rendering depends on an Anthropic-external CDN; flagged here because it matters for CSP and for the air-gapped story. See connect-src/script-src allowances in packages/shared/lib/security-headers.ts (mvsPay profile).

Google Pay: the one env var

Google Pay is the simplest of the three — no certificates. The merchant ID alone is enough. MvsPayCheckout accepts a googlePayMerchantId prop and composes it into the HyperLoader payment element’s wallets block:

1// MvsPayCheckout.tsx — composedWallets (last wins):
2// 1. walletReturnUrl (from returnUrl prop)
3// 2. googlePay.merchantId (from googlePayMerchantId prop)
4// 3. caller-supplied wallets block (escape hatch)
5const composedWallets = {
6 ...(returnUrl ? { walletReturnUrl: returnUrl } : {}),
7 ...(googlePayMerchantId ? { googlePay: { merchantId: googlePayMerchantId } } : {}),
8 ...(callerWallets ?? {}),
9};

The caller reads process.env.NEXT_PUBLIC_GOOGLE_PAY_MERCHANT_ID and passes it in. Without the merchant ID, Google Pay still renders in test mode, but production processing requires the value (documented in the prop’s JSDoc and in apps/mvs-pay/.env.example).

Apple Pay needs no prop on MvsPayCheckout. Apple Pay is gated entirely on (a) the certs being uploaded to the card MCA in Hyperswitch and (b) the domain being verified in Apple’s portal. There is no Apple Pay equivalent of googlePayMerchantId in the widget API — the browser’s Apple Pay capability plus the verified domain is what HyperLoader keys on.

Apple Pay — provisioning model

Apple Pay is configured per Apple Developer Merchant ID, not per Hyperswitch merchant. One Merchant ID (merchant.com.mvscloud.<org_slug>) covers all of an MVS org’s profiles. Two distinct certificates are involved, and they do different jobs:

CertificateGenerated wherePrivate key livesPurpose
Merchant Identity CertificateOperator workstation (CSR) → AppleOperator → InfisicalAuthenticates Hyperswitch → Apple during session validation.
Payment Processing CertificateHyperswitch (CSR) → AppleOnly in HyperswitchApple encrypts the payment token to this cert; Hyperswitch decrypts before relaying to the PSP.

The critical security property: the Payment Processing Certificate’s CSR is generated inside Hyperswitch’s Control Center, so the matching private key never leaves Hyperswitch’s key vault. The operator never holds the key that decrypts payment tokens. The full step-by-step (CSRs, openssl invocation, Apple portal navigation, the “processed exclusively in China?” → No prompt) lives in the Wallet Validation runbook.

Each merchant org owns its own Apple Developer Program account — MVS does not own it. Apple’s contract requires the merchant to provision the Merchant ID and Merchant Identity Certificate and share the credentials with MVS out-of-band (1Password / Infisical secure note). This is a per-org dependency that gates Apple Pay go-live and is one reason no org is provisioned yet.

Certs are uploaded to Hyperswitch against the existing card MCA (Stripe, Cybersource, Adyen, …) — Apple Pay is not a separate connector, it rides on the card processor’s merchant_connector_id:

$curl -X POST \
> "$HYPERSWITCH_BASE_URL/v1/account/$MERCHANT_ID/connectors/$MCA_ID/wallets/apple_pay/certs" \
> -H "api-key: $HYPERSWITCH_ADMIN_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "merchant_identity_cert": "...", "merchant_identity_key": "...",
> "payment_processing_cert": "...",
> "merchant_id": "merchant.com.mvscloud.<org_slug>",
> "domain_names": ["pay.mvscloud.com", "<embed-host-domain>"] }'

The response returns a domain-association token per domain in domain_names. That token is what MVS Pay has to serve back to Apple’s verifier.

The Apple Pay domain-association route

This is the single piece of first-party wallet runtime in MVS Pay. Apple requires that, for every domain on which the Apple Pay button is rendered, the server respond at the canonical path:

GET /.well-known/apple-developer-merchantid-domain-association
GET /.well-known/apple-developer-merchantid-domain-association?domain=partner.example.com

Source: apps/mvs-pay/app/.well-known/apple-developer-merchantid-domain-association/route.ts. It is a Node-runtime, force-dynamic route that resolves the token from an env var and returns it as text/plain.

Token resolution

Two resolution paths:

  1. No domain param → use the environment-default key: APPLE_PAY_DOMAIN_ASSOCIATION_PROD in production (when NODE_ENV, INFISICAL_ENV, or VERCEL_ENV indicate prod), else APPLE_PAY_DOMAIN_ASSOCIATION_DEV.
  2. ?domain=partner.example.com → look up a per-domain override at APPLE_PAY_DOMAIN_ASSOCIATION_<SLUG>. On a miss it falls through to the default so embed-host deployments keep working when a new partner is added to Hyperswitch but their override hasn’t been pasted into Infisical yet.

The slug function is exported and worth pinning down exactly:

1// route.ts — domainToEnvSlug
2domain.trim().toUpperCase()
3 .replace(/[^A-Z0-9]+/g, "_") // any run of non-alphanumerics → one "_"
4 .replace(/^_+|_+$/g, ""); // strip leading/trailing underscores
HostEnv-var slugResolved key
pay.mvscloud.com (no domain param)APPLE_PAY_DOMAIN_ASSOCIATION_PROD / _DEV
partner.example.comPARTNER_EXAMPLE_COMAPPLE_PAY_DOMAIN_ASSOCIATION_PARTNER_EXAMPLE_COM
foo-bar.example.co.ukFOO_BAR_EXAMPLE_CO_UKAPPLE_PAY_DOMAIN_ASSOCIATION_FOO_BAR_EXAMPLE_CO_UK

Responses

ConditionStatusCache-ControlBody
Token configured200public, max-age=86400the raw token (text/plain)
No token for domain (and no default)404no-storeapple pay domain association not configured

The 404 is intentional: Apple’s verifier reads 404 as a clean “not provisioned” signal rather than a half-broken integration, and no-store prevents a stale negative from being cached. HEAD mirrors GET’s status and headers with an empty body.

Because the route reads process.env directly and force-dynamic disables Next.js caching, the token must be present in the runtime environment, not baked at build time. MVS Pay pulls Infisical on every Vercel deploy, so updating the token requires a redeploy (or it’s picked up on the next deploy) — there is no app restart needed, but there is no hot-reload of secrets either. The max-age=86400 on the 200 means Apple (and any CDN) may serve a stale token for up to a day; purge the Vercel edge cache during initial setup if you’re iterating.

Infisical paths

DomainInfisical path
pay.mvscloud.com (prod)/apps/mvs-pay/prod/APPLE_PAY_DOMAIN_ASSOCIATION_PROD
pay.mvscloud.com (dev)/apps/mvs-pay/dev/APPLE_PAY_DOMAIN_ASSOCIATION_DEV
partner.example.com (prod)/apps/mvs-pay/prod/APPLE_PAY_DOMAIN_ASSOCIATION_PARTNER_EXAMPLE_COM

See Secrets Management and the Secrets Rotation runbook for how these flow into the deployment.

End-to-end: provisioning Apple Pay for an org

1

Create the Merchant ID (org admin)

In the org’s Apple Developer account, register merchant.com.mvscloud.<org_slug> where <org_slug> is payment_organization.org_slug.

2

Generate both certs

Merchant Identity Certificate via an operator-workstation CSR; Payment Processing Certificate via a Hyperswitch-generated CSR (so its key stays in the vault). Store the operator-held artefacts in Infisical under /apps/mvs-pay/wallets/{org}/....

3

Upload certs to Hyperswitch

POST .../connectors/$MCA_ID/wallets/apple_pay/certs against the card MCA. Capture the returned association_token per domain.

4

Paste tokens into Infisical

One env var per domain, slugged per the table above. The default pay.mvscloud.com token goes to _PROD / _DEV.

5

Verify the route serves the token

$curl -i https://pay.mvscloud.com/.well-known/apple-developer-merchantid-domain-association
$# → 200 + the token
6

Register + verify the domain in Apple's portal

Merchant ID → Merchant Domains → Add Domain → Verify. Apple fetches the .well-known URL within ~24h; status flips Pending → Verified.

The full runbook — including the failure-mode triage (token mismatch, deploy lag, stale CDN) and the 25-month cert-rotation cadence — is the Wallet Validation runbook.

The www.mvspay.io re-verification requirement

The production checkout host is moving from pay.mvscloud.com to www.mvspay.io (see the deployment topology and Domain & DNS — www.mvspay.io; the cutover is also called out in the Architecture overview and the gap list on the Overview page).

Apple Pay domain association is bound to the exact host. A domain change is not transparent — it triggers a full re-provision on the Apple side:

Apple Pay must be re-verified for www.mvspay.io. The existing pay.mvscloud.com verification does not carry over to a new registrable domain. At cutover you must:

  1. Re-upload certs to Hyperswitch with www.mvspay.io added to domain_names (this regenerates the association token for the new host).
  2. Set the token in Infisical. With the current route logic, requests to www.mvspay.io with no domain param resolve to the default APPLE_PAY_DOMAIN_ASSOCIATION_PROD key — so simply updating _PROD to the new host’s token is the minimal change. (If you instead serve the old and new hosts in parallel during cutover, register www.mvspay.io as a per-domain override APPLE_PAY_DOMAIN_ASSOCIATION_WWW_MVSPAY_IO so both resolve correctly.)
  3. Add www.mvspay.io under Merchant Domains in the Apple portal and click Verify again.

Until the new domain shows Verified in Apple’s portal, the Apple Pay button will not render on www.mvspay.io.

Google Pay is also domain-scoped: the Google Pay & Wallet Console integration is registered per domain, so www.mvspay.io needs to be added there too. The merchant ID itself is reused (it does not change), so this is a console-side domain addition rather than a re-issue.

Click to Pay V2 does not require domain re-verification — it is gated on the connector being active on the profile, not on a .well-known file, so it survives the host cutover unchanged.

Click to Pay V2

Click to Pay V2 implements the EMV Secure Remote Commerce (SRC) standard. Hyperswitch v1.123.1 ships it as two connectors:

ConnectorNetworkAuth shapeCredentials
ctp_visaVisaHeaderKeyapi_key = Visa CTP API key
ctp_mastercardMastercardSignatureKeyapi_key = MC API key, key1 = SRCI ID, api_secret = HMAC signing key

V1 (VisaSRCI) is deprecated. Hyperswitch’s old VisaSRCI connector predates Click to Pay V2 and is no longer maintained upstream. There are no day-1 wizard entries for V1; operators still on it should add ctp_visa and disable the legacy MCA (rollback flow in Connector Configuration §9).

No new SDK component is needed

This is the central point: Click to Pay requires zero MVS code changes. HyperLoader’s <PaymentElement /> — the same one mounted by <MvsPayCheckout /> — automatically surfaces a “Click to Pay” tile when a CTP connector is active on the checkout’s business profile. There is no MvsPayClickToPay component, no prop, no env var. You add the connector; the tile appears.

Provisioning via the connector wizard

Click to Pay is added through the same connector wizard as any other connector, driven at /connectors/new. The full steps (creds pre-flight, forced card / credit + debit payment methods, routing append, $0 auth smoke test, the MerchantConnectorExtension sidecar row) are in the Connector Onboarding runbook §3.

Routing interaction

Click to Pay does not bypass routing. The CTP MCA is just another merchant_connector_id in the active routing rule:

  • To pin CTP-only flows (e.g. for the E2E test), set algorithm.type = "single" with the CTP connector.
  • To make CTP optional alongside a primary acquirer (the typical setup), keep the priority list with CTP as a fallback entry.

See Routing & Smart Retries for the routing model.

What’s actually tested

The Click to Pay E2E (tests/e2e/click-to-pay.spec.ts, marked M27) drives the connector wizard for ctp_visa, activates a single-connector routing rule, mints a payment intent, renders <MvsPayCheckout /> via the /embed/checkout harness, and asserts the CTP tile (data-testid="payment-method-click_to_pay") is visible, then screenshots it.

It is wired but not executed in CI as of the M27 commit. Live execution requires all of:

  • Hyperswitch sandbox up (tests/e2e/docker-compose.hyperswitch.yml),
  • CTP_VISA_TEST_API_KEY + CTP_VISA_TEST_PROFILE_ID in env,
  • mvs-pay UI + extensions running on MVS_PAY_E2E_BASE_URL / MVS_PAY_E2E_EXTENSIONS_URL,
  • a Visa-issued sandbox identity for the consumer-side SRC iframe.

Without CTP_VISA_TEST_API_KEY the spec test.skips silently. So the green CI signal does not mean Click to Pay is exercised end-to-end. There is no equivalent E2E for Apple Pay or Google Pay (those require Safari/iOS and a real wallet identity respectively — not in the harness). Treat all three surfaces as validated by hand against a real low-value charge, per the Wallet Validation runbook’s verification matrix.

Verification checklist

The full verification matrix (commands + pass criteria) is in the Wallet Validation runbook §10. The essentials:

SurfaceCheckPass criterion
Apple Pay (route)curl -i .../.well-known/apple-developer-merchantid-domain-association200 + non-empty token body
Apple Pay (per-domain)same with ?domain=partner.example.com200 + partner token (or default fallback)
Apple Pay (Apple side)Apple Developer portal → Merchant Domains”Verified”
Apple Pay (sheet)open checkout in Safari on iOS/macOSbutton renders, sheet opens, no “not configured” error
Google Payopen checkout in Chromebutton renders, header shows org merchant name
Click to Payactive CTP MCA + load checkout”Click to Pay” tile visible in payment element

Test wallets and CTP against a real payment intent (a ~10-cent charge), not a $0 auth. Some PSPs gate wallet-sheet rendering on a minimum amount, so a $0 intent can hide the very tile you’re trying to verify.

Honest gaps

  • No org is provisioned. Per-org Apple Pay merchant certs are explicitly in the “NONE done” bucket of docs/HANDOFF.md §6d. The code is ready; the live Apple Developer accounts, certs, Google Pay integrations, and CTP credentials are not.
  • Wallet validation has no automated coverage. The only automated test is the Click to Pay E2E, and it skips without sandbox creds. Apple Pay / Google Pay validation is manual against a real device/browser.
  • The HyperLoader CDN is external. Wallet/CTP rendering depends on beta.hyperswitch.io/v1/HyperLoader.js unless scriptSrc is overridden. CSP must allow it (mvsPay profile in packages/shared/lib/security-headers.ts).
  • The www.mvspay.io cutover is pending and re-verification is manual. There is no automation that re-uploads certs / re-registers domains on the host change — it is a runbook step that must be done by hand at cutover, and Apple Pay is unavailable on the new host until it completes.
  • Cert rotation is a calendar reminder. Apple Pay certs expire at 25 months; there is no automated expiry alerting wired for the per-org certs yet — the runbook prescribes a manual reminder at month 24.