Skip to main content

Accept Apple Pay & Google Pay

Add standalone Apple Pay and Google Pay buttons — the buyer taps a wallet, authorizes with Face ID / a fingerprint, and you get a vp_pmt_* token. Wallets ride on the same Elements session as your card field, so you can offer "wallets on top, or pay with card below" from one integration.

There are two ways to do it:

  • Path A — the express-checkout element (recommended). The SDK renders the buttons, runs the native wallet sheet, vaults the result, and hands you a vp_pmt_* — you charge it exactly like a card.
  • Path B — bring your own button. You render a native ApplePaySession / Google Pay button yourself and call the wallet endpoints directly. More control, more code.
Availability

Wallets are available where your account's gateway supports them. The wallet endpoints return 501 endpoint_not_implemented until they're enabled for your account — treat a 501 as "not yet available on this account," not an error to retry. A button only renders on a domain you've verified for that wallet (see Wallet domain setup); on an unverified domain the button simply doesn't appear.

Prerequisite: verify your domain

Apple Pay and Google Pay only show a button on domains you've proven you own. Complete Wallet domain setup first — Apple Pay needs a hosted verification file; Google Pay verifies automatically.


Mount the express-checkout element in its own collection (separate from the card field — see the two-collection rule). On a successful tap it vaults the wallet credential and resolves a vp_pmt_*, which you charge with POST /v1/payment_intentsthe same charge path as a card.

// Same Elements session as the card field (integrationMode: "elements").
await vora.sessions.retrieve(sessionId);

const walletCollection = vora.elements.create();

const express = walletCollection.create("express-checkout", {
wallets: ["apple_pay", "google_pay"], // default
buttonLayout: "horizontal", // or "vertical"
label: "My Store",
});

express.on("change", async (e) => {
if (e.error) {
// Buttons unavailable — domain not verified for this wallet,
// device unsupported, or wallets not enabled. Card is your fallback.
return;
}
if (e.complete) {
// Buyer authorized in the native sheet and the credential vaulted.
const result = await walletCollection.submit();
if (result.token) {
// result.wallet is "apple_pay" | "google_pay" (for your analytics).
await chargeOnYourServer(result.token, result.wallet); // vp_pmt_*
}
}
});

express.mount("#wallets"); // your own <div id="wallets">

Your server then charges the vp_pmt_* exactly as in the card flow, Step 5POST /v1/payment_intents { payment_method: { id } }. There is no wallet-specific charge endpoint in this path.

Why the button might not appear. If the wallet isn't verified for your domain, the device doesn't support it, or wallets aren't enabled for your account, the element renders zero buttons and fires a change event with an error code (e.g. frame_wallet_domain_unverified) — it never renders a button that would fail at tap time. Always keep pay-by-card as the fallback.


Path B — bring your own button

If you need full control over the button and the wallet sheet, render the native wallet API yourself and call the public wallet endpoints directly. Both wallet-session calls and the charge use your publishable key (a secret key is rejected with 403).

Unlike Path A, the wallet charge here is immediate: POST /v1/public/tokens with instrument: "wallet" charges the session in one call (a wallet cryptogram is single-use, so it can't be pre-vaulted). Do not also call /v1/payment_intents for the same session — that double-charges.

Apple Pay (native)

Apple Pay runs in Safari on Apple devices only. Gate the button on window.ApplePaySession?.canMakePayments().

const session = new ApplePaySession(3, {
countryCode: "US",
currencyCode: "USD",
merchantCapabilities: ["supports3DS"],
supportedNetworks: ["visa", "masterCard", "amex", "discover"],
total: { label: "My Store", amount: "49.99" },
});

// 1. Validate the merchant — forward Apple's validationURL to Vonpay.
session.onvalidatemerchant = async (event) => {
const res = await fetch("https://checkout.vonpay.com/v1/public/wallets/apple-session", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PUBLISHABLE_KEY}` },
body: JSON.stringify({ session_id: sessionId, validationUrl: event.validationURL }),
});
session.completeMerchantValidation(await res.json()); // pass the response through verbatim
};

// 2. Charge the authorized payment — instrument:"wallet" charges immediately.
session.onpaymentauthorized = async (event) => {
const res = await fetch("https://checkout.vonpay.com/v1/public/tokens", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PUBLISHABLE_KEY}` },
body: JSON.stringify({
instrument: "wallet",
session_id: sessionId,
wallet_type: "apple_pay",
wallet_token: event.payment.token, // Apple's PKPaymentToken
}),
});
const charge = await res.json();
session.completePayment(
charge.charged || charge.status === "succeeded"
? ApplePaySession.STATUS_SUCCESS
: ApplePaySession.STATUS_FAILURE,
);
};

session.begin();

The wallet domain is derived server-side from the request Origin — you don't send it. Vonpay allowlists Apple's validationUrl to *.apple.com before calling it, so a forged URL is rejected.

Google Pay (native)

Load Google's pay.js, then ask Vonpay for the gateway parameters and pass them straight into the Google Pay tokenizationSpecificationforward them verbatim; never hardcode a gateway name.

// 1. Get the gateway parameters for this session.
const gw = await fetch("https://checkout.vonpay.com/v1/public/wallets/google-session", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PUBLISHABLE_KEY}` },
body: JSON.stringify({ session_id: sessionId }),
}).then((r) => r.json());

// 2. Build the payment-data request with the values Vonpay returned.
const paymentData = await paymentsClient.loadPaymentData({
apiVersion: 2,
apiVersionMinor: 0,
allowedPaymentMethods: [{
type: "CARD",
parameters: {
allowedAuthMethods: ["PAN_ONLY", "CRYPTOGRAM_3DS"],
allowedCardNetworks: ["AMEX", "DISCOVER", "MASTERCARD", "VISA"],
},
tokenizationSpecification: {
type: "PAYMENT_GATEWAY",
parameters: { gateway: gw.gateway, gatewayMerchantId: gw.gatewayMerchantId },
},
}],
merchantInfo: { merchantId: YOUR_GOOGLE_PAY_MERCHANT_ID }, // your own, for production
transactionInfo: { totalPriceStatus: "FINAL", totalPrice: "49.99", currencyCode: "USD", countryCode: "US" },
});

// 3. Charge — same immediate POST /v1/public/tokens as Apple Pay.
const charge = await fetch("https://checkout.vonpay.com/v1/public/tokens", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${PUBLISHABLE_KEY}` },
body: JSON.stringify({
instrument: "wallet",
session_id: sessionId,
wallet_type: "google_pay",
wallet_token: paymentData.paymentMethodData.tokenizationData.token,
}),
}).then((r) => r.json());

Google Pay in production requires your own Google Pay Console merchant id (merchantInfo.merchantId); without it only a test/placeholder button renders.

The wallet charge result

POST /v1/public/tokens with instrument: "wallet" returns a charge result, not a token:

{
"charged": true,
"status": "succeeded",
"wallet_type": "apple_pay",
"id": "vp_pmt_live_…",
"card": { "brand": "visa", "last4": "4242" }
}
FieldNotes
chargedtrue only when status is succeeded. Branch on this, not the HTTP code — a decline is 200 with charged: false.
statussucceeded | declined | pending (202 while in-flight).
wallet_typeapple_pay | google_pay.
idA reusable vp_pmt_* when the buyer's credential was stored for reuse; otherwise null (guest charge). If present, you can charge it again off-session via /v1/payment_intents.
cardPCI-safe { brand, last4 }, present on success.

The charge is session-bound and replay-safe: the session flips pending → processing before charging, so a retried request never double-charges — a retry against an already-succeeded session returns the original result. If the session is already complete you'll get 409 session_already_completed; do not create a new session to retry (that would charge twice).


Constraints at a glance

ConstraintDetail
BrowserApple Pay → Safari on macOS/iOS. Google Pay → a Chromium browser signed into a Google account.
DomainMust be verified for the wallet (setup). Derived server-side from Origin.
KeysWallet endpoints are publishable-key only — a secret key returns 403.
AvailabilityFeature-gated per gateway; 501 endpoint_not_implemented until enabled for your account.
Card dataPAN/CVC never reach you; wallet device tokens are single-use cryptograms vaulted server-side. You hold only the vp_pmt_* + display metadata.

What's next