Skip to content

Implementing the Apple Pay button with the Appmax JS ​

This guide covers the part of the Apple Pay flow that runs in the browser, via appmax.min.js: how the button is rendered, the DOM contract the script requires, and how to wire the callbacks up to payment. For the rest of the flow, see:

  • App installation — domain_name needs to be configured before anything here works.
  • Domain configuration for Apple Pay — publishing the .well-known file, required in every integration model.
  • Appmax JS — the full init signature, IP collection, and card tokenization (this guide assumes you've already read the "How to use" section there).
  • Apple Pay payment — the endpoint contract (payload, Apple Token mapping, integrated model × direct flow, FAQ).
  • Implementing the Google Pay button with the Appmax JS — the same init() also activates the Google Pay button when the page anchors [data-appmax-google-pay]. This page covers what is specific to Apple Pay and points out what changes when both wallets coexist.

Quick prerequisites

  1. Installation authorized with domain_name (or domain_names).
  2. .well-known file published on the domain.
  3. The Appmax JS script loaded on the page, from the right environment's build — the URL differs between sandbox and production, see Environments.

Without all three, the button may still show up, but domain validation fails when the PaymentSheet tries to open.


Rendering the button ​

appmax.min.js recognizes two Apple Pay DOM selectors. You only need one of them — not both. The Google Pay selectors are independent: anchor only those of the wallet the store offers.

SelectorWalletRole
.appmax-apple-pay-btnApple PayContainer you render empty. The SDK replaces its innerHTML with Apple's official button (SVG and styles included). Recommended path — you don't need to draw the button yourself.
[data-appmax-apple-pay]Apple PayThe button itself. This is the element the SDK registers the click on. Use this attribute directly on your own button if you'd rather control the markup.
[data-appmax-google-pay]Google PayAnchor. The SDK inserts an iframe with Google's official button right before it and hides it. The iframe inherits the anchor's height, with a 48 px minimum. Details in Implementing the Google Pay button.
[data-appmax-google-pay-wrapper]Google PayOptional. Hidden together with the anchor when the wallet is unavailable.
data-appmax-merchant-name (on [data-appmax-checkout])Google PayName shown to the buyer on the Google sheet. When absent, the SDK uses the page <title> or the host.

Simplest path — let the SDK draw the button:

html
<div class="appmax-apple-pay-btn"></div>

The SDK injects something like:

html
<button data-appmax-apple-pay class="applepay-button">
  <span class="applepay-button__label">Pay with</span>
  <svg class="applepay-button__logo">...</svg>
</button>

The button only appears if the device supports Apple Pay

Outside Safari (or on a device with no Apple Pay set up), ApplePaySession.canMakePayments() returns false and the SDK doesn't activate the button — it may stay empty or hidden, depending on how you styled the container. That's expected, not a bug.

When the page also anchors Google Pay, the SDK applies a precedence rule: where Apple Pay is available, Google Pay is hidden; in every other supported browser, Google Pay is shown. The store does not choose which wallet appears — the selection is automatic.


Load order: the button needs to exist before init ​

AppmaxScripts.init(...) looks for the button once, at the moment it runs, and doesn't observe DOM changes after that — the same contract described in "DOM contract" on the Appmax JS page. For the Apple Pay button, the practical consequence is:

  • If the container/button only mounts after init — behind a route, a checkout step, a v-if/conditional — the click won't do anything. No error, no log.
  • If the component that calls init can re-render (StrictMode, Fast Refresh, a poorly-scoped effect dependency), remember that init() is not idempotent: each call registers a new listener on top of the previous one.

Practical rule for SPAs: make sure the button's container is already in the DOM at the exact moment AppmaxScripts.init(...) is called — never the other way around.


Initializing with the Apple Pay callbacks ​

For Apple Pay, init requires externalId, onUpdate, and onAuthorize (plus onSuccess/onError, always required). The full reference for each parameter is in Appmax JS → Initialize AppmaxScripts; the focus here is how these three connect to the Apple Pay flow specifically.

html
<!-- IP collection trigger: mandatory, and must exist before init -->
<form data-appmax-customer hidden></form>

<div class="appmax-apple-pay-btn"></div>

<script>
  // onUpdate: called when the PaymentSheet opens and whenever the user
  // changes something in it. Must return the current cart, with numeric
  // values in BRL — see "What onUpdate must return", below.
  const onUpdate = () => ({
    total: 129.90,
    freight: 15.00,
    discount: 10.00,
    installments: 1,
    products: [{ name: 'Black T-shirt', price: 62.45, quantity: 2 }],
  });

  // onAuthorize: called when the payment is authorized by the user.
  // Receives the token of the wallet that was used — the same callback serves
  // Apple Pay and Google Pay. The Apple Token (paymentData + transactionIdentifier)
  // goes to POST /v1/payments/apple-pay; Google's paymentMethodData
  // (tokenizationData) goes to POST /v1/payments/google-pay.
  // For Apple Pay, reject the Promise to signal failure — see "How to signal
  // failure", below.
  const onAuthorize = async (token) => {
    if (token.tokenizationData) {
      await processGooglePayPayment(token); // see the Google Pay page
      return;
    }
    await processPayment(token);
  };

  // IP collection is mandatory in every integration, and only happens if
  // there is a form[data-appmax-customer] in the DOM — the empty form above serves.
  const onIp = ({ ip }) => { customer.ip = ip };
  const onError = (err) => console.error('Apple Pay error:', err.code, err.message);

  window.AppmaxScripts.init({ externalId, onIp, onError, onUpdate, onAuthorize });
</script>

If you use the positional form, order matters

init(onSuccess, onError, externalId, onUpdate, onAuthorize) — in that order. Swapping onUpdate and onAuthorize, or writing onAutorize, makes the SDK treat the wrong parameter as the callback function. The options object carries no such risk, and is the recommended form.

The complete init reference — both forms, every callback and the onError contract — lives in Appmax JS. This page covers only what is specific to Apple Pay.

externalId is the same external_id you returned with an HTTP 200 during app installation — without it, init throws a synchronous exception (see the warning in Appmax JS).

Existing Apple Pay integrations are not affected by Google Pay

Google Pay is only mounted when the page contains the [data-appmax-google-pay] anchor. Without it, init() behaves exactly as before. The init() signature and the script URL do not change.


What onUpdate must return ​

onUpdate is called when the PaymentSheet opens and whenever the user changes something in it. The returned object describes the cart, with numeric values in BRL — the SDK converts it into the format Safari's PaymentSheet consumes. You don't build the lineItems or format the total.

javascript
const onUpdate = () => ({
  total: 129.90,
  freight: 15.00,
  discount: 10.00,
  installments: 1,
  products: [
    { name: 'Black T-shirt', price: 62.45, quantity: 2 },
  ],
});
FieldTypeRequiredWhat shows up on the PaymentSheet
totalnumberYesTotal line. The label is hardcoded to "Total" and is not configurable.
productsarrayRecommendedOne line per item. Without it, the sheet shows the total only.
products[].namestringYes, if products is presentLine label.
products[].pricenumberYes, if products is presentUnit price, in BRL.
products[].quantitynumberYes, if products is presentQuantity. The displayed value is price × quantity.
freightnumberNo"Frete" line. Omitted when 0 or absent.
discountnumberNo"Desconto" line. Omitted when 0 or absent.
installmentsnumberNoDoes not affect the PaymentSheet. Accepted for compatibility.

Values are numbers in BRL — not cents, not strings

This is the most common cause of the PaymentSheet failing to open.

  • total: 12990 charges R$ 12,990.00, not R$ 129.90.
  • total: '129.90' (string) makes the SDK throw Error: Error processing payment... before the sheet opens, because it calls .toFixed(2) on the value.

For the same reason, do not return the already-formatted object:

javascript
// ✗ Wrong — this is the INTERNAL format, which the SDK builds from your return value
{ total: { label: 'Total', amount: { currency: 'BRL', value: '129.90' } },
  displayItems: [ /* ... */ ] }

// ✓ Correct — the cart, with numbers
{ total: 129.90, products: [{ name: 'Black T-shirt', price: 62.45, quantity: 2 }] }

How to signal failure in onAuthorize ​

The SDK decides the PaymentSheet outcome from the state of the Promise your onAuthorize returns:

Applies to Apple Pay only

For Google Pay, Google's sheet is already closed by the time onAuthorize is invoked, and the returned value (or rejection) is not used. Approval or refusal must be handled in the store's own UI — see How onAuthorize behaves.

Your PromiseWhat the SDK does
ResolvescompletePayment(STATUS_SUCCESS) — the sheet closes with Apple's confirmation.
RejectscompletePayment(STATUS_FAILURE) — the sheet reports the failure to the buyer.

return false does not signal failure

Only rejecting the Promise is read as a failure. A try/catch around your backend call — the natural pattern for showing an error message on screen — makes the Promise resolve, and the buyer sees Apple's confirmation on a payment that was declined.

If you need to handle the error, rethrow it:

javascript
const onAuthorize = async (appleToken) => {
  try {
    const res = await fetch('/checkout/apple-pay', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ order_id: orderId, customer_id: customerId, appleToken }),
    });
    if (!res.ok) throw new Error('Payment declined');
  } catch (err) {
    showError(err);
    throw err; // required: without this, the sheet closes as a success
  }
};

From click to payment ​

  1. The user clicks the button → the SDK opens the ApplePaySession (Safari's PaymentSheet) and validates the merchant automatically — you don't call any endpoint for this.
  2. If the user changes shipping, installments, or an item along the way, Apple calls your onUpdate to refresh the displayed total.
  3. On confirmation (Face ID/Touch ID), the SDK calls your onAuthorize(appleToken).
  4. In onAuthorize, with the appleToken in hand: create the customer, create the order, and settle the payment at POST /v1/payments/apple-pay — the full appleToken → payload mapping is in the "Process the payment" section of Apple Pay payment.

Quick troubleshooting ​

SymptomLikely cause
Button doesn't appearOutside Safari, or the device has no card set up in Apple Wallet. Confirm with ApplePaySession.canMakePayments().
Error processing payment... in onError and the PaymentSheet never opensThe onUpdate return value is off-contract — usually values in cents, as strings, or the already-formatted object. See "What onUpdate must return".
A declined payment shows up as approved to the buyeronAuthorize caught the error without rethrowing. See "How to signal failure in onAuthorize".
Click does nothing, no errorThe button was mounted after init (SPA) — see "Load order" above.
init() throws Error: External ID is required...externalId missing or invalid. In the Apple Pay flow this is a synchronous exception that does not go through onError — wrap the call in try/catch.
Generic Safari error on confirmation (e.g. DOMException)Invalid merchant session — check whether the domain has the .well-known file published (guide) and whether external_id is the installation's latest one.
Works on one store but not another, same codeThe second store's domain wasn't informed during installation (domain_name) or doesn't have the .well-known file published.

Did the sheet never open, or open and then fail?

That's the first question to ask, and it separates two worlds that never overlap:

  • Never appeared → the problem is in the browser, before any network call: the onUpdate contract, DOM/init order, or a missing externalId.
  • Appeared and failed midway → the problem is merchant validation: the installation's domain_name, the .well-known file, or external_id.

You can't test the full flow on localhost — see Testing and Sandbox for environment details.

In sandbox, domain validation runs against Apple's production

There is no sandbox merchant session. The button renders and the PaymentSheet may open, but domain/merchant validation always happens against Apple's production infrastructure — so it fails consistently if the domain isn't registered with the .well-known file published. Treat sandbox and production as asymmetric on this specific point; details in Apple Pay payment.


Reference project for implementation

The appmaxbrasil/appstore-demo-php repository contains a reference implementation of the full Apple Pay integration — App Store installation, button rendering and appmax.js callbacks on the checkout, and the call to the payment endpoint — in plain PHP and vanilla JavaScript, with no framework. Use it as a study base and a starting point for your own implementation, not as production code.

The repository's Apple Pay walkthrough links directly to the code line corresponding to each step described in this guide.