Apple Pay
Accept Apple Pay on the web with the Web SDK button: domain enablement, availability, events, and reconciliation.
Apple Pay is accepted on the web through the Web SDK button (@tonder.io/web-sdk). The SDK
renders the button, presents Apple's payment sheet, and processes the charge; the transaction
lands on /process/ with the same shape as a card payment and fires the same webhooks β your
reconciliation does not change.
Apple Pay does not exist in the legacy SDK or as a Direct API payment_method.type. The only
integration surface is the Web SDK's button component.
Try it live: the Apple Pay demo runs this page's code β open it in Safari (macOS, iOS or iPadOS) to see the real button and the availability result.
Step 1: ask Tonder to enable it and register your domains
Not a code step, and the most common reason a correct integration fails in production.
- Tonder handles Apple β you never contact them and you do not need an Apple developer account.
- Your part is sending Tonder the list of domains that will show the button β subdomains count separately β and hosting the verification file Tonder gives you.
Start this step before writing code: it gates everything else. The exact steps and failure modes are in the SDK README.
Step 2: render the button
Apple Pay is the one flow that does not end in a pay() call. Apple requires the payment sheet to
open in the same tick as the tap, so the SDK owns the click, and the result comes back on the
events.payment callbacks instead of a returned promise.
Those callbacks are not an Apple Pay-only mechanism β they fire for every method the SDK charges, so one set of handlers covers the whole checkout.
The SDK renders the button into an element you provide. It has to exist before mount() runs, and
it stays empty β do not put a button, a label, or an icon inside it:
<div id="tonder-apple-pay-button"></div>const tonder = createTonder({
api_key: tonderPublicConfig.api_key,
environment: 'stage', // switch to 'production' when you go live
session: { customer: { email: 'ada@example.com' } },
// Fires for EVERY payment method, not just Apple Pay. With pay() these run
// alongside the promise it returns; Apple Pay has no promise, so for it
// these callbacks are the only channel.
events: {
payment: {
on_completed: (transaction) => handleResult(transaction),
on_error: (error) => showError(error.code),
on_cancel: () => { /* shopper dismissed the sheet β not an error */ },
},
},
});
await tonder.init();
// Ask before you render. The answer is an OBJECT, never a bare boolean:
// { available: true }, or { available: false, code, message }. Reading the
// object itself as a condition would always be truthy β read .available.
const availability = tonder.isApplePayAvailable();
if (availability.available) {
const button = tonder.create('apple_pay_button', {
// Called SYNCHRONOUSLY when the shopper taps, so it reads whatever the
// cart holds at that moment β amount, currency and references can all
// change after mount without remounting the button. It must not be
// async: Apple requires the sheet to open in the same tick as the tap.
payment: () => ({
amount: 250,
currency: 'MXN',
return_url: 'https://merchant.example.com/return',
client_reference: 'ORD-001',
}),
});
await button.mount();
} else {
// Do not guess at the reason: only APPLE_PAY_UNSUPPORTED_BROWSER means
// "offer another method"; the other two codes are yours to fix.
console.info('Apple Pay hidden:', availability.code, availability.message);
}When availability is false
code | Means | What to do |
|---|---|---|
APPLE_PAY_UNSUPPORTED_BROWSER | The browser cannot run Apple Pay | Offer another payment method β the only code that is the shopper's browser |
APPLE_PAY_NOT_ENABLED | Apple Pay is not enabled for your business | Yours to fix β see step 1 |
NOT_INITIALIZED | You called before init() finished | Yours to fix β await tonder.init() |
Customize the button
Safari draws the control natively, so Apple only allows these keys in
customization.apple_pay_button β anything else is ignored:
| Key | Example |
|---|---|
type | 'check-out' (the call to action) |
style | 'black' |
locale | 'es-MX' (the label's language) |
width / height | '100%' / '48px' |
border_radius | '8px' |
The SDK reads customization once at createTonder() β changing it means building a new
instance and mounting again. The same applies to api_key and session.customer.
A settled attempt consumes its references, whatever the outcome. On on_completed, mint a
fresh client_reference and idempotency_key so the next tap is a new order with its own
idempotency scope.
Where the button goes in your checkout
Apple Pay earns its conversion lift by letting the shopper skip the card form entirely β so the layout that works is the express one: the button first, a divider, and your other methods underneath, untouched.
Apple Pay is one more payment method, not a replacement. Keep your credit and debit card
button β and every other method β visible alongside it. A shopper whose browser or wallet can't
use Apple Pay still needs a way to pay: that's exactly the
APPLE_PAY_UNSUPPORTED_BROWSER case.
The practices below follow Apple's Human Interface Guidelines:
| Practice | Why |
|---|---|
| Offer it early β express position, above the card form | Its value is skipping the form; burying it behind a "choose your method" step throws that away |
| Give it at least the prominence of your primary pay button | Same width or larger, and never below the fold when the card button isn't |
Match your checkout's controls with customization.apple_pay_button | width, height and border_radius are the sanctioned way to fit it in |
| Only render it when it's available | Never show a disabled or grayed-out Apple Pay button |
| Never rebuild or decorate it | The container stays empty and Safari's native control is the only permitted rendering |
The mistake to avoid is the replacement: swapping the card button out for Apple Pay strands every shopper it isn't available for. Measured checkouts keep both.
What this guide leaves out β a custom container id and releasing the button on a route change β is in Apple Pay in the SDK README.
pay({ payment_method: { type: 'apple_pay' } }) is rejected on purpose. Apple Pay cannot be
charged through pay() β Apple's gesture requirement is why. Use the button component.
Step 3: your reconciliation does not change
Nothing to do. The Apple Pay charge lands on /api/v1/process/ like your card charges, produces a
transaction with the same shape, and fires the same webhook. Your existing handler already covers
it.
Send client_reference in the button's payment data exactly as you do today and your
correlation keeps working. See the Webhooks model.
Test Apple Pay
| Check | What proves it worked |
|---|---|
| The sheet opens on a real device | The iOS Simulator cannot test web Apple Pay |
| A decline | Arrives on on_completed with a declined status, not on on_error |
client_reference | Appears in the webhook and correlates your order |
The decline row is the one that surprises people: on_completed means the charge reached a final
answer, not that the answer was yes.
Sandbox test cards
Tonder's own test cards (4000 0000 0000 0077 and the rest of
Card test cases) cannot be added to Wallet β Apple's sandbox only
provisions Apple's own test credentials. To run a real Apple Pay sheet in sandbox:
In App Store Connect go to Users and Access β Sandbox β Testers and invite a tester.
Sign out of iCloud on the test iPhone or iPad and sign in with the sandbox tester account. The device region must be one where Apple Pay is available.
Wallet β Add Credit or Debit Card, enter one of the cards below manually, then open your checkout in Safari and pay.
Apple lists these as its latest FPANs (August 2025). Only the networks Tonder processes are reproduced here; the full list is on Apple Pay sandbox testing.
Visa
| Card number | Expiry | CVV |
|---|---|---|
4622 9431 2318 9285 | 12/2028 | 096 |
4622 9431 2318 9293 | 12/2028 | 413 |
4622 9431 2318 9301 | 12/2028 | 752 |
4622 9431 2318 9319 | 12/2028 | 356 |
4622 9431 2318 9327 | 12/2028 | 994 |
4622 9431 2318 9335 | 12/2028 | 777 |
4622 9431 2318 9343 | 12/2028 | 868 |
4622 9431 2318 9350 | 12/2028 | 792 |
4622 9431 2318 9368 | 12/2028 | 161 |
4622 9431 2318 9376 | 12/2028 | 732 |
Mastercard
| Card number | Expiry | CVC |
|---|---|---|
5204 2452 5046 0049 | 01/30 | 111 |
5204 2452 5052 2095 | 01/30 | 111 |
5204 2452 5110 7599 | 01/30 | 111 |
5204 2452 5305 0839 | 01/30 | 111 |
5204 2452 5471 8095 | 01/30 | 111 |
American Express β provisions only when the device region is set to the United States or
the United Kingdom. If Wallet asks for an OTP, enter 111111.
| Card number | Region | Expiry | CID |
|---|---|---|---|
37273 57230 32000 | United States | 12/28 | 7777 |
37272 79248 51007 | United States | 12/28 | 1111 |
37272 67850 11008 | United States | 12/28 | 1111 |
37677 17299 24003 | United States | 12/28 | 1111 |
37677 47309 52005 | United States | 12/28 | 1111 |
37420 05569 95003 | United Kingdom | 12/28 | 1111 |
37420 05590 81009 | United Kingdom | 12/28 | 1111 |
37420 05605 82003 | United Kingdom | 12/28 | 1111 |
37420 07381 38001 | United Kingdom | 12/28 | 7777 |
These cards work only in Apple's sandbox. They will not provision on a production Apple ID and
must never be used against app.tonder.io. The expiry Wallet shows for the device card (DPAN)
does not need to match the one above.
