Use the payment request button

The Payment Request Button is a single frame-js element that gives customers a one-click checkout through their device's stored wallet — Apple Pay on Apple devices, Google Pay on Chrome. Frame.js handles the wallet detection automatically: if the customer's device + browser supports a wallet, the button appears; if not, you can fall back to the standard Card element.

The customer flow: they tap the button, the wallet UI pops up with their stored cards and shipping addresses, they confirm via biometric (Face ID, fingerprint, etc.), and Frame produces a payment method record on your backend. No card-data entry, no autofill friction, materially higher conversion on mobile.

Prerequisites

RequirementDetails
HTTPSApple Pay and Google Pay both require HTTPS. Use ngrok or similar for local dev.
Domain registered with FrameEach domain (and subdomain) where the button appears must be registered with Frame support in both sandbox and live modes.
Apple Pay enabledApple Pay isn't on by default — contact Frame support to enable it for your account.
Apple domain verificationApple requires a one-time domain verification file at /.well-known/apple-developer-merchantid-domain-association. Frame provides the file; you host it.
frame-js loadedRequired for the button to render.

Domain registration + Apple verification is the most painful part of the setup. Loop in Frame support early — it's a one-time setup but has a few back-and-forth steps.

Customer requirements

The button only renders when the customer's device supports a wallet:

WalletSupported on
Apple PaySafari on macOS 10.13+ or iOS 11.3+; cardholder must have a card in Wallet
Google PayChrome 61+ on Android, macOS, Windows, or Linux; cardholder must have a card in Google Pay

Some regions (notably India) restrict one or both wallets — check device + region eligibility before assuming the button will appear.

1. Load frame-js

Same as any frame-js integration: load from Frame's CDN, never a local copy.

<script src="https://js.framepayments.com/v1/index.js"></script>
const frame = await Frame.init('pk_sandbox_your_publishable_key');

2. Define the payment request

A paymentRequest describes what the customer is being charged for — currency, amount, what info to collect. Create one before the button:

const paymentRequest = frame.paymentRequest({
  country: 'US',
  currency: 'usd',
  total: {
    label: 'Order #1042',
    amount: 5000  // $50.00 in cents
  },
  requestPayerName: true,
  requestPayerEmail: true,
  requestShipping: false
});

A few options worth noting:

  • total.amount is the charge amount in the smallest currency unit (cents for USD). Frame uses this as the charge amount unless you override at confirmation time.
  • requestPayerName collects the cardholder name from the wallet — useful for AVS + dispute defense.
  • requestPayerEmail collects the email (most customers' wallets have this on file).
  • requestShipping: true opens the wallet's shipping selector with whatever addresses the customer has stored. The selected address comes back on the event.

3. Detect support + mount the button

The button only renders if the wallet can be used. Check first:

<div id="payment-request-button"></div>
const prButton = await frame.createElement('paymentRequestButton', { paymentRequest });

const supportResult = await paymentRequest.canMakePayment();

if (supportResult?.applePay || supportResult?.googlePay) {
  prButton.mount('#payment-request-button');
} else {
  // No wallet available — fall back to Card element or hide entirely
  document.getElementById('payment-request-button').style.display = 'none';
}

This pattern is the right shape for mixed customer bases: the wallet button shows for customers whose devices support it; everyone else sees your standard checkout.

4. Handle the payment-confirmation event

When the customer taps the button and authenticates, the paymentRequest fires a paymentmethod event with the customer's selected card + the wallet-provided details. Handle it to create the charge on your backend:

paymentRequest.on('paymentmethod', async (event) => {
  // event.paymentMethod is the Frame PaymentMethod created from the wallet credential
  // event.payerName, event.payerEmail are the customer's wallet-provided info

  const response = await fetch('/checkout', {
    method: 'POST',
    body: JSON.stringify({
      paymentMethodId: event.paymentMethod.id,
      payerName: event.payerName,
      payerEmail: event.payerEmail,
      sonarSessionId: localStorage.getItem('frame_charge_session_id')
    })
  });

  const { success, requires3ds, clientSecret } = await response.json();

  if (success) {
    event.complete('success');
  } else if (requires3ds) {
    event.complete('success');  // close the wallet UI
    await frame.confirmCardPayment(clientSecret);
  } else {
    event.complete('fail');
  }
});

event.complete() is what closes the wallet UI on the customer's device — call it with 'success' to dismiss the loading state with a checkmark, 'fail' to dismiss with an error. The customer sees this transition immediately; latency between complete() and your post-payment UI matters for perceived performance.

5. Create the charge server-side

Your backend creates the transfer normally, using the payment method ID from the wallet event:

curl --request POST \
  --url https://api.framepayments.com/v1/transfers \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "amount": 5000,
    "currency": "USD",
    "account_id": "<account_id>",
    "source_payment_method_id": "<payment_method_id from the wallet event>",
    "sonar_session_id": "<sonar session>",
    "description": "Order #1042"
  }'

Wallet-sourced charges run through the same Sonar + 3DS evaluation as any other card charge. Apple Pay and Google Pay use device-bound tokens (not the underlying card number), which means dispute defense is materially stronger — the cryptogram is built into the wallet's authorization.

Common variations

Wallet + Card together. Render both the Payment Request Button and the Card element on the same page. Customers with wallet support see the button; everyone else uses the Card element. Don't make customers choose between paths.

Dynamic amounts. Update the paymentRequest.total if the cart changes between page load and the customer tapping the button. Call paymentRequest.update({ total: { ... } }) to reflect the new amount in the wallet UI.

Shipping address from wallet. Set requestShipping: true and Frame returns the selected shipping address on the paymentmethod event. You can also update available shipping methods dynamically based on the address via the shippingaddresschange event.

Multiple buttons per page. If you have multiple "pay" actions on a page (e.g., one per cart item), create a separate paymentRequest and button per action. They don't share state.

Testing

Use Apple Pay's sandbox test cards in iCloud Wallet to test in development, and Google Pay's test environment for Google Pay. Frame's sandbox accepts wallet-sourced payment methods normally.

For browsers without wallet support during dev, use the Card element instead — the button won't render anyway.

Gotchas

Symptom: the Payment Request Button doesn't appear in production despite working in development. Why: the production domain isn't registered with Frame and/or Apple. Domain registration is per-domain (subdomains count as separate domains). Fix: check Frame's dashboard for registered domains; loop in Frame support to register any new ones. Verify the Apple apple-developer-merchantid-domain-association file is hosted at the right URL with no redirects.

Symptom: the button appears but tapping it shows "Payment not supported." Why: the customer has no card in their wallet, or the card is from a region where Apple Pay / Google Pay isn't supported. Fix: this is expected — surface a fallback to the Card element.

Symptom: paymentmethod event fires but the wallet UI doesn't close after event.complete('success'). Why: you didn't call complete(), or you called it with a status the wallet rejects. Fix: complete() must be called exactly once per event, with 'success' or 'fail'. Calling it twice or with an invalid status leaves the wallet in a stuck state.

Symptom: the payment succeeded but the Sonar session ID was empty on the charge. Why: the wallet flow bypassed your normal page load, so localStorage may not have a session yet. Fix: initialize frame-js (and therefore Sonar) early in the page lifecycle, not lazily right before the wallet event.

Next steps

  • Build a custom payment page — pair the Payment Request Button with the Card element for full coverage
  • Handle 3D Secure — completing 3DS when Frame's engine triggers it on a wallet-sourced charge
  • Sonar — fraud signal coverage on wallet-sourced charges