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.

Verify your domain with Apple

Before Apple Pay can be displayed to customers on your website, Apple requires you to verify that you own the domain. This is a one-time setup step per domain.

Domain Registration Required

Every domain where you want to display Apple Pay or Google Pay must be registered with Frame and verified with the respective payment provider. This includes subdomains (e.g., checkout.example.com and example.com are treated as separate domains). If you change or add domains such as moving your checkout from app.example.com to example.com - you must notify Frame support before the change so we can register the new domain. Apple Pay and Google Pay buttons will not appear on unregistered domains.

To verify your domain:

  1. Download the apple-developer-merchantid-domain-association file.
  2. Host the file at the following URL on each domain and subdomain where you want to use Apple Pay:
    https://<yourdomain>/.well-known/apple-developer-merchantid-domain-association
    
    The file must be served with no file extension and accessible without redirects.
  3. Notify Frame support once the file is in place so your domain can be registered with Apple.
Reverification

Once a domain is verified, you must ensure that the validation token is always available at the specified URL. If the validation token is not available when Apple periodically attempts to retrieve it, Apple will not be able to verify the domain, and Apple Pay will not work on your website.

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');
  }
});

Here clientSecret is the client_secret your backend returned from the transfer it created — on a Transfer flow that value is the underlying ChargeIntent's ci_ secret, which is exactly what confirmCardPayment expects. See Handle 3D Secure for why the SDK helper is named after the ChargeIntent rather than the Transfer.

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.

Collect shipping information

Enable shipping collection to gather customer delivery addresses and offer shipping options directly within the payment interface. This streamlined approach reduces checkout friction while ensuring you collect all necessary information for order fulfillment.

When you enable requestShipping: true, customers can select from their saved addresses or enter a new one, and you can dynamically calculate shipping costs based on their location.

Set up shipping collection

Begin by including requestShipping: true when creating the payment request. You can provide initial shipping options if they don't depend on the customer's specific address:

const paymentRequest = frame.paymentRequest({
  country: "US",
  currency: "USD",
  total: {
    amount: 2099,
    label: "Demo"
  },
  requestShipping: true,
  shippingOptions: [
    {
      id: "free-shipping",
      label: "Free shipping",
      detail: "Arrives in 5 to 7 days",
      amount: 0,
    },
    {
      id: "express",
      label: "Express Shipping",
      detail: "Arrives in 2-3 business days",
      amount: 1299,
    }
  ];
});

Handle address changes and validation

Listen for the shippingaddresschange event to validate addresses and update shipping options based on the customer's location. The address data is anonymized by the browser for privacy until the customer completes their purchase.

paymentRequest.on("shippingaddresschange", async (event) => {
  const { shippingAddress, updateWith } = event;
  if (shippingAddress.country !== "US") {
    updateWith({ status: "invalid_shipping_address" });
  } else {
    // Perform server-side request to fetch shipping options
    const response = await fetch("/shipping/calculate", {
      data: JSON.stringify({ shippingAddress: shippingAddress })
    });
    const result = await response.json();

    updateWith({
      status: "success",
      shippingOptions: result.supportedShippingOptions,
    });
  }
});
Address validation best practices:
  • Use the anonymized address data responsibly - only collect what's needed for shipping calculations
  • Provide clear error messages when shipping isn't available to specific locations
  • Consider offering alternative delivery methods (like local pickup) when standard shipping isn't available
  • Always validate shipping costs server-side to prevent manipulation

Handle shipping option selection

When customers change their shipping preference, update the total and display items accordingly:

paymentRequest.on("shippingoptionchange", async (event) => {
  const { shippingOption, updateWith } = event;

  // Recalculate total with new shipping cost
  const subtotal = 1999;
  const tax = 160; // Previously calculated tax
  const newTotal = subtotal + tax + shippingOption.amount;

  updateWith({
    status: "success",
    total: {
      amount: newTotal,
      label: "Total"
    },
    displayItems: [
      { label: "Premium Course", amount: subtotal },
      { label: "Tax", amount: tax },
      { label: shippingOption.label, amount: shippingOption.amount }
    ]
  });
});

Display line items

Use displayItems to provide customers with a transparent breakdown of their purchase in the browser's payment interface. This creates trust and helps reduce cart abandonment by showing exactly what customers are paying for before they complete their transaction.

Display items appear as a detailed list in the payment sheet, showing individual costs like products, taxes, shipping, and discounts. This transparency is especially important for complex purchases where customers want to understand the total calculation.

const paymentRequest = frame.paymentRequest({
  country: "US",
  currency: "USD",
  total: {
    amount: 2000,
    label: "Total"
  },
  displayItems: [
    {
      label: "Premium Course",
      amount: 1000,
    },
    {
      label: "Express Shipping",
      amount: 1000,
    }
  ],
});
Best practices for line items:
  • Use clear, descriptive labels that customers will recognize
  • Keep labels concise but informative (avoid generic terms like "Item 1")
  • Ensure all displayItems sum to your total amount for consistency

Updating display items dynamically

You can update display items in real-time when customers make changes, such as selecting different shipping options:

// Update display items when shipping changes
paymentRequest.on("shippingoptionchange", async (event) => {
  const { shippingOption, updateWith } = event;

  // Calculate new totals based on selected shipping
  const shippingCost = getShippingCost(shippingOption.id);
  const subtotal = 1999;
  const newTotal = subtotal + shippingCost;

  updateWith({
    status: "success",
    total: {
      amount: newTotal,
      label: "Total"
    },
    displayItems: [
      {
        label: "Premium Course Access",
        amount: subtotal,
      },
      {
        label: shippingOption.label,
        amount: shippingCost,
      }
    ],
  });
});

Set up recurring payments

The Payment Request Button supports recurring payments by requesting an Apple Pay MPAN, which enables you to process merchant-initiated transactions (MIT) for subscription-based services.

Create a recurring paymentRequest instance

When setting up recurring payments, modify your paymentRequest configuration to include the applePay object with recurring payment details:

const paymentRequest = frame.paymentRequest({
  country: "US",
  currency: "USD",
  total: {
    amount: 1000,
    label: "Monthly Subscription"
  },
  requestPayerName: true,
  requestPayerEmail: true,
  applePay: {
    recurringPaymentRequest: {
      paymentDescription: "Monthly subscription to Premium Service",
      managementURL: "https://yoursite.com/manage-subscription",
      billingAgreement: "You will be charged $10 monthly for access to premium content. This subscription will automatically renew until canceled.",
      regularBilling: {
        amount: 1000,
        label: "Premium Course",
        recurringPaymentStartDate: new Date(),
        recurringPaymentIntervalUnit: "monthly"
      }
    }
  }
});

The managementURL must be a valid HTTPS URL where customers can manage their recurring payment. Apple requires this for all recurring payment requests.

Recurring payment parameters

Configure your recurring payment request with these parameters:

Parameters
paymentDescriptionstring

A description of the recurring payment that Apple Pay displays to the user in the payment sheet.

managementURLstring

A URL to a web page where customers can manage their subscription.

billingAgreementstringoptional

A localized billing agreement that the payment sheet displays to the user before the user authorizes the payment.

regularBillingdictionary

Object containing the recurring payment details.

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
Frame Assistant

Ask anything about Frame's APIs and products