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. Apple Pay is 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.
// From vora.js 1.22.0 the element sends the buyer to `redirectUrl` FOR YOU.
// Only navigate here yourself if you created the element with
// `handleRedirect: false` — see "Wallets and 3-D Secure" below.
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. | Nothing, from vora.js 1.22.0 — the element navigates to redirectUrl itself. On an earlier pinned build, or if you set handleRedirect: false, the redirect is still yours. 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. 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 your processor connection has no Express Checkout support, 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.
Buyer details and shipping in the wallet sheet
By default the sheet asks for nothing but the card, and the tap charges at once. Four options on the element let a buyer pay in one tap from a checkout whose form is still empty, and let you create the order before any money moves. Each is opt-in; leave them all out and the element behaves exactly as above. They need vora.js 1.35.0 or later.
| Option | What it does |
|---|---|
collect | Asks the sheet for the buyer's email, name, phone, billingAddress and/or shippingAddress. Each defaults to false. |
shippingOptions | Shipping choices shown in the sheet, the first one pre-selected: { id, label, amount, detail? }, with amount in minor units. Needs collect.shippingAddress: true and an onShippingOptionChange handler. |
onShippingAddressChange / onShippingOptionChange | Called when the buyer picks an address or a shipping option. Answer with the new total in minor units (and, for an address, optionally a new shippingOptions list), or with { error }. |
onConfirm | Runs after the buyer approves and before any money moves, with everything the buyer gave the sheet. Create or check your order here. |
collect, shippingOptions and onShippingAddressChange need an integrationMode: "elements" session and a payment connection whose wallet buttons the SDK draws itself; not every connection supports that. Where it is not supported the element refuses them at mount with frame_unsupported_element and renders no buttons, rather than ignoring them. Mount once on your own account to confirm. onConfirm works on every session.
const express = walletCollection.create("express-checkout", {
collect: { email: true, name: true, phone: true, shippingAddress: true },
// The session amount is the total the sheet opens with, so create the
// session with an amount that already includes the FIRST option.
shippingOptions: [
{ id: "standard", label: "Standard shipping", amount: 500, detail: "3-5 business days" },
{ id: "express", label: "Express shipping", amount: 1500 },
],
// Your server prices the order AND moves the session's total before answering.
onShippingAddressChange: async ({ shippingAddress }) => {
const res = await fetch("/api/checkout/reprice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, shippingAddress }),
});
if (!res.ok) {
const { reason } = await res.json();
return { error: reason === "not_open" ? "This order is already being paid." : "We can't ship to that address." };
}
return res.json(); // { total, shippingOptions? }
},
onShippingOptionChange: async ({ shippingOptionId }) => {
const res = await fetch("/api/checkout/reprice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, shippingOptionId }),
});
if (!res.ok) return { error: "That shipping option is not available." };
return res.json(); // { total }
},
// Create the order as PENDING. Nothing has been charged yet.
onConfirm: async ({ payer, shippingAddress, shippingOptionId, total }) => {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, payer, shippingAddress, shippingOptionId, total }),
});
return res.ok ? { ok: true } : { ok: false, message: "We couldn't place your order." };
},
});
On your server, the reprice route changes the session's total with PATCH /v1/sessions/{sessionId} and answers the sheet only once that has succeeded:
// POST /api/checkout/reprice (your server, secret key)
const { total, shippingOptions } = priceOrder(cart, req.body);
const res = await fetch(`https://checkout.vonpay.com/v1/sessions/${req.body.sessionId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.VON_PAY_SECRET_KEY}`,
},
body: JSON.stringify({ amount: total }),
});
if (!res.ok) {
// Nothing changed. Pass the reason on so the sheet can say what happened:
// `not_open` means a payment is already in progress on this session.
const err = await res.json();
return reply(res.status, { code: err.code, reason: err.reason });
}
return reply(200, { total, shippingOptions });
Keep the session's total in step with the sheet. When any of these options is used, the element sends the total the buyer approved with the payment, and the checkout charges exactly that total or refuses the payment without charging. If you answer the sheet with a new total but never change the session, the payment is refused with 409 expected_amount_mismatch, which the element reports as frame_wallet_total_mismatch. Nothing is charged either way. Changing the session inside your shipping handlers also keeps onConfirm short.
If the total changes outside the sheet (the buyer edits their cart, say), change the session on your server and then call fetchUpdates() on the wallet's collection (vora.js 1.36.0 and later). The next sheet opens at the new total. A sheet that is already open keeps the total it opened with, and a payment approved at that total after the session moved is refused without charging. The card collection needs its own fetchUpdates(): see Charge at submit.
Answer in time. The wallets give the whole approval step about 30 seconds:
- The shipping handlers must answer within 20 seconds. A late answer, a thrown error or a malformed answer is shown to the buyer as a failure. An
{ error }fromonShippingOptionChangecloses the sheet on Apple Pay (nothing is charged) and is shown in the sheet on Google Pay. onConfirmgets 15 seconds, and the charge uses the rest.{ ok: false, message }charges nothing, and the sheet showsmessagewhere the wallet supports it. A thrown error, a timeout or any other answer also charges nothing and emitsframe_wallet_confirm_failed.
{ ok: true } does not mean the payment will succeed. It can still be declined or sent to a bank check. Create the order as pending in onConfirm, and complete it on the payment webhook.
The address in the shipping handlers is partial. Before the buyer approves, both wallets hide the street line and may shorten the postcode. You get enough to price shipping and tax. The full address arrives after approval.
What comes back. The confirm event carries payer, billingAddress, shippingAddress and shippingOptionId, and submit() carries the same four under result.expressCheckout, with only the fields you asked for. These come from the buyer's browser, so use them to fill in the order but read what you fulfil against from the server:
- The shipping address is returned as
shippingAddressbyGET /v1/sessions/{sessionId}once the session has succeeded. - The shipping option is recorded on the payment as
metadata.shipping_option_id, readable withGET /v1/payment_intents/{id}. - Contact details are stored with the session once the payment is accepted, never from a declined attempt, and are not returned in the payment response or in webhooks. Keep the copy you received in
onConfirm.
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();
// The session's total changed after the buyer approved. Only reachable if
// your integration changes it (PATCH /v1/sessions). Nothing was charged.
if (charge.code === "expected_amount_mismatch") {
session.completePayment(ApplePaySession.STATUS_FAILURE);
return;
}
// 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 request — forward them verbatim; never hardcode a gateway name or merchant id. gateway and gatewayMerchantId go in tokenizationSpecification; merchantId, merchantOrigin and token (as authJwt) go in merchantInfo, and merchantOrigin must match byte for byte.
// 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 },
},
}],
// Forward these three verbatim when present. Production Google Pay refuses
// the request (OR_BIBED_11) if any is missing or altered.
merchantInfo: {
merchantName: "Your Store", // yours: the name shown on the sheet
...(gw.merchantId ? { merchantId: gw.merchantId } : {}),
...(gw.merchantOrigin ? { merchantOrigin: gw.merchantOrigin } : {}),
...(gw.token ? { authJwt: gw.token } : {}),
},
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.code === "expected_amount_mismatch") {
showCurrentTotal(); // the total moved after approval; nothing charged
} else 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 merchantInfo.merchantId, merchantOrigin and authJwt, and google-session returns all three for your session. Without them loadPaymentData fails with OR_BIBED_11 and the sheet never opens.
The wallet charge result
POST /v1/public/tokens with instrument: "wallet" returns a charge result, not a token:
{
"charged": true,
"status": "succeeded",
"payment_intent_id": "vpi_test_abc123",
"token": "vp_pmt_test_abc123",
"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). A resend while the first attempt waits on the bank check answers requires_action again: see below. |
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. |
payment_intent_id | The payment record for this wallet charge. Present on every outcome, including requires_action — so you can record the payment before the buyer leaves to authenticate, and reconcile it when the charge.* webhook arrives. null on a genuinely in-flight pending (no outcome to record yet), on the vault-only path (no charge happens, so no record exists by design), and when the processor rejected the request before creating a transaction. ⚠️ On a duplicate tap the response is also pending, but it deliberately carries the original attempt's payment_intent_id rather than null — so a second tap resolves to the same payment instead of reading as "no payment happened". |
token | The reusable stored card — the same value as id, under a second name so one field carries the vaulted card across every wallet response whichever arm answered. ⚠ null on requires_action, and that is correct rather than missing: nothing is vaulted until the buyer completes the challenge and the charge captures. Read the saved card afterwards from payment_method_id on GET /v1/payment_intents/{id} or on the charge.succeeded webhook — both keyed by the payment_intent_id returned here. |
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. A resend while the first attempt is still waiting on the buyer's 3-D Secure check (a lost response, a double tap) returns that attempt's answer again: 200, requires_action, the same payment_intent_id and the same next_action link, with nothing new charged. Send the buyer to the link once, including when you handle the redirect yourself (handleRedirect: false) and see the same link twice. 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).
Sending the buyer's details and the approved total
The native sheet can return the buyer's contact details and addresses beside the payment token. They reach us only if you add them to the POST /v1/public/tokens body:
| Field | Notes |
|---|---|
billing_address | The billing address the buyer confirmed in the sheet: address_line1, postal_code and country required; address_line2, city, state optional. |
payer | { email, name, phone }, each optional. name is stored whole, never split into first and last names. |
shipping_address | The billing-address fields plus the recipient's name and phone. Returned as shippingAddress by GET /v1/sessions/{sessionId} once the session has succeeded. |
shipping_option_id | Your id for the option the buyer picked (1 to 64 printable ASCII characters). Recorded on the payment as metadata.shipping_option_id. |
expected_amount | { amount, currency }: the total the buyer approved in the sheet. Always send it if your integration changes the session's total with PATCH /v1/sessions/{sessionId}. |
The details are stored only once the payment is accepted, never from a declined attempt, and none of them are echoed back in the response. With expected_amount, the payment is taken only if the session still holds exactly that total; otherwise it is refused with 409 expected_amount_mismatch and the buyer is not charged. Close the sheet as a failure on that answer and show the buyer the current total.
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 the buyer to authenticate — the same vocabulary the card path uses, so you can branch on it identically.
From vora.js 1.22.0, the wallet (express-checkout) element navigates the
page to redirectUrl itself on this arm. You do not write the redirect.
The card path is unchanged and still expects you to perform it. The two differ deliberately, so don't generalise from one to the other: the wallet element dismisses the native sheet showing success, so a buyer whom nobody redirected is left believing they paid while the charge quietly expires having taken no money. The card element never dismisses anything as success, so it has no equivalent failure mode.
Path A — submit() resolves. Your handler runs before the navigation:
const result = await walletCollection.submit();
if (result.chargeStatus === "requires_action") {
// Nothing has been charged. The element navigates to result.redirectUrl
// for you — unless you opted out with handleRedirect: false.
return;
}
Path B — the raw response carries status: "requires_action" and next_action.redirect_to_url.url. On this path the redirect is yours.
Opting out — handleRedirect: false
const express = elements.create("express-checkout", { handleRedirect: false });
The element then emits confirm and stops; the navigation is yours, exactly as on the card path.
confirm handler awaits anythingThe element delivers the confirm and change events before it navigates, so
a synchronous listener always observes the outcome. But anything your handler is
still awaiting when the navigation begins is cancelled by the browser — an
order write, a fraud check, an analytics call. You lose those records silently
while the payment succeeds.
If your handler does server-side bookkeeping, set handleRedirect: false and
navigate once your own work has finished.
Two things about this arm bite people, so they're worth stating plainly:
- No money has moved. No token is minted and
chargedisfalse. 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. Re-running submit() won't help — a different card fails identically; quote the session id to support.
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