Appmax JS
What is Appmax JS
appmax.js is a JavaScript library built by Appmax for secure integration into checkout pages. Once you include the script, you don't have to worry about changes to your store's look and feel — it works quietly and efficiently.
IP collection is always mandatory
IP collection through Appmax JS is mandatory for every integration — including architectures within PCI-DSS scope. There is no API-only alternative for this step.
Server-side tokenization requires PCI-DSS
Card tokenization, on the other hand, can be done in two ways: through Appmax JS itself (recommended — the script keeps sensitive data away from your server) or directly through your API, if your architecture is within PCI-DSS scope. When you tokenize on the backend, your server touches the card number and CVV in the clear — that is only allowed inside PCI-DSS scope. If you are not sure, use the Appmax JS path. See Credit card tokenization.
How it works
Because of PCI DSS (Payment Card Industry Data Security Standard) guidelines, protecting sensitive data is critical. appmax.js was designed to:
- Keep sensitive card data from passing through your servers.
- Collect the customer's IP to secure the payment flow.
Which integration is yours
The script covers three scenarios, and what you have to implement differs quite a bit between them. Identify yours before moving on:
| Scenario | What you use | Read |
|---|---|---|
| Checkout with tokenization in the browser — the most common case | <form data-appmax-customer> for the IP + <form data-appmax-checkout> for the card, with onIp and onTokenize | IP collection and Tokenization |
| IP collection only — you tokenize through the API, within PCI-DSS scope | <form data-appmax-customer> (may be empty and hidden) + onIp. There is no card form on the page | IP collection |
| Apple Pay and Google Pay — added on top of the first scenario | Everything from the checkout case, plus onUpdate, onAuthorize and each wallet's button selectors | Implementing the Apple Pay button · Implementing the Google Pay button |
IP collection is mandatory in all three cases, and the trigger is always form[data-appmax-customer].
How to use
The script URL changes per environment:
<!-- Production -->
<script src="https://scripts.appmax.com.br/appmax.min.js"></script><!-- Sandbox -->
<script src="https://scripts.sandboxappmax.com.br/appmax.min.js"></script>The script is per environment — and it is not the same file
The two addresses serve different builds: the API URL is baked into the bundle at build time. There is no parameter or setting to point one environment's script at the other.
Mixing one environment's script with the other's external_id makes tokenization hit the wrong API, which doesn't know that merchant. The result is 404 Merchant not found, reaches your onError as TOKENIZATION_FAILED with status: 404 — the message is the generic Failed to process payment: Failed to tokenize card., and what names the real cause is details. See Environments for the full URL list.
Pass an options object, declaring only the callbacks for the events you care about:
window.AppmaxScripts.init({
externalId: 'YOUR_EXTERNAL_ID',
onIp: ({ ip }) => { customer.ip = ip },
onTokenize: ({ token }) => completePayment(token),
onError: (err) => console.error(err.code, err.message, err.stage),
});| Option | Required | Description |
|---|---|---|
externalId | Yes for tokenization and wallets | Identifier of the app installation in the store. See external-id. |
onIp | Recommended | Called when the customer's IP is collected. Receives { ip }. |
onTokenize | Yes for tokenization | Called when the card is tokenized. Receives { token }. |
onError | Recommended | Called on any failure, with a structured object — see The onError contract. It is optional: the script always logs the failure to console.error, prefixed with [Appmax]. |
onUpdate | Yes for Apple Pay and Google Pay | Called when the PaymentSheet opens and on every change to it (Apple Pay), or when the button loads and after each attempt (Google Pay). Must return the current cart, with numeric values in BRL — see the return contract. Google Pay uses only total. |
onAuthorize | Yes for Apple Pay and Google Pay | Called when the payment is authorized. The same callback serves both wallets: it receives the Apple Token or Google Pay's paymentMethodData. For Apple Pay, reject the Promise to signal failure — see how to signal failure; for Google Pay the return value is not used — see how onAuthorize behaves. |
onSuccess | No | Single callback, kept for backward compatibility. Use onIp and onTokenize in new code — see onSuccess: the legacy shape. |
onIp and onTokenize replace onSuccess for their respective event: each event calls exactly one callback, never both.
Implementing a wallet button?
onUpdate and onAuthorize are only half the story — the button also requires specific DOM selectors and its own load order. See the dedicated walkthrough in Implementing the Apple Pay button with Appmax JS and, for Google Pay, Implementing the Google Pay button with the Appmax JS.
The positional form
The old signature still works and will not be removed:
window.AppmaxScripts.init(onSuccess, onError, externalId, onUpdate, onAuthorize);It gives you no access to the per-event callbacks — the positional onSuccess receives both payloads and you have to tell them apart (see onSuccess: the legacy shape). Prefer the object for new code.
Nothing changes in this form. onSuccess receives { ip } on collection and the token string on tokenization; onError receives the message as a string, and only on a tokenization failure. This is the same behaviour as always, and it will not change — if you already integrated this way, there is nothing to update.
If you only need IP collection, the tokenization and wallet fields (Apple Pay, Google Pay) can be omitted:
window.AppmaxScripts.init({ onIp: ({ ip }) => { customer.ip = ip } });externalId is required for tokenization through Appmax JS
Without externalId, submitting any form with data-appmax-checkout fails with MISSING_EXTERNAL_ID before the HTTP call is made. The value is the same external_id defined during app installation in the store — persist it in your database and render it into the checkout template. See external-id for the full reference.
external_id is per environment. A sandbox installation's id does not exist in the production registry: using it there returns 404 Merchant not found, which arrives in onError as TOKENIZATION_FAILED / status: 404. When promoting to production, redo the installation and store the new external_id.
If your architecture is within PCI-DSS scope and you prefer tokenizing straight through your API (without the script), use the backend path with the merchant access token (Authorization: Bearer) instead of externalId — see Credit card tokenization.
init() can throw synchronously for wallets
If you pass onUpdate and onAuthorize (the Apple Pay or Google Pay flow) without a valid externalId, init throws Error("External ID is required for Apple Pay use.") synchronously, inside the call itself — not through onError. In React, that blows up inside useEffect and can take down the whole tree if there is no try/catch around the call.
The onError contract
What onError receives depends on which form of init() you used:
Form of init() | What reaches onError | When it is called |
|---|---|---|
| Options object | a structured object | on any step: fingerprint, ip, setup, tokenize |
| Positional (legacy) | the message as a string | only on a tokenization failure |
The difference is deliberate: the positional form always delivered a string, and changing that would break live integrations. In both forms every failure still goes to console.error with the [Appmax] prefix — including the steps the positional form does not forward.
Fields of the structured object:
| Field | Contents |
|---|---|
code | MISSING_EXTERNAL_ID, IP_FETCH_FAILED, FINGERPRINT_FAILED, FORM_SETUP_FAILED, TOKENIZATION_FAILED, APPLE_PAY_FAILED or GOOGLE_PAY_FAILED |
message | Human-readable message. |
stage | Which step failed: ip, fingerprint, setup, tokenize, apple_pay or google_pay. |
status | HTTP status code, when the failure came from the API. |
details | Response body, when available — this is what tells a 404 from a 422. |
window.AppmaxScripts.init({
externalId: 'YOUR_EXTERNAL_ID',
onTokenize: ({ token }) => completePayment(token),
onError: (err) => {
if (err.code === 'TOKENIZATION_FAILED' && err.status === 404) {
// external_id from another environment, or an incomplete installation
}
console.error(err.code, err.stage, err.status, err.details);
},
});Read status before opening the Network tab: 404 means the external_id is invalid for that environment, 422 means payload, 5xx means unavailability.
IP failures arrive here too
In the object form, a failure during IP collection reaches onError as IP_FETCH_FAILED / stage: "ip". In the positional form it only goes to console.error, as it always did.
onSuccess: the legacy shape
If you use onSuccess (positional or in the object), the same callback serves both events, with different payloads:
| When | What arrives |
|---|---|
| IP collection | an object: { ip: "187.0.0.1" } |
| Card tokenization | the token string: "tok_..." |
In this callback the token is not wrapped: data.token is undefined. To receive it as { token }, use onTokenize.
function onSuccess(data) {
if (data?.ip) {
customer.ip = data.ip;
return;
}
// here data is the token string
completePayment(data);
}Telling them apart by type (typeof data === 'string') works, and will keep working.
This shape is stable
onSuccess is deprecated but will not be removed, and its payload does not change. The script is served from a single URL, with no versioning: changing the shape of this callback would change the behaviour of every live integration at once. That is why the new path is additive — onIp and onTokenize — instead of a change to onSuccess.
DOM contract: init is not reactive
AppmaxScripts.init(...) runs querySelector exactly once, at the moment it is called, and does not watch the DOM afterwards. On a traditional page (server-rendered HTML, nothing changes after load) this is transparent. In React, Vue or any SPA, it needs care:
- The IP trigger, the card form and the Apple Pay and Google Pay buttons must exist in the DOM before
initruns. If the element shows up later — behind a route, a checkout step, av-if/conditional — the SDK never finds it. No error, no log: the click or the collection simply doesn't happen. - Calling
init()again replaces the handler instead of stacking. One submit produces one tokenization, no matter how many timesinitran — so React StrictMode (which mounts effects twice in development) does not cause double charges. Even so, callinitonce per page load: each call redoes IP collection and the fingerprint. externalIdis captured atinit, not read on each use. Changing the value afterwards does not affect subsequent requests. IfexternalIdchanges (different store, different environment), reload the page.
Available features
Customer IP collection
Collection happens when the SDK finds a <form data-appmax-customer> in the DOM — you don't have to submit anything or wait for user interaction. Without that form at init time, onIp simply never fires (see "DOM contract" above — it is a silent failure, with no error at all).
It can be the customer data form you already have:
<form id="customer-form" data-appmax-customer>
<div>
<label for="first-name">First name:</label>
<input type="text" id="first-name" name="first-name" required />
</div>
<div>
<label for="last-name">Last name:</label>
<input type="text" id="last-name" name="last-name" required />
</div>
<div>
<label for="email">Email:</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label for="phone">Phone:</label>
<input type="text" id="phone" name="phone" required />
</div>
<button type="submit">Submit</button>
</form>If your page has no customer form — a multi-step checkout, an SPA that keeps the customer in state — use an empty, hidden form purely as the trigger:
<form data-appmax-customer hidden></form>The SDK injects a <input hidden> into form[data-appmax-customer]
When it finds that form, the SDK inserts an <input type="hidden" id="ip" name="ip" data-appmax-ip> into it through direct DOM manipulation — outside your framework's control. On a traditional page it rides along with the native submit, and that's how the IP reaches your backend.
The append is idempotent: calling init() again updates the value instead of appending a second field.
Nothing is injected into form[data-appmax-checkout] — if you're looking for the IP field in the card form, it isn't there.
In an SPA, that node can be dropped on the next re-render with no warning. Don't rely on it: use the IP that arrives in onIp, which is the dependable source in both cases.
For frameworks like Vue.js, the trigger must be rendered before init — see "DOM contract" above. Retrieve the IP in the callback:
<template>
<form data-appmax-customer hidden></form>
</template>const customer = ref({
first_name: '',
last_name: '',
email: '',
phone: '',
ip: ''
})
onMounted(() => {
const script = document.createElement('script')
script.src = 'https://scripts.sandboxappmax.com.br/appmax.min.js'
script.onload = () => {
if (window.AppmaxScripts) {
window.AppmaxScripts.init({
onIp: ({ ip }) => { customer.value.ip = ip },
onError: (err) => console.error(err.code, err.message)
})
}
}
document.head.appendChild(script)
})Need a complete example with card tokenization? See SPA checkout (React/Next.js).
Payment tokenization
Tokenization happens when a form carrying the data-appmax-checkout attribute is submitted. Sensitive card data is converted into a secure token.
Requires externalId at initialization
To tokenize through the script, initialize with the store's externalId. Without it, the form submit fails with MISSING_EXTERNAL_ID. See external-id.
The token arrives in the onTokenize({ token }) callback. You don't have to touch HTTP or headers. If you need to implement tokenization manually (without the script), the underlying endpoint contract lives at Credit card tokenization — including the alternative backend path (merchant Bearer), allowed only within PCI-DSS scope.
<form id="payment-form" method="POST" data-appmax-checkout>
<div>
<label for="card-number">Card number:</label>
<input type="text" id="card-number" name="card-number"
appmax-form-element="number" required />
</div>
<div>
<label for="card-holder-name">Cardholder name:</label>
<input type="text" id="card-holder-name" name="card-holder-name"
appmax-form-element="holder_name" required />
</div>
<div>
<label for="exp-month">Expiration month:</label>
<input type="text" id="exp-month" name="exp-month"
appmax-form-element="expiration_month" required />
</div>
<div>
<label for="exp-year">Expiration year:</label>
<input type="text" id="exp-year" name="exp-year"
appmax-form-element="expiration_year" required />
</div>
<div>
<label for="cvv">CVV:</label>
<input type="text" id="cvv" name="cvv"
appmax-form-element="cvv" required />
</div>
<button type="submit">Pay</button>
</form>The name attributes are required and have fixed values — they are how the SDK reads the form, via new FormData(form). appmax-form-element is an HTML attribute (not a CSS class), kept for backward compatibility, which is not read by the SDK and can be omitted.
| Field | name (required) | appmax-form-element (optional) |
|---|---|---|
| Credit card number | card-number | number |
| Cardholder name | card-holder-name | holder_name |
| Expiration month | exp-month | expiration_month |
| Expiration year | exp-year | expiration_year |
| Security code (CVV) | cvv | cvv |
The two values are different — and this breaks integrations
Note that in every row the name is not the same as the appmax-form-element value. Anyone who assumes they are the same and writes name="number", name="holder_name" and so on gets only the CVV right — the one field where both values coincide.
The symptom is a 422 listing number, holder_name, expiration_month and expiration_year as field is required, with the form visibly filled in. Renaming or removing any name makes that field arrive empty at tokenization.
Consequence for SPAs: what reads the fields is FormData — that is, the DOM element's value property, not framework state. A controlled input works, because React keeps the DOM in sync. What doesn't work is value without onChange: React treats the field as read-only, the shopper can't type, and FormData reads the initial value.
Browser fingerprint
getFingerprint() returns the device signals the script is able to collect. It is a direct call, independent of init(): it returns a Promise, doesn't go through a callback and doesn't require externalId — the script just has to be loaded.
const basic = await AppmaxScripts.getFingerprint();
const full = await AppmaxScripts.getFingerprint({ meta: true });With no argument, it returns the basic signals: userAgent, language, screenWidth, screenHeight, colorDepth, deviceMemory, hardwareConcurrency, touchPoints, timezone and isPrivate.
With { meta: true }, the same object plus a meta key holding the detection signals — which comes back null if signal collection fails:
meta field | Contents |
|---|---|
bot | { result: "notDetected" | "bad", reasons: [...] } |
incognito | true, false or null |
developerTools | Heuristic for open DevTools. |
virtualMachine | Inferred from the WebGL renderer. |
privacySettings | Brave, randomized canvas, resistFingerprinting. |
tampering | Anomalies between APIs that should agree. |
webgl | { vendor, renderer } |
extras | math, voices, mediaDevices, network, css |
The return value stays with you — Appmax receives nothing
This data serves your own risk analysis: the value stays in the browser and is only sent if you send it. It is not Appmax's anti-fraud token (payment.Fingerprint.token), which is generated by a different library and has a different shape and purpose.
The shape of meta may change — treat the detection fields as informational, not as a contract.
Collection cost
With meta: true, collection touches WebGL, media devices and voices, and takes a few hundred milliseconds. Fire it on page load and keep the Promise; only await it on submit.
AppCheckout and AppmaxScripts are the same object — use whichever you prefer.
Traditional pages and SPAs: what changes
The choices diverge on concrete points:
| MPA (PHP, HTML) | SPA (React/Next, Vue) | |
|---|---|---|
| IP trigger | <form data-appmax-customer> — see the note below | empty <form data-appmax-customer hidden> |
Injected <input hidden name="ip"> | rides the native submit, if there is one | ignore it — use the IP from onIp |
When to call init() | script in <head>, stable DOM | after externalId arrives from your backend, once per page load |
| Card form | on the page since first paint | always mounted, hidden with CSS — never behind a conditional |
preventDefault | the SDK's is enough | you need yours too |
The IP trigger does not depend on MPA vs SPA
What decides is how you send the customer data:
- Native form submit (the browser POSTs to your route): mark that form with
data-appmax-customer. The SDK injects<input type="hidden" name="ip">into it, and the IP travels with the POST — you write no JavaScript for it. - Sent via JavaScript (
fetch/XHR, withpreventDefaulton submit): an empty<form data-appmax-customer hidden></form>works purely as the trigger, and you read the IP fromonIp. Your real form stays separate, without the attribute.
A PHP or static HTML page that builds the payload in JS uses the empty form, just like any SPA. The reverse holds too.
preventDefault deserves its own note: in an SPA there is a real window between the component mounting and init() finishing, since it is async and waits on the network. A click in that window makes the browser do a native POST and leave your app — the classic "clicked pay and the page turned into a 405". Register your own preventDefault on the form.
For a complete React/Next.js example, see SPA checkout.
Troubleshooting: symptom and cause
| Symptom | err.code | Likely cause |
|---|---|---|
| The submit does nothing — no error, no request | — | The form wasn't in the DOM when init() ran (see "DOM contract"); or there is an .appmax-ip element on the page, which ends initialization before the card listener is registered — remove it and use <form data-appmax-customer> for the IP |
Tokenizes with 201 and the payment never fires | — | Handler reading data.token on a payload that isn't an object. Use onTokenize |
| Every card field arrives empty | TOKENIZATION_FAILED | The form's name attributes were renamed or removed |
422 listing number, holder_name, expiration_month, expiration_year as field is required, with the form filled in | TOKENIZATION_FAILED | The name attributes got the appmax-form-element values. Only cvv gets through, the one field where both coincide |
Failed to process payment: Failed to tokenize card. in onError, with status: 404 | TOKENIZATION_FAILED (status: 404) | Three possible origins: an external_id from another environment, a script from another environment (see step 1), or an external_id that isn't the installation's — see external-id |
| The submit fails before any request | MISSING_EXTERNAL_ID | init() without externalId |
| The IP never reaches the callback | IP_FETCH_FAILED | Network failure during collection; or there is no form[data-appmax-customer] in the DOM (in which case there is no error at all) |
The browser navigates away / 405 on pay | — | The click landed before init() finished, which is async. Register your own preventDefault on the form |
The err.code column applies to init() with the options object. In the positional form onError receives only the message, and only on a tokenization failure — the other steps show up in console.error.
Which bundle did this page load?
While the script doesn't expose a version number, you can check in the console whether the page has the current bundle:
document.querySelector('[data-appmax-checkout]').dataset.appmaxBound
// "true" → current bundle
// undefined → earlier bundle (probably cached)The attribute is only written by the current bundle. It's the first thing to check when behavior doesn't match this page.
Script version and caching
The script is always published at the same address, with no version in the URL path. Two practical consequences:
- Caching is roughly 4 hours. A fix published by Appmax can take that long to reach the shopper's browser, and there is no way to force it. In development, reload bypassing the cache.
- There is no way to pin a version. If the behavior you observe doesn't match what's documented here, check the
dataset.appmaxBoundmarker above before digging into your own code.
Recent script changes, for anyone migrating from an older bundle:
| What changed | Before | Now |
|---|---|---|
| Token in the callback | raw string in onSuccess | unchanged in onSuccess; onTokenize({ token }) is the new path |
| Errors | console.error and generic messages | unchanged in the positional form; structured { code, message, stage, status?, details? } in the options object |
| Callbacks | only the polymorphic onSuccess | dedicated onIp and onTokenize, and init() accepts an options object |
| Card submit | two listeners per init() — one click produced two tokenizations | one click, one tokenization |
| IP collection | two GET /ip, two callbacks and two injected <input name="ip"> | one of each, with an idempotent append |
| Fingerprint failure | aborted initialization, leaving the form without a listener | doesn't block the bind, and reports FINGERPRINT_FAILED |
Interactive test
Use the playground below to try the CDN features straight from your browser — no installation required.