Accept Apple Pay & Google Pay
Add standalone Apple Pay and Google Pay buttons — the buyer taps a wallet and authorizes with Face ID / a fingerprint. 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.
On the default wallet path, the authorized wallet is charged there and then — money moves during the tap, before your server is involved. Do not charge the result again; that bills the buyer twice. Branch on the charge result, never on the presence of a token. This applies to both paths below.
express-checkout elementLooking for a wallet button / Apple Pay button / Google Pay button? It's the express-checkout element — one element renders both wallets. There is no wallet-button, applePay, or googlePay element name. In the default integrationMode: "embed" you don't add any element at all: the wallet buttons appear inside the card mount automatically when the buyer's device and your domain are eligible (see Elements reference → Apple Pay & Google Pay). Use the standalone express-checkout element below when you create the session with integrationMode: "elements".
There are two ways to do it:
- Path A — the
express-checkoutelement (recommended). The SDK renders the buttons, runs the native wallet sheet, registers the authorized wallet, and hands you the charge result. - 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.
Verify your domain first. Apple Pay and Google Pay only show a button on a domain you've proven you own — complete Wallet domain setup before anything else (Apple Pay needs a hosted verification file; Google Pay verifies automatically). On an unverified domain the button simply doesn't appear.
Availability is per-gateway. 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. Apple Pay is generally available once your domain is verified; Google Pay's charge step additionally depends on your gateway connection, so verify Google Pay end-to-end on your own account before relying on it in production.
Path A — the express-checkout element (recommended)
Mount the express-checkout element in its own collection (separate from the card field — see the two-collection rule). On a successful tap the SDK registers the authorized wallet and submit() resolves the charge result.
// 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) return; // buyer hasn't authorized yet
// result.wallet is "apple_pay" | "google_pay" (for your analytics).
const result = await walletCollection.submit();
if (result.chargeStatus === "requires_action") {
// 3-D Secure. NOTHING has been charged — send the buyer to authenticate.
window.location.href = result.redirectUrl;
return;
}
if (result.charged) {
// Money has ALREADY moved. Do NOT charge result.token.
return awaitWebhookThenFulfil();
}
if (result.chargeStatus === "pending") {
// In flight — the outcome arrives on the charge.* webhook.
return awaitWebhookThenFulfil();
}
// Vault-only accounts only: a token and no charge. This one you charge.
await chargeOnYourServer(result.token, result.wallet); // vp_pmt_*
});
express.mount("#wallets"); // your own <div id="wallets">
What submit() resolves to
Check the arms in the order above. charged and chargeStatus are what tell the arms apart — token does not, because a charge that had a buyer to save against returns one and has already taken the money.
| Result | What happened | What you do |
|---|---|---|
chargeStatus: "requires_action" + redirectUrl | The issuer wants the buyer to authenticate. No money moved, no token minted. Not a success. | Send the buyer to redirectUrl. See Wallets and 3-D Secure. |
charged: true (with chargeStatus: "succeeded") | The buyer has been charged. A reusable vp_pmt_* may also be present — it is real, but it is not a handle to bill this payment later. | Nothing. Fulfil on the charge.* webhook. Never call POST /v1/payment_intents with result.token for this payment. |
chargeStatus: "pending" | Charged, awaiting settlement. No token yet. | Wait for the charge.* webhook. Don't fulfil, don't retry. |
token, no charged | Nothing was charged — your account is set up to vault wallets and charge separately. | Charge it yourself, exactly as in the card flow, Step 5. |
Charge-direct is the default on every account where wallet payments are enabled at all — if wallets are not yet available on your processor connection the endpoints return 501 rather than behaving differently. Vaulting instead is a per-account arrangement — creating the session differently does not on its own switch a wallet to vaulting, so don't assume the last arm without confirming your account is set up for it. Writing the branch above handles either.
Declines don't reach these arms: they throw frame_payment_declined, the same code as the card 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).
As in Path A, the wallet charge is immediate:
POST /v1/public/tokenswithinstrument: "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_intentsfor the same session — that double-charges. The difference here is only that you make the call yourself.
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();
// Only a DECLINE closes the sheet as a failure. `pending` and
// `requires_action` are live outcomes — telling the buyer they failed
// invites a retry, and a retry here is a second real charge.
if (charge.status === "declined") {
session.completePayment(ApplePaySession.STATUS_FAILURE);
return;
}
session.completePayment(ApplePaySession.STATUS_SUCCESS);
if (charge.status === "requires_action") {
// 3DS — nothing charged yet. Dismiss the sheet first, then send them.
window.location.href = charge.next_action.redirect_to_url.url;
return;
}
// succeeded or pending: money is moving. Confirm on the charge.* webhook
// before fulfilling — never on this result.
};
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 tokenizationSpecification — forward 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());
// 4. Branch on the RESULT. Every arm below is reachable.
if (charge.status === "declined") {
showDeclineMessage(); // nothing charged
} else if (charge.status === "requires_action") {
window.location.href = charge.next_action.redirect_to_url.url; // 3DS
} else {
// succeeded or pending — money is moving. Do NOT charge again, and
// confirm settlement on the charge.* webhook before fulfilling.
awaitWebhookThenFulfil();
}
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" }
}
| Field | Notes |
|---|---|
charged | true only when status is succeeded. Branch on this, not the HTTP code — a decline is 200 with charged: false. |
status | succeeded | declined | requires_action | pending (202 while genuinely in-flight; the other three are 200). |
next_action | Present only on requires_action — redirect_to_url.url is the page the buyer must be sent to. See Wallets and 3-D Secure. |
wallet_type | apple_pay | google_pay. |
id | A reusable vp_pmt_* when the buyer's credential was stored for reuse. On a guest charge it is null; on a non-succeeded result (declined, pending, requires_action) the key is absent entirely — so test truthiness, not !== null, or a decline will read as a token. This payment is already paid for — the token is for a later, separate purchase, not for settling this one. Charging it for this order double-charges. |
card | PCI-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).
Wallets and 3-D Secure
A wallet payment can still be challenged by the buyer's bank. When it is, the charge comes back asking you to send the buyer away to authenticate — the same vocabulary the card path already uses, so you can branch on it identically.
Path A — submit() resolves:
const result = await walletCollection.submit();
if (result.chargeStatus === "requires_action") {
window.location.href = result.redirectUrl; // buyer authenticates
return;
}
Path B — the raw response carries status: "requires_action" and next_action.redirect_to_url.url; send the buyer to that URL.
Two things about this arm bite people, so they're worth stating plainly:
- No money has moved. No token is minted and
chargedis absent. It is not a success — don't record it as one. - The buyer coming back is not proof of payment. They return to the session's
successUrl, but the charge settles asynchronously. Fulfil on thecharge.*webhook, never on the return.
If authentication is required but no usable challenge URL comes back, the SDK fails closed with frame_3ds_required and nothing is charged — the same fail-closed direction as the card path. Re-running submit() won't help; it's a configuration fault (most often a session created without a successUrl).
vora.js ≥ 1.20.3Earlier versions had no handled path for this outcome: a challenged wallet payment surfaced as a tokenization failure, which pointed at the wrong problem and left the buyer on a charge that could never complete. If you see that, you're on an older build — the auto-update channel picks up the fix on its own; a pinned /v1.x.y/ URL needs repointing.
Constraints at a glance
| Constraint | Detail |
|---|---|
| Browser | Apple Pay → Safari on macOS/iOS. Google Pay → a Chromium browser signed into a Google account. |
| Domain | Must be verified for the wallet (setup). Derived server-side from Origin. |
| Keys | Wallet endpoints are publishable-key only — a secret key returns 403. |
| Availability | Feature-gated per gateway; 501 endpoint_not_implemented until enabled for your account. |
| Card data | PAN/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
- Custom checkout with Elements — the discrete card-field integration wallets ride alongside
- Wallet domain setup — verify your domains (Apple file / Google auto)
- Apple Pay domain setup — per-framework hosting recipes for the verification file
- Payment Intents — charging a stored
vp_pmt_*off-session