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_nameneeds to be configured before anything here works. - Domain configuration for Apple Pay — publishing the
.well-knownfile, required in every integration model. - Appmax JS — the full
initsignature, 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
- Installation authorized with
domain_name(ordomain_names). .well-knownfile published on the domain.- 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.
| Selector | Wallet | Role |
|---|---|---|
.appmax-apple-pay-btn | Apple Pay | Container 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 Pay | The 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 Pay | Anchor. 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 Pay | Optional. Hidden together with the anchor when the wallet is unavailable. |
data-appmax-merchant-name (on [data-appmax-checkout]) | Google Pay | Name 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:
<div class="appmax-apple-pay-btn"></div>The SDK injects something like:
<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, av-if/conditional — the click won't do anything. No error, no log. - If the component that calls
initcan re-render (StrictMode, Fast Refresh, a poorly-scoped effect dependency), remember thatinit()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.
<!-- 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.
const onUpdate = () => ({
total: 129.90,
freight: 15.00,
discount: 10.00,
installments: 1,
products: [
{ name: 'Black T-shirt', price: 62.45, quantity: 2 },
],
});| Field | Type | Required | What shows up on the PaymentSheet |
|---|---|---|---|
total | number | Yes | Total line. The label is hardcoded to "Total" and is not configurable. |
products | array | Recommended | One line per item. Without it, the sheet shows the total only. |
products[].name | string | Yes, if products is present | Line label. |
products[].price | number | Yes, if products is present | Unit price, in BRL. |
products[].quantity | number | Yes, if products is present | Quantity. The displayed value is price × quantity. |
freight | number | No | "Frete" line. Omitted when 0 or absent. |
discount | number | No | "Desconto" line. Omitted when 0 or absent. |
installments | number | No | Does 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: 12990charges R$ 12,990.00, not R$ 129.90.total: '129.90'(string) makes the SDK throwError: 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:
// ✗ 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 Promise | What the SDK does |
|---|---|
| Resolves | completePayment(STATUS_SUCCESS) — the sheet closes with Apple's confirmation. |
| Rejects | completePayment(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:
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
- 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. - If the user changes shipping, installments, or an item along the way, Apple calls your
onUpdateto refresh the displayed total. - On confirmation (Face ID/Touch ID), the SDK calls your
onAuthorize(appleToken). - In
onAuthorize, with theappleTokenin hand: create the customer, create the order, and settle the payment atPOST /v1/payments/apple-pay— the fullappleToken→ payload mapping is in the "Process the payment" section of Apple Pay payment.
Quick troubleshooting
| Symptom | Likely cause |
|---|---|
| Button doesn't appear | Outside 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 opens | The 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 buyer | onAuthorize caught the error without rethrowing. See "How to signal failure in onAuthorize". |
| Click does nothing, no error | The 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 code | The 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
onUpdatecontract, DOM/initorder, or a missingexternalId. - Appeared and failed midway → the problem is merchant validation: the installation's
domain_name, the.well-knownfile, orexternal_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.