Implementing the Google Pay button with the Appmax JS
This guide covers the part of the Google 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 — the installation's
external_idis whatinit()receives. Unlike Apple Pay, there is nodomain_nameto configure. - Appmax JS — the full
initsignature, IP collection, and card tokenization (this guide assumes you've already read the "How to use" section there). - Google Pay payment — the endpoint contract (payload, responses, testing, FAQ).
- Implementing the Apple Pay button with the Appmax JS — if the store offers both wallets, what changes when they coexist.
Quick prerequisites
- Installation authorized, with the
external_idat hand. - The Appmax JS script loaded on the page, from the right environment's build — the URL differs between sandbox and production, see Environments.
- Page served over HTTPS.
There is no domain to register and no .well-known file to publish: Google's sheet opens inside an Appmax iframe, whose domain is already approved.
Rendering the button
appmax.min.js recognizes one anchor for Google Pay and two optional attributes around it.
| Selector | Role |
|---|---|
[data-appmax-google-pay] | Anchor. The SDK inserts an iframe with Google's official button right before it and hides it. The anchor's content is never shown — it only marks position and height. |
[data-appmax-google-pay-wrapper] | Optional. Hidden together with the anchor when the wallet is unavailable — useful to hide a label or a divider. |
data-appmax-merchant-name (on [data-appmax-checkout]) | Name shown to the buyer on the Google sheet. When absent, the SDK uses the page <title> or the host, and logs a warning to the console. |
<div data-appmax-checkout data-appmax-merchant-name="Store name">
<div data-appmax-google-pay-wrapper>
<button data-appmax-google-pay style="height: 48px; width: 100%"></button>
</div>
</div>The button's height comes from the anchor
The iframe inherits the anchor's height, with a 48 px minimum. An anchor with no height produces a clipped button — the SDK enforces the minimum, but it does not guess the size your layout needs. Set the height on the anchor.
When the button appears
The button is shown in Chrome, Firefox, Safari, Edge, Opera and UC Browser, on desktop and on mobile devices, to a buyer signed in to a Google account with a saved card.
Where Apple Pay is available (Safari on macOS or iOS with a card in Apple Wallet), the SDK hides Google Pay and shows Apple Pay instead. The store does not choose which wallet appears — the selection is automatic. If the browser does not support the Google Pay API, the anchor and the wrapper stay hidden. That's expected, not a bug.
Checkout inside an iframe
If your checkout already runs inside an iframe, that iframe needs the allow="payment" attribute. The SDK creates the button's iframe with allow="payment <origin>", but the browser only honors the permission if every iframe above it grants it too. Without it the sheet does not open, and the error does not point to the cause.
Load order: the button needs to exist before init
AppmaxScripts.init(...) looks for the anchor 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 Google Pay button, the practical consequence is:
- If the anchor only mounts after
init— behind a route, a checkout step, av-if/conditional — the iframe is never created. No error, no log. onUpdateis called when the iframe loads. If the cart changes afterwards (coupon, shipping), callinit()again or reload the page — see "WhatonUpdatemust return".
Practical rule for SPAs: make sure the anchor is already in the DOM at the exact moment AppmaxScripts.init(...) is called — never the other way around.
Initializing with the Google Pay callbacks
For Google Pay, init requires externalId, onUpdate, and onAuthorize (plus onIp/onError, always recommended). The full reference for each parameter is in Appmax JS → Initialize AppmaxScripts; the focus here is how these three connect to the Google Pay flow specifically.
<!-- IP collection trigger: mandatory, and must exist before init -->
<form data-appmax-customer hidden></form>
<div data-appmax-checkout data-appmax-merchant-name="Store name">
<div data-appmax-google-pay-wrapper>
<button data-appmax-google-pay style="height: 48px; width: 100%"></button>
</div>
</div>
<script>
// onUpdate: called when the iframe loads and after each completed attempt.
// Must return the current cart, with numeric values in BRL — see
// "What onUpdate must return", below. Google Pay uses only `total`.
const onUpdate = () => ({
total: 150.90,
freight: 0,
discount: 0,
installments: 1,
products: [{ name: 'Product', price: 150.90, quantity: 1 }],
});
// onAuthorize: called when the buyer confirms on Google's sheet.
// Receives the paymentMethodData already unwrapped — the whole object that
// goes in payment_data.google_pay.payment_data of POST /v1/payments/google-pay.
// The sheet is already closed at this point: handle approval and refusal
// in your own UI — see "How onAuthorize behaves", below.
const onAuthorize = async (paymentMethodData) => {
await processGooglePayPayment(paymentMethodData);
};
// 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 checkoutData = {
customer: {}
};
const onIp = ({ ip }) => {
checkoutData.customer.ip = ip;
};
const onError = ({ code, message, stage }) => console.error('Google Pay error:', code, stage, 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 Google 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). AppCheckout.init(...) is an alias of window.AppmaxScripts.init(...).
Errors arrive in onError as { code: 'GOOGLE_PAY_FAILED', stage: 'google_pay', message }. Unlike tokenization, there is no status or details here: the failure comes from Google's sheet, not from an API response — see The onError contract.
What onUpdate must return
onUpdate is called when the iframe loads and after each completed attempt — authorization or error —, never on click. The returned object is the same contract as Apple Pay's: the cart, with numeric values in BRL. For Google Pay, only total is used; the other fields are accepted for compatibility with Apple Pay and with tokenization.
const onUpdate = () => ({
total: 150.90,
});| Field | Type | Required | What Google Pay does with it |
|---|---|---|---|
total | number | Yes | Amount shown on Google's sheet, in BRL. |
products, freight, discount, installments | — | No | Ignored by Google Pay. Fill them in if the same page also offers Apple Pay, which shows them on the PaymentSheet. |
Values are numbers in BRL — not cents, not strings
onUpdate is an Appmax JS contract, not an API one. The SDK applies .toFixed(2) to total and sends it to Google as "150.90".
total: 15090shows R$ 15,090.00 on the sheet, not R$ 150.90.total: '150,90'(string with a comma) becomesNaN, which the SDK turns into0— the sheet receives0.00, not the intended amount. A string with a dot ('150.90') is converted and works, but the contract is a number: do not rely on it.
This does not conflict with the API's general rule, where monetary values are integers in cents: the order is still created in cents through POST /v1/orders, and the body of POST /v1/payments/google-pay has no amount field — the amount charged is always the order's. The currency is always BRL.
The amount shown is the one from the last call
The SDK reads onUpdate when the iframe loads and again after each authorization or error — not when the buyer merely closes the sheet. If the cart changes (coupon applied, shipping recalculated), call init() again or reload the page before the buyer clicks.
How onAuthorize behaves
Unlike Apple Pay, the SDK does not use the outcome of the onAuthorize Promise to decide anything: Google's sheet is already closed when the callback is invoked, and the returned value (or rejection) is ignored.
| Wallet | When onAuthorize runs | What the return value does |
|---|---|---|
| Apple Pay | With the PaymentSheet open | Resolves → success on the sheet; rejects → failure on the sheet. See How to signal failure. |
| Google Pay | With the sheet already closed | Nothing. Approval or refusal is handled in your UI, from the response of POST /v1/payments/google-pay. |
In practice: send the paymentMethodData to your backend, wait for the endpoint's response and show the result on your screen. A try/catch here is only your UI decision, with no side effect on the sheet.
const onAuthorize = async (paymentMethodData) => {
try {
const res = await fetch('/checkout/google-pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId, customer_id: customerId, paymentMethodData }),
});
if (!res.ok) throw new Error('Payment refused');
showSuccess(); // the backend answered with the status only — nothing sensitive reaches here
} catch (err) {
showError(err); // the sheet is already closed; the message is yours
}
};Pass the whole paymentMethodData through
The object arrives already unwrapped and must reach the backend unchanged. Do not JSON.parse the tokenizationData.token (it is a string, and Appmax parses it) and do not remove fields from info — Appmax uses them to validate the transaction and build the receipt. The object's shape and its mapping to the payload are in Google Pay payment.
Backend integration
Your endpoint (/checkout/google-pay in the example) should return to the front-end only what the interface needs: the operation status (approved or refused) and, optionally, a message. Data such as credentials, pay_reference, holder_document_number and full API responses must stay in the backend.
From click to payment
- The user clicks the button → the SDK opens Google's sheet inside the Appmax iframe, with the
totalfrom the lastonUpdate. You don't call any endpoint for this. - On confirmation, Google returns the
paymentMethodDataand the sheet closes. The SDK calls youronAuthorize(paymentMethodData)and then readsonUpdateagain. - In
onAuthorize, with the object at hand: create the customer, create the order and process the payment throughPOST /v1/payments/google-pay— the full payload is in the "Process the payment" section of Google Pay payment. - If the buyer closes the sheet without confirming, nothing is called: treat it as abandonment and keep the button available for another click.
Quick troubleshooting
| Symptom | Likely cause |
|---|---|
| Button doesn't appear | Apple Pay available on the device (precedence hides Google Pay), browser without Google Pay API support, or anchor mounted after init (SPA). |
| Button looks clipped or empty | Anchor with no height. Set height on the anchor; the minimum the SDK enforces is 48 px. |
| The sheet doesn't open and nothing shows in the console | Checkout inside an iframe without allow="payment". |
| The sheet opens with the wrong amount | total in cents or as a string in onUpdate, or cart changed without a new init() — see "What onUpdate must return". |
| The sheet shows the wrong store name | data-appmax-merchant-name missing on [data-appmax-checkout]; the SDK fell back to the <title> or the host. |
400 Invalid Google Pay token from the endpoint | paymentMethodData changed before sending (JSON.parse on the token or fields removed). Pass the object through as received. |
init() throws Error: External ID is required... | externalId missing or invalid. It is a synchronous exception, which does not go through onError — wrap the call in try/catch. |
In sandbox you don't need a real card
As soon as the sheet opens, Google shows its own list of test cards, in any supported browser — desktop included. Use the sandbox script, API and credentials together; details are in Testing and Sandbox on the endpoint page.