Payments¶
A Progressive Web App takes payments with the same web platform primitives as any website, but the details decide whether checkout feels native or broken: the Payment Request API opens a browser-owned payment sheet backed by Apple Pay, Google Pay or a payment app; Secure Payment Confirmation turns a passkey into strong customer authentication for card payments; and the Digital Goods API connects a store-distributed PWA to Google Play Billing or the Microsoft Store. Which API you use depends on what you sell (physical goods or digital content), where the app runs (a browser tab, an installed window, a Trusted Web Activity) and which browsers your customers use. This page covers every one of those paths down to the event order, error names and server-side verification, and ends with a complete checkout implementation.
Key takeaways
new PaymentRequest(methodData, details, options)→canMakePayment()→show()→PaymentResponse.complete()is the whole happy path. It works in Chromium and Safari. Firefox has never enabled it by default.- The payment method you name decides everything. In Safari the only method is Apple Pay (
https://apple.com/apple-pay). Chrome is the only browser that routes Payment Request to third-party methods such as Google Pay (https://google.com/pay). The oldbasic-cardmethod was removed from Chrome in version 100. updateWith()must be called synchronously insideshippingaddresschange,shippingoptionchangeandpaymentmethodchangehandlers. Pass it a promise;awaitfirst and it throwsInvalidStateError.- Apple Pay requires a merchant ID, two certificates, a verified domain and server-side merchant validation over mutual TLS. Since iOS 18, the Apple Pay JS SDK also works in non-Safari browsers through a QR-code handoff to an iPhone.
- Secure Payment Confirmation (Chromium) uses WebAuthn credentials to sign the amount and payee in
clientDataJSON(type: "payment.get"); the bank's server must verify those fields. - A PWA distributed through Google Play that sells digital goods must use Play Billing, through the Digital Goods API in a Trusted Web Activity (Chrome 101+). Microsoft Store PWAs use the same API with
https://store.microsoft.com/billing(Edge 134+). - Never trust amounts from the client, never cache payment API responses in a service worker, and treat the payment page as the most sensitive page you ship (CSP, SRI, script inventory).
Choosing a payment path¶
Start from two questions: what is the customer paying for, and where is your PWA running? The answers narrow the options quickly.
| What you sell | Where the PWA runs | What to use |
|---|---|---|
| Physical goods, services, donations | Any browser, installed or not | Your payment service provider's (PSP) checkout, plus Apple Pay and Google Pay buttons; Payment Request where it helps |
| Physical goods | Trusted Web Activity from Google Play | Same as the web. Play Billing is not required for physical goods |
| Digital goods, subscriptions, in-app features | Browser or installed from the browser | Your PSP's web checkout (cards, wallets). Store rules don't apply |
| Digital goods, subscriptions | Trusted Web Activity from Google Play | Google Play Billing through the Digital Goods API, unless you are enrolled in an alternative billing program |
| Digital goods, subscriptions | PWA from the Microsoft Store | Microsoft Store billing through the Digital Goods API, or your own checkout |
| Card payments needing strong customer authentication (SCA) | Chromium browsers | Secure Payment Confirmation as a 3-D Secure step, with fallback |
| You are a payment provider | Chromium browsers | A web-based payment app (Web-based Payment Handler API) |
flowchart TD
A["Customer clicks Pay"] --> B{"Digital goods and app installed from a store?"}
B -- "Google Play TWA" --> C["Digital Goods API + Play Billing"]
B -- "Microsoft Store" --> D["Digital Goods API + Microsoft Store billing"]
B -- "No" --> E{"Apple Pay available?"}
E -- "Yes" --> F["Apple Pay JS or Payment Request with apple.com/apple-pay"]
E -- "No" --> G{"Chrome with Google Pay?"}
G -- "Yes" --> H["Payment Request with google.com/pay or Google Pay JS"]
G -- "No" --> I["PSP card form (hosted fields or redirect)"]
I --> J{"Issuer asks for SCA?"}
J -- "SPC available" --> K["Secure Payment Confirmation"]
J -- "Otherwise" --> L["3-D Secure challenge"] A PWA installed in a standalone window behaves like a browser tab for all of these APIs. The differences come from the platform: an iOS Home Screen web app runs in its own WebKit container, a Trusted Web Activity runs in Chrome with Play integration, and a Microsoft Store PWA runs in Edge with Store integration. The Installation by Platform page covers these runtimes.
The Payment Request API in depth¶
The Payment Request API standardizes the conversation between a merchant page, the browser and a payment handler. It isn't a payment method and it doesn't move money: it collects a payment credential (an encrypted Apple Pay token, a Google Pay token, a payment app's response) plus optional shipping and contact details, and hands them to your page. You then send the credential to your PSP to authorize the charge.
The Payment Request API became a W3C Recommendation on 8 September 2022. That Recommendation, after privacy and internationalization reviews, left out shipping and billing address collection. Browsers kept shipping those features interoperably, so the Web Payments Working Group re-aligned the specification with implementations: the version at w3.org/TR/payment-request/ is now a Candidate Recommendation Draft (22 June 2026) that restores requestShipping, requestBillingAddress, the shipping events and address redaction, and takes its address components from the Contact Picker API. MDN's compatibility data still marks PaymentRequest.shippingAddress, shippingOption, shippingType and the two shipping events as non-standard and deprecated, although both Chromium and Safari support them. Plan for that: use shipping collection where it improves conversion, but keep your own address form as the source of truth.
Lifecycle and state machine¶
A PaymentRequest object is single-use. Internally it moves through three states, and most exceptions come from calling a method in the wrong state.
stateDiagram-v2
[*] --> created: new PaymentRequest()
created --> created: canMakePayment()
created --> interactive: show()
interactive --> interactive: shipping / method change events and updateWith()
interactive --> closed: user accepts, show() resolves
interactive --> closed: user cancels or abort(), show() rejects AbortError
closed --> [*]: response.complete() | Call | Allowed state | Otherwise |
|---|---|---|
canMakePayment() | created | Rejects with InvalidStateError |
show() | created | Rejects with InvalidStateError |
abort() | interactive (before the response is returned) | Rejects with InvalidStateError |
event.updateWith() | interactive, once per event, during dispatch | Throws InvalidStateError |
response.complete() | After show() resolved, once | Rejects with InvalidStateError |
response.retry() | After show() resolved, before complete() | Rejects with InvalidStateError |
Create a new PaymentRequest for every attempt. Reusing an object after the user cancelled is the most common source of InvalidStateError.
The constructor: PaymentRequest(methodData, details, options)¶
const request = new PaymentRequest(
// 1. methodData: which payment methods you accept, in preference order
[
{
supportedMethods: "https://apple.com/apple-pay", // payment method identifier
data: { /* method-specific, JSON-serializable */ },
},
{
supportedMethods: "https://google.com/pay",
data: { /* Google Pay PaymentDataRequest minus transactionInfo */ },
},
],
// 2. details: what the customer pays for
{
id: "order-7f3c9a", // optional; UUID generated if omitted
total: { label: "Total", amount: { currency: "EUR", value: "54.90" } },
displayItems: [
{ label: "Espresso beans 1 kg", amount: { currency: "EUR", value: "49.90" } },
{ label: "Shipping", amount: { currency: "EUR", value: "5.00" }, pending: true },
],
shippingOptions: [
{ id: "standard", label: "Standard (3–5 days)", amount: { currency: "EUR", value: "5.00" }, selected: true },
{ id: "express", label: "Express (next day)", amount: { currency: "EUR", value: "12.00" } },
],
modifiers: [ /* per-method overrides, see below */ ],
},
// 3. options: what else to collect
{
requestPayerName: true,
requestPayerEmail: true,
requestPayerPhone: false,
requestShipping: true,
shippingType: "shipping", // "shipping" | "delivery" | "pickup"
},
);
methodData is a sequence of PaymentMethodData dictionaries. supportedMethods is a single string. It used to accept an array, which Chrome deprecated in version 62. The string is either a standardized identifier (secure-payment-confirmation) or a URL-based identifier (https://apple.com/apple-pay, https://google.com/pay, https://play.google.com/billing, or your own payment app's URL). The constructor serializes data with JSON.stringify and rethrows any exception, so data must be JSON-serializable: BufferSource values (challenges, credential IDs) are the exception for Secure Payment Confirmation, whose spec defines an IDL type for them.
details is a PaymentDetailsInit:
| Member | Type | Notes |
|---|---|---|
id | DOMString | Your order or transaction ID. Surfaces as response.requestId and in payment handlers as paymentRequestId. The browser generates one if you omit it |
total | PaymentItem | Required. amount.value must not be negative. label is shown in the sheet |
displayItems | sequence<PaymentItem> | Line items. The browser doesn't check that they add up to total |
shippingOptions | sequence<PaymentShippingOption> | {id, label, amount, selected}. Only processed when requestShipping is true. Duplicate id values throw TypeError |
modifiers | sequence<PaymentDetailsModifier> | {supportedMethods, total, additionalDisplayItems, data}: a different total or surcharge when the user picks a specific method |
A PaymentItem is {label, amount, pending}. pending: true tells the browser the amount isn't final (tax or shipping not yet computed).
Amounts are PaymentCurrencyAmount dictionaries: currency is a well-formed three-letter ISO 4217 code and value is a string that must be a valid decimal monetary value: an optional -, one or more digits, and optionally a . followed by one or more digits. "10", "10.5" and "1.234" (Omani rial has three decimals) are valid; "10.", ".5", "1,000.00" and the number 10 coerced to a string with exponent notation are not. The constructor throws TypeError for an invalid amount or a negative total. The browser performs no currency conversion and doesn't round: format and compute amounts on the server with a decimal library and send strings.
options is a PaymentOptions dictionary. Every member defaults to false (shippingType defaults to "shipping"), and every true value adds UI friction, so request only what you need. requestBillingAddress is back in the 2026 Candidate Recommendation Draft, but support differs: Apple Pay and Google Pay collect billing addresses through their own method data instead (requiredBillingContactFields, billingAddressRequired).
The constructor also throws SecurityError when the document isn't allowed to use the payment policy-controlled feature (see below), and throws on the main thread only: PaymentRequest is exposed on Window and isn't available in workers.
Permissions Policy and iframes¶
The payment Permissions Policy feature has a default allowlist of 'self'. A cross-origin iframe, for example a PSP's hosted checkout, can only construct a PaymentRequest when the embedding page delegates the feature:
or with an HTTP header on the top-level document:
The older allowpaymentrequest attribute on <iframe> still exists in Chromium but is deprecated and superseded by allow="payment"; Firefox removed it and Safari never implemented it. Safari 17 added Apple Pay in cross-origin iframes that carry allow="payment", which is how PSP-hosted checkouts show an Apple Pay button inside your page. The same feature also gates the Digital Goods API and Secure Payment Confirmation credential creation in iframes. The Permissions page covers delegation in general.
Detecting support: canMakePayment()¶
canMakePayment() resolves to true when the browser has at least one payment handler that can process one of the methods you listed. The spec is explicit that true "does not imply that the user has a provisioned instrument ready for payment". For Apple Pay, Safari resolves true when the device supports Apple Pay. Whether a card is in Wallet is a separate question, answered by ApplePaySession.applePayCapabilities() (below).
async function paymentRequestAvailable(methodData) {
if (!("PaymentRequest" in window)) return false;
try {
// A throwaway request: canMakePayment() is only allowed in the "created" state,
// and you must not call show() on this object after probing.
const probe = new PaymentRequest(methodData, {
total: { label: "Probe", amount: { currency: "USD", value: "0.00" } },
});
return await probe.canMakePayment();
} catch (err) {
// TypeError: malformed data. SecurityError: blocked by Permissions Policy.
// NotAllowedError: the browser throttled repeated probing.
console.warn("canMakePayment probe failed:", err.name, err.message);
return false;
}
}
Browsers limit how much a page can learn from probing. Private browsing modes may resolve false or behave as if no handler exists, and Chromium throttles pages that call the method repeatedly with different method data. Call it once per page load with your real method list, cache the answer, and decide which buttons to render from it.
Chromium also implements hasEnrolledInstrument() (Chrome 74), which asks whether the user has a ready-to-use instrument. It's not in the spec, not in Safari, and most wallets answer conservatively, so use it only as a hint.
show(): preconditions, errors and detailsPromise¶
show(detailsPromise?) displays the payment sheet and returns a promise for a PaymentResponse. Before anything is displayed, the browser checks, in order:
- User activation. The spec says that without transient activation the user agent may reject with
SecurityError, and when activation is presentshow()consumes it. Safari requires the call to happen inside a user gesture handler, so Apple Pay flows must callshow()synchronously from the click. Chrome enforced the requirement from version 102, then removed it again in Chrome 118 for Payment Request in general, with spam and clickjacking mitigations in its place. Callshow()from the click handler anyway: it's required in Safari and it's what users expect. - Document state. Not fully active →
InvalidStateError. Not visible →AbortError. - Request state. Not
created→InvalidStateError. - One sheet per tab. The top-level browsing context has a payment request is showing flag. If another request is showing, the new one is closed and rejects with
AbortError. - Handlers. If no handler supports any of the listed methods, the promise rejects with
NotSupportedErroraftershow()returns. That's why you probe withcanMakePayment()first.
detailsPromise lets you open the sheet immediately and fill in the final amounts later. The browser shows a loading state until the promise settles; if it rejects, the request aborts. Use it when the click must call show() synchronously but the price depends on a network call (tax, inventory, a coupon):
payButton.addEventListener("click", () => {
const request = buildRequest(provisionalDetails);
// Start the network call, but don't await it before show(): Safari needs show()
// inside the click handler, and awaiting would lose the user activation.
const finalDetails = fetch("/api/quote", { method: "POST", body: cartJSON })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("quote failed"))));
request.show(finalDetails).then(handleResponse, handleShowError);
});
abort() closes an interactive sheet programmatically, for example when the cart changes in another tab or a server-side session expires. It resolves when the sheet closes, and show() then rejects with AbortError.
Reacting to changes inside the sheet¶
While the sheet is open, the user can change the shipping address, the shipping option and the payment method. Each change fires an event on the PaymentRequest object, and your handler can update the totals, the shipping options or show an error:
| Event | Interface | Fires when | Chromium | Safari |
|---|---|---|---|---|
shippingaddresschange | PaymentRequestUpdateEvent | The user picks or edits a shipping address. Read request.shippingAddress | 60 (Android 53) | 11.1 |
shippingoptionchange | PaymentRequestUpdateEvent | The user picks a shipping option. Read request.shippingOption | 60 (Android 53) | 11.1 |
paymentmethodchange | PaymentMethodChangeEvent | The user switches card or payment app. Read event.methodName and event.methodDetails | 76 | 12.1 |
payerdetailchange | PaymentRequestUpdateEvent on PaymentResponse | During retry(), the user corrects name, email or phone | 78 | 12.1 |
merchantvalidation | MerchantValidationEvent | Apple Pay needs a merchant session (Safari only, non-standard) | — | 11.1 |
The handler must call event.updateWith() before it returns, passing a PaymentDetailsUpdate or a promise for one. Calling it sets an internal [[waitForUpdate]] flag, and the sheet shows a spinner until the promise settles. If your handler awaits something first, the event finishes dispatching, the browser continues with the old details, and the late updateWith() throws InvalidStateError. The same happens on a second call, on an untrusted (script-dispatched) event, or when the request is no longer interactive.
request.addEventListener("shippingaddresschange", (event) => {
// Correct: pass a promise synchronously.
event.updateWith(
quoteForAddress(request.shippingAddress).catch(() => ({
error: "We couldn't reach our shipping service. Try again.",
shippingOptions: [], // an empty list tells the sheet the address can't be shipped to
})),
);
});
request.addEventListener("shippingoptionchange", (event) => {
event.updateWith(quoteForOption(request.shippingOption));
});
A PaymentDetailsUpdate can contain total, displayItems, shippingOptions, modifiers, plus error fields: error (a general message), shippingAddressErrors (per-field messages such as {postalCode: "Enter a valid ZIP code"}) and paymentMethodErrors. Setting shippingOptions to an empty array with an error means "we don't ship there".
Address redaction during shippingaddresschange
Before the user accepts, the page only needs enough of the address to quote shipping. Since Chrome 78, Chromium redacts addressLine, organization, phone and recipient from request.shippingAddress during the change event; you get country, region, city, postal code and similar fields. The full address arrives in PaymentResponse.shippingAddress after the user accepts. Don't build fraud checks that depend on the street address before acceptance.
The PaymentResponse¶
When the user authorizes, show() resolves with a PaymentResponse:
| Member | Description |
|---|---|
requestId | The details.id you passed (or the generated one). Use it as an idempotency key |
methodName | The identifier of the method the user chose, for example https://apple.com/apple-pay |
details | Method-specific object: the Apple Pay payment (encrypted token plus contacts), the Google Pay PaymentData, a payment app's response, or a PublicKeyCredential for SPC |
shippingAddress | Full address when requestShipping was true, else null |
shippingOption | Selected shipping option id, or null |
payerName, payerEmail, payerPhone | Present when requested, else null |
toJSON() | Serializer. Convenient for logging, but don't log details in production: it contains payment tokens |
At this point the sheet shows a spinner and waits for you. Send details to your server, let the server charge the payment, then close the sheet with complete():
Promise<undefined> complete(optional PaymentComplete result = "unknown",
optional PaymentCompleteDetails details = {});
Promise<undefined> retry(optional PaymentValidationErrors errorFields = {});
enum PaymentComplete { "fail", "success", "unknown" };
"success"and"fail"let the browser or wallet show a final status (Apple Pay displays a checkmark or an error);"unknown"closes without a status.complete()resolves when the UI has closed. It rejects withInvalidStateErrorif called twice or while aretry()is pending, and withAbortErrorif the document became inactive.- The spec lets browsers impose a timeout: if you never call
complete(), the browser eventually behaves as if you called it with no arguments. Wallets time out faster than your slowest PSP call, so authorize asynchronously if you must and complete the sheet promptly.
retry(errorFields) reopens the sheet so the user can fix data you rejected, such as an email address that bounced validation or a shipping address your carrier can't serve. PaymentValidationErrors has error (general), payer ({email, name, phone}), shippingAddress (per-field AddressErrors) and paymentMethod (method-specific). The promise resolves when the user resubmits, and the same PaymentResponse object now holds the corrected values:
async function validateAndMaybeRetry(response) {
for (let attempt = 0; attempt < 3; attempt++) {
const problems = await fetch("/api/validate-payer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: response.payerEmail, phone: response.payerPhone }),
}).then((r) => r.json());
if (!problems.payer) return true;
await response.retry({ payer: problems.payer }); // e.g. { email: "Use a work address" }
}
await response.complete("fail");
return false;
}
Apple's documentation points out that Payment Request can't retry after authorization failures the way Apple Pay JS can with ApplePayError. Safari supports retry() for data errors, but for declines you complete with "fail" and let the user start again.
Apple Pay on the web¶
Apple Pay is the only payment method Safari exposes through Payment Request, and the only wallet button most iOS users recognize. Apple supports two JavaScript APIs that show the same sheet:
Apple Pay JS (ApplePaySession) | Payment Request | |
|---|---|---|
| Availability (Apple's docs) | iOS 10+, macOS 10.12+ | iOS 11.3+, Safari 11.1+ on macOS 10.12.6+ |
| In China | iOS 11.2+, not on macOS | iOS 11.3+, not on macOS |
| Non-Safari browsers | Yes with the Apple Pay JS SDK (iOS 18 QR-code flow) | No |
| Errors after authorization | Granular ApplePayError, retry after authorization | User must restart |
| Store and co-branded cards, phonetic names | Yes | No |
| Code shared with other wallets | No | Yes |
Both APIs are covered by the same versioning scheme: ApplePaySession.supportsVersion(n) tells you which Apple Pay version the device supports, and "the same Apple Pay version number applies to both Apple Pay JS and Payment Request APIs". Use the lowest version that supports the features you need.
One-time setup¶
In your Apple Developer account (Team Agent or Admin), you create:
- A merchant ID (
merchant.com.example.shop). It never expires and can be shared across websites and iOS apps. - A payment processing certificate. Apple encrypts the payment token with its public key; you or your PSP decrypt it with the private key. Most PSPs generate this for you.
- A merchant identity certificate, a TLS client certificate your server uses to request merchant sessions. It's only needed for the web.
- Domain verification for every domain and subdomain that shows the button. You host the file Apple gives you at
/.well-known/apple-developer-merchantid-domain-association. Domains "can't be behind a proxy or redirect", and a domain can't be registered under two Team IDs.
Certificates and domain verification expire, so put renewal dates in your runbook. Your server must speak TLS 1.2 or later with one of the cipher suites Apple lists, and send SNI, and its egress firewall must allow Apple's gateway IP ranges.
Merchant validation¶
Every Apple Pay transaction starts with merchant validation: the sheet asks your page for a fresh, opaque merchant session, which only your server can obtain from Apple over mutual TLS.
sequenceDiagram
participant U as User
participant P as Page (Safari)
participant S as Your server
participant A as Apple Pay server
U->>P: tap Apple Pay button
P->>P: request.show()
P-->>P: merchantvalidation event (validationURL)
P->>S: POST /apple-pay/session
S->>A: POST /paymentservices/paymentSession (mTLS, merchant identity cert)
A-->>S: opaque merchant session (single use, expires in 5 minutes)
S-->>P: merchant session JSON
P->>P: event.complete(sessionPromise)
U->>P: Face ID / Touch ID / double-click
P-->>P: show() resolves, details.token (encrypted)
P->>S: POST /orders with token
S->>S: PSP authorizes the charge
S-->>P: result
P->>P: response.complete("success") The request body is {merchantIdentifier, displayName, initiative: "web", initiativeContext: "<your fully qualified domain>"}. displayName is at most 64 UTF-8 characters and shouldn't contain dynamic values like order numbers. Apple documents a global endpoint, https://apple-pay-gateway.apple.com/paymentservices/paymentSession, and a China-region endpoint, https://cn-apple-pay-gateway.apple.com/paymentservices/paymentSession. It also allows posting to the validationURL from the event. If you use validationURL, validate its host against Apple's published list before your server connects to it: a server that fetches arbitrary client-supplied URLs with a client certificate attached is a server-side request forgery hole. The simplest safe design, used in the complete example below, ignores validationURL and always calls the fixed endpoint for your region.
Apple Pay through Payment Request¶
In Safari, the data for https://apple.com/apple-pay is an ApplePayRequest: version, merchantIdentifier, merchantCapabilities (for example ["supports3DS"]), supportedNetworks (["visa", "masterCard", "amex", ...]), countryCode, and optionally requiredBillingContactFields, requiredShippingContactFields, supportedCountries, supportsCouponCode, couponCode, shippingContactEditingMode and applicationData. Recurring, deferred and automatic-reload payments are expressed through modifiers.
const applePayMethod = {
supportedMethods: "https://apple.com/apple-pay",
data: {
version: 3,
merchantIdentifier: "merchant.com.example.shop",
merchantCapabilities: ["supports3DS"],
supportedNetworks: ["visa", "masterCard", "amex", "discover"],
countryCode: "US",
requiredBillingContactFields: ["postalAddress"],
},
};
const request = new PaymentRequest([applePayMethod], details, { requestPayerEmail: true });
request.onmerchantvalidation = (event) => {
// complete() takes a promise, so the fetch can be in flight while Safari waits.
event.complete(
fetch("/api/apple-pay/session", { method: "POST" }).then((r) => {
if (!r.ok) throw new Error(`merchant validation failed: ${r.status}`);
return r.json();
}),
);
};
After authorization, response.details holds the Apple Pay payment: token (with paymentData, the encrypted payload, paymentMethod and transactionIdentifier), plus billingContact and shippingContact when requested. Forward token to your PSP unmodified.
Apple Pay JS, availability and third-party browsers¶
Before rendering a button, check window.ApplePaySession, then either ApplePaySession.canMakePayments() (device capability only, can be called any time) or ApplePaySession.applePayCapabilities(merchantIdentifier), which contacts Apple's servers and resolves with a paymentCredentialStatus of "paymentCredentialsAvailable", "paymentCredentialStatusUnknown", "paymentCredentialsUnavailable" or "applePayUnsupported". Apple's guidelines say that when credentials are available, Apple Pay should be the primary, though not necessarily the only, payment option.
With iOS 18, Apple extended Apple Pay to non-Safari browsers: the user scans a code with an iPhone and authorizes there. Apple's WWDC24 session lists two requirements: load the Apple Pay JS SDK (version 1.2.0 or later) and render the JavaScript <apple-pay-button> rather than the CSS button. Apple recommends the auto-updating https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js (the 1.2.0 build was a beta for testing, not for production). If you pin a specific version, add its published integrity hash, and never add SRI to 1.latest, whose contents change. A strict Content Security Policy needs https://applepay.cdn-apple.com in script-src, img-src and frame-src.
The Apple Pay JS flow is event-driven, like Payment Request, but each callback has its own completion method. Three rules make it work: construct ApplePaySession synchronously inside the click handler (the constructor throws outside a user gesture), call begin() in the same handler, and answer every event with its matching complete*() call, or the sheet times out.
// Apple Pay JS with post-authorization error handling (not possible with Payment Request).
function onApplePayClick() {
const session = new ApplePaySession(3, { // Apple Pay version 3
countryCode: "US",
currencyCode: "USD",
merchantCapabilities: ["supports3DS"],
supportedNetworks: ["visa", "masterCard", "amex", "discover"],
requiredShippingContactFields: ["postalAddress", "email"],
total: { label: "Example Coffee", amount: "54.90", type: "final" },
});
session.onvalidatemerchant = async () => {
try {
const res = await fetch("/api/apple-pay/session", { method: "POST" });
if (!res.ok) throw new Error(String(res.status));
session.completeMerchantValidation(await res.json());
} catch {
session.abort(); // oncancel fires; show another payment method
}
};
session.onpaymentauthorized = async (event) => {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
methodName: "https://apple.com/apple-pay",
details: { token: event.payment.token },
shippingContact: event.payment.shippingContact,
}),
}).then((r) => r.json()).catch(() => ({ status: "error" }));
if (res.status === "paid") {
session.completePayment({ status: ApplePaySession.STATUS_SUCCESS });
} else if (res.status === "invalid-shipping") {
// Apple Pay JS can point at the exact field and let the user fix it in the sheet.
session.completePayment({
status: ApplePaySession.STATUS_FAILURE,
errors: [new ApplePayError("shippingContactInvalid", "postalCode", "We can't deliver to this ZIP code.")],
});
} else {
session.completePayment({ status: ApplePaySession.STATUS_FAILURE });
}
};
session.oncancel = () => { /* re-enable the button; nothing was charged */ };
session.begin(); // must run in the same user gesture as the constructor
}
ApplePayError codes include shippingContactInvalid, billingContactInvalid, addressUnserviceable, couponCodeInvalid and couponCodeExpired (the coupon codes need version 12). The onshippingcontactselected, onshippingmethodselected, onpaymentmethodselected and oncouponcodechanged handlers mirror the Payment Request change events and answer with completeShippingContactSelection(), completeShippingMethodSelection(), completePaymentMethodSelection() and completeCouponCodeChange().
In iOS, Apple documents Apple Pay support in Safari and SFSafariViewController. Home Screen web apps run on the same WebKit, but test the full flow, including merchant validation, in the standalone container on every iOS version you support, and keep a card fallback.
Google Pay¶
Google Pay offers two integration paths on the web:
- Google Pay API for Web, the
pay.jslibrary (https://pay.google.com/gp/p/js/pay.js). It works in all major browsers, renders the branded button (PaymentsClient.createButton()), checks readiness withisReadyToPay()and opens the sheet withloadPaymentData(). This is Google's recommended path for broad reach. - Payment Request with the identifier
https://google.com/pay. Google's documentation notes that "Chrome is currently the only web browser supporting the Payment Request API with third-party payment methods, including Google Pay". The advantage is one code path for Google Pay and any other Chrome-supported method.
For Payment Request, data is a Google Pay PaymentDataRequest without transactionInfo: amounts come from the Payment Request details.
const googlePayMethod = {
supportedMethods: "https://google.com/pay",
data: {
environment: "TEST", // "PRODUCTION" after Google approves your integration
apiVersion: 2,
apiVersionMinor: 0,
merchantInfo: { merchantName: "Example Coffee", merchantId: "BCR2DN4T..." },
allowedPaymentMethods: [{
type: "CARD",
parameters: {
allowedAuthMethods: ["PAN_ONLY", "CRYPTOGRAM_3DS"],
allowedCardNetworks: ["AMEX", "DISCOVER", "MASTERCARD", "VISA"],
},
tokenizationSpecification: {
type: "PAYMENT_GATEWAY",
parameters: { gateway: "example", gatewayMerchantId: "exampleGatewayMerchantId" },
},
}],
},
};
response.details is Google Pay's PaymentData; the PSP token is details.paymentMethodData.tokenizationData.token. PAN_ONLY cards may require 3-D Secure at your PSP; CRYPTOGRAM_3DS credentials are device tokens with a cryptogram.
With the pay.js library the same configuration drives three calls. isReadyToPay() resolves {result: boolean} (and paymentMethodPresent when you pass existingPaymentMethodRequired: true), createButton() returns a branded element, and loadPaymentData() must be called from the button's click handler because it opens a popup on browsers without Payment Request support:
// Load https://pay.google.com/gp/p/js/pay.js with async, then call initGooglePay().
const baseCard = {
type: "CARD",
parameters: {
allowedAuthMethods: ["PAN_ONLY", "CRYPTOGRAM_3DS"],
allowedCardNetworks: ["AMEX", "DISCOVER", "MASTERCARD", "VISA"],
},
};
const card = {
...baseCard,
tokenizationSpecification: {
type: "PAYMENT_GATEWAY",
parameters: { gateway: "example", gatewayMerchantId: "exampleGatewayMerchantId" },
},
};
export async function initGooglePay(container, getQuote) {
const client = new google.payments.api.PaymentsClient({ environment: "TEST" });
const ready = await client.isReadyToPay({
apiVersion: 2, apiVersionMinor: 0, allowedPaymentMethods: [baseCard],
}).catch(() => ({ result: false }));
if (!ready.result) return false;
container.append(client.createButton({
buttonType: "buy",
buttonSizeMode: "fill",
allowedPaymentMethods: [baseCard],
onClick: () => pay(client, getQuote()), // getQuote(): server-rendered total, synchronous
}));
return true;
}
async function pay(client, quote) {
try {
const paymentData = await client.loadPaymentData({
apiVersion: 2,
apiVersionMinor: 0,
merchantInfo: { merchantName: "Example Coffee", merchantId: "BCR2DN4T0000000" },
allowedPaymentMethods: [card],
transactionInfo: {
totalPriceStatus: "FINAL", // "ESTIMATED" if the amount can still change
totalPrice: quote.total, // string, e.g. "54.90"
currencyCode: quote.currency,
countryCode: "US",
},
emailRequired: true,
});
// paymentData.paymentMethodData.tokenizationData.token is what the PSP needs.
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId: quote.orderId, methodName: "https://google.com/pay", details: paymentData }),
});
const result = await res.json().catch(() => ({ status: "error" }));
return result.status; // "paid" | "declined" | "error": render it yourself
} catch (err) {
if (err.statusCode === "CANCELED") return "cancelled"; // user closed the sheet
throw err; // DEVELOPER_ERROR: check the console for the invalid field
}
}
Unlike Payment Request, loadPaymentData() has no complete() step: the Google Pay sheet closes as soon as it returns the token, so show your own "processing" state while the server charges. For dynamic shipping pricing, pass callbackIntents and paymentDataCallbacks (onPaymentDataChanged, onPaymentAuthorized) to the PaymentsClient constructor; onPaymentAuthorized keeps the sheet open until your server answers, which is the closest equivalent to complete().
Web-based payment apps: the Payment Handler API¶
Chromium-only
The Web-based Payment Handler API (renamed from "Payment Handler API"; W3C Working Draft of 23 April 2026) ships in Chrome and Edge on desktop and Android. MDN marks every interface experimental. It isn't available in Safari, Firefox or Android WebView.
The Payment Request examples so far assume someone else owns the payment method. If you run a wallet, a bank or a buy-now-pay-later service, the Payment Handler API lets your PWA be a payment method: a merchant lists your identifier in supportedMethods, the browser discovers your app, and your service worker handles the payment in a browser-owned window.
Discovery: from identifier to service worker¶
sequenceDiagram
participant M as Merchant page
participant B as Browser
participant I as bobpay.example (your origin)
participant SW as Your service worker
M->>B: new PaymentRequest([{supportedMethods: "https://bobpay.example/pay"}])
B->>I: HEAD https://bobpay.example/pay
I-->>B: Link header rel=payment-method-manifest pointing to /pay/payment-manifest.json
B->>I: GET /pay/payment-manifest.json
I-->>B: default_applications, supported_origins
B->>I: GET /manifest.json (web app manifest)
I-->>B: name, icons, serviceworker
B->>SW: register just-in-time (if not already)
B->>SW: canmakepayment event
M->>B: show()
B->>SW: paymentrequest event
SW->>B: openWindow("/pay/checkout")
SW-->>B: respondWith({methodName, details})
B-->>M: PaymentResponse - Payment method identifier. Any HTTPS URL you control, such as
https://bobpay.example/pay. It must respond toHEADwith aLinkheader carryingrel="payment-method-manifest". Chrome follows up to three same-site redirects. - Payment method manifest, a JSON file with
default_applications(required: URLs of web app manifests, which may be relative) andsupported_origins(other origins allowed to implement this method). Chrome supports a single default payment app per payment method. - Web app manifest.
nameandicons(required, used in Chrome's payment UI) and aserviceworkermember withsrc,scopeanduse_cache.related_applicationswithprefer_related_applications: truemakes Chrome on Android launch your native payment app instead (it needs anorg.chromium.action.PAYintent filter). - Just-in-time registration. The browser registers the service worker from the manifest when the user first chooses your app on a merchant site, without your page ever running
navigator.serviceWorker.register(). JIT registration only happens when the payment method manifest points to a single payment app.
{
"default_applications": ["https://bobpay.example/manifest.json"],
"supported_origins": ["https://partner-wallet.example"]
}
{
"name": "Pay with BobPay",
"icons": [{ "src": "/icons/pay-192.png", "sizes": "192x192", "type": "image/png" }],
"serviceworker": { "src": "/payment-sw.js", "scope": "/pay/", "use_cache": false }
}
The service worker side¶
The spec adds two events to ServiceWorkerGlobalScope, canmakepayment and paymentrequest, plus registration.paymentManager on the page side.
CanMakePaymentEventhas onlyrespondWith(Promise<boolean>). Chrome 111 removed the merchant identity (topOrigin,paymentRequestOrigin,methodData,modifiers) from this event for privacy, so your answer can't depend on who is asking. The browser doesn't fire it in private browsing, and implementations may time out and treat a slow answer asfalse.PaymentRequestEventcarriestopOrigin,paymentRequestOrigin(differs fromtopOriginwhen a PSP iframe called the API),paymentRequestId,methodData,total,modifiers,paymentOptionsandshippingOptions. Its methods areopenWindow(url)(resolves with aWindowClient, ornull),changePaymentMethod(),changeShippingAddress(),changeShippingOption()(all Chrome 76–80) andrespondWith(Promise<PaymentHandlerResponse>).PaymentHandlerResponseis{methodName, details, payerName, payerEmail, payerPhone, shippingAddress, shippingOption}.PaymentManager(registration.paymentManager) hasuserHint(a string shown next to your app, such as"•••• 4242") andenableDelegations([...]), which declares that your app will provide"shippingAddress","payerName","payerPhone"or"payerEmail"itself, so the browser doesn't ask for them.
Chrome also removed PaymentInstruments (the old instrument registry) in version 111, and payment handlers for standardized identifiers like basic-card in version 92: a web payment app now handles only URL-based identifiers.
// A minimal but complete web-based payment app service worker.
const CHECKOUT_URL = new URL("/pay/checkout", self.location.origin).href;
let pending = null; // { event, resolve, reject, client }
self.addEventListener("canmakepayment", (event) => {
// No merchant data is available here (removed in Chrome 111). Answer based on
// your own state only, and answer quickly: a timeout counts as false.
event.respondWith(Promise.resolve(true));
});
self.addEventListener("paymentrequest", (event) => {
// One service worker instance serves every tab: reject a transaction that is
// still pending instead of silently overwriting it.
if (pending) pending.reject(new DOMException("Superseded", "AbortError"));
let resolve, reject;
const result = new Promise((res, rej) => { resolve = res; reject = rej; });
pending = { event, resolve, reject, client: null };
// respondWith() must be called synchronously during dispatch.
event.respondWith(result);
event.openWindow(CHECKOUT_URL).then((client) => {
if (!client) throw new DOMException("payment window failed to open", "OperationError");
pending.client = client;
}).catch((err) => {
// Only fail the transaction this call belongs to, not a newer one.
if (pending?.event !== event) return;
pending.reject(err.name === "OperationError" ? err : new DOMException(err.message, "OperationError"));
pending = null;
});
});
self.addEventListener("message", (event) => {
if (!pending) return;
// Only the payment window this transaction opened may drive it. Any other
// same-origin tab could otherwise post "AUTHORIZED" for someone else's payment.
const { type } = event.data ?? {};
if (type !== "WINDOW_READY" && event.source?.id !== pending.client?.id) return;
switch (type) {
case "WINDOW_READY": {
const { total, topOrigin, paymentRequestId } = pending.event;
// Only send what the UI needs; the merchant origin is shown to the user.
event.source.postMessage({ type: "PAYMENT_DETAILS", total, topOrigin, paymentRequestId });
break;
}
case "AUTHORIZED": {
// details must be something the merchant's server can verify with you,
// e.g. a signed, single-use payment reference, never raw credentials.
pending.resolve({
methodName: "https://bobpay.example/pay",
details: { paymentReference: event.data.paymentReference },
});
pending = null;
break;
}
case "CANCELLED": {
pending.reject(new DOMException("User cancelled", "AbortError"));
pending = null;
break;
}
}
});
How you reject the respondWith() promise matters to the merchant. Today any rejection reaches the merchant's show() as AbortError, indistinguishable from a user cancel. Chrome Platform Status lists a change targeting Chrome 149 under which rejecting with an OperationError DOMException surfaces to the merchant as OperationError ("internal payment app error"), while other rejections still mean "user cancelled", so a merchant can fall back to another method instead of stopping. Reject with OperationError for your own failures (backend down, risk engine timeout) and AbortError for real cancellations; older Chrome versions treat both as cancellation.
The payment handler window is a regular Chrome window with two restrictions: viewport resizing and window.open() are disabled. WebAuthn works inside it, so the natural way for your app to authenticate the user is a passkey (see Authentication & Passkeys). The page and every subresource must be served over valid HTTPS without mixed content, or Chrome cancels the payment. Chrome DevTools has a Payment Handler pane under Application that records canmakepayment and paymentrequest events; check "Show events from other domains" to see events fired from merchant pages.
Secure Payment Confirmation¶
Secure Payment Confirmation (SPC) lets a card issuer or bank, the relying party, authenticate the cardholder with a passkey during checkout on a merchant's site, typically as the challenge step of EMV 3-D Secure. It's WebAuthn with a payment layer: the browser shows a transaction dialog with the payee, amount and card art, and the resulting assertion signs those values in clientDataJSON. Stripe reported to the W3C Web Payments Working Group that an SPC experiment achieved an 8% better conversion rate and checkouts three times faster, according to Chrome's SPC overview.
Chromium-only
SPC is enabled by default in Chrome 95 on desktop and Chrome 109 on Android (Chrome Platform Status). Chrome's documentation lists macOS, Windows and Android as supported; iOS and ChromeOS are not. Safari and Firefox don't implement it. The specification is a W3C Web Payments Working Group draft.
Registering an SPC credential¶
Registration is a normal navigator.credentials.create() call with extra requirements: a platform authenticator (authenticatorAttachment: "platform"), residentKey: "required", userVerification: "required", and the payment extension with isPayment: true. Browsers that don't know the extension ignore it and create an ordinary passkey. Unlike ordinary WebAuthn, SPC registration works in a cross-origin iframe, so the issuer can enroll the card from within the merchant's checkout, provided the merchant delegates payment (allow="payment https://issuer.example").
const credential = await navigator.credentials.create({
publicKey: {
challenge: serverChallenge, // BufferSource from your server
rp: { id: "issuer.example", name: "Example Bank" },
user: { id: cardholderIdBytes, name: "[email protected]", displayName: "Jane Doe" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
authenticatorSelection: {
authenticatorAttachment: "platform",
residentKey: "required",
userVerification: "required",
},
excludeCredentials: existingCredentialDescriptors,
extensions: { payment: { isPayment: true } },
},
});
// Verify on the server exactly like any WebAuthn registration.
Authenticating a payment¶
The merchant, or the 3-D Secure server acting for the issuer, calls Payment Request with the standardized identifier secure-payment-confirmation:
const request = new PaymentRequest([{
supportedMethods: "secure-payment-confirmation",
data: {
rpId: "issuer.example",
credentialIds: [credentialIdBytes], // BufferSource[] from the issuer
challenge: issuerChallengeBytes,
instrument: {
displayName: "Example Bank Visa •••• 4242",
icon: "https://issuer.example/card-art.png",
iconMustBeShown: false,
},
payeeName: "Example Coffee",
payeeOrigin: "https://coffee.example",
timeout: 360000,
},
}], {
total: { label: "Total", amount: { currency: "USD", value: "54.90" } },
});
try {
const response = await request.show();
// response.details is a PublicKeyCredential; serialize it for the issuer.
const verdict = await sendToIssuer(response.details.toJSON());
await response.complete(verdict.ok ? "success" : "fail");
} catch (err) {
// NotSupportedError / NotAllowedError: fall back to the regular 3-D Secure challenge.
fallbackToChallenge(err);
}
SecurePaymentConfirmationRequest requires challenge, rpId, credentialIds and instrument, and takes optional payeeName, payeeOrigin (at least one of the two), timeout and extensions. The current editor's draft adds paymentEntitiesLogos, browserBoundPubKeyCredParams, locale and showOptOut; feature-detect before relying on them.
Feature detection used to require building a dummy request with fake credential IDs and calling canMakePayment(). Chrome 139 added PaymentRequest.securePaymentConfirmationAvailability(), which resolves with "available", "unavailable-unknown-reason", "unavailable-feature-not-enabled", "unavailable-no-permission-policy" or "unavailable-no-user-verifying-platform-authenticator". Chrome 148 added PaymentRequest.getSecurePaymentConfirmationCapabilities(), which currently reports browserBoundKeyHardware. Both are marked experimental on MDN.
Verifying the assertion (issuer server)¶
Verify the assertion like any WebAuthn authentication (signature over authenticatorData || SHA-256(clientDataJSON), RP ID hash, UV flag, counter), with two differences:
clientDataJSON.typeis"payment.get", not"webauthn.get". Most WebAuthn libraries accept an expected type option (SimpleWebAuthn'sexpectedType).clientDataJSON.paymentcontains what the user saw:rpId(older Chrome versions emittedrp),topOrigin,payeeOriginand/orpayeeName,totalandinstrument. Compare every field with the transaction you asked the merchant to authenticate. The signature proves the user approved these values, which is what satisfies dynamic linking under PSD2 strong customer authentication.
function checkPaymentData(clientData, expected) {
if (clientData.type !== "payment.get") throw new Error("wrong ceremony type");
const p = clientData.payment;
if (!p) throw new Error("no payment data");
const rpId = p.rpId ?? p.rp; // tolerate the legacy field name
if (rpId !== expected.rpId) throw new Error("rpId mismatch");
if (p.topOrigin !== expected.merchantOrigin) throw new Error("topOrigin mismatch");
if (p.payeeOrigin !== expected.merchantOrigin) throw new Error("payee mismatch");
if (p.total.currency !== expected.currency || p.total.value !== expected.amount) {
throw new Error("amount mismatch");
}
if (p.instrument.displayName !== expected.instrumentName) throw new Error("instrument mismatch");
}
Chrome is also shipping browser bound keys for SPC (Chrome Platform Status lists Android 139 and desktop 145): a second, device-bound key pair that is never synced, whose signature over the transaction helps meet device-binding requirements that synced passkeys alone can't.
Digital goods: store billing for installed PWAs¶
Google Play: Trusted Web Activity + Digital Goods API¶
If your PWA is on Google Play as a Trusted Web Activity and sells digital goods (subscriptions, premium features, virtual items), Google Play's Payments policy requires Google Play's billing system for those transactions. The policy exempts physical goods and services, peer-to-peer payments and some other categories, and allows alternative billing or linking out only for developers enrolled in the relevant programs in eligible countries and regions. Read the current Payments policy before you ship: this area has changed repeatedly and differs by country.
The web side uses two APIs together: the Digital Goods API to read product details and existing purchases, and Payment Request with the method https://play.google.com/billing to buy. Chrome enables the Digital Goods API from Chrome 101 on Android (and ChromeOS), and only inside a Trusted Web Activity installed from Play. Anywhere else getDigitalGoodsService is missing or the promise rejects.
partial interface Window {
[SecureContext] Promise<DigitalGoodsService> getDigitalGoodsService(DOMString serviceProvider);
};
interface DigitalGoodsService {
Promise<sequence<ItemDetails>> getDetails(sequence<DOMString> itemIds);
Promise<sequence<PurchaseDetails>> listPurchases();
Promise<sequence<PurchaseDetails>> listPurchaseHistory();
Promise<undefined> consume(DOMString purchaseToken);
};
dictionary ItemDetails {
required DOMString itemId; required DOMString title; required PaymentCurrencyAmount price;
ItemType type; /* "product" | "subscription" */ DOMString description; sequence<DOMString> iconURLs;
DOMString subscriptionPeriod; DOMString freeTrialPeriod; PaymentCurrencyAmount introductoryPrice;
DOMString introductoryPricePeriod; unsigned long long introductoryPriceCycles;
};
dictionary PurchaseDetails { required DOMString itemId; required DOMString purchaseToken; };
Periods are ISO 8601 durations ("P1M"). getDetails() may return items in any order and silently omits unknown IDs; there is no call to list your catalog, so keep SKUs in your code or on your server. The API is gated by the payment Permissions Policy feature.
Setup, per Chrome's guide:
- A Play Developer account linked to a payments merchant account, an app release on at least an internal testing track, and products or subscriptions created in Play Console.
- A Bubblewrap project (1.8.2 or later) with working Digital Asset Links, and in
twa-manifest.json:"features": {"playBilling": {"enabled": true}}and"alphaDependencies": {"enabled": true}. Thenbubblewrap updateandbubblewrap build. - For testing on a development device: Android 9+, Chrome 101+, and
chrome://flags/#enable-debug-for-store-billing. The flag isn't needed when the app is installed from Play.
The purchase flow: show() a PaymentRequest whose method data is {sku}. Play ignores details.total (the price comes from Play Console), but the Payment Request API still requires it, so pass a placeholder. On success, response.details.purchaseToken identifies the purchase. Your server must verify it with the Google Play Developer API and acknowledge it: unacknowledged purchases are refunded and revoked after three days. Call consume() for consumables so the item can be bought again. On every launch, listPurchases() recovers purchases that completed while your server was unreachable. Subscribe to Real-time Developer Notifications so your backend learns about renewals, cancellations and refunds without polling.
Microsoft Store: Edge 134+¶
A PWA listed in the Microsoft Store can sell in-app products and subscriptions through the same API. Microsoft documents Edge 134.0.3124.51 or later, the service provider string https://store.microsoft.com/billing for both getDigitalGoodsService() and supportedMethods, and data: {sku: itemId}, where the item ID is the product's InAppOfferToken from Partner Center. The service is only available to a PWA installed from the Microsoft Store on Windows; in a browser tab the call rejects. listPurchases() omits consumed products and expired subscriptions; listPurchaseHistory() returns the latest purchase per item regardless of state.
Apple platforms¶
Home Screen web apps on iOS aren't distributed through the App Store, so Apple's in-app purchase rules don't apply to them, and there is no Digital Goods backend for Apple. If you wrap your PWA in a native shell for the App Store, App Review's rules for digital goods apply to the shell; see Publishing to App Stores.
One module for every storefront¶
// Picks store billing when the PWA runs inside a store that requires it,
// otherwise returns null so the caller uses the web checkout.
const STORES = [
"https://play.google.com/billing", // Trusted Web Activity from Google Play
"https://store.microsoft.com/billing", // PWA installed from the Microsoft Store
];
export async function getStoreBilling() {
if (typeof window.getDigitalGoodsService !== "function") return null;
for (const provider of STORES) {
try {
const service = await window.getDigitalGoodsService(provider);
return { provider, service };
} catch {
// Provider not available in this context; try the next one.
}
}
return null;
}
export async function loadProducts({ service }, skus) {
const items = await service.getDetails(skus);
return items.map((item) => ({
id: item.itemId,
title: item.title,
description: item.description ?? "",
type: item.type ?? "product",
// Format with the store's currency, not the user's locale default.
price: new Intl.NumberFormat(navigator.language, {
style: "currency",
currency: item.price.currency,
}).format(Number(item.price.value)),
}));
}
export async function purchase({ provider, service }, sku, { consumable = false } = {}) {
const request = new PaymentRequest(
[{ supportedMethods: provider, data: { sku } }],
// Required by the API, ignored by the store: the price comes from the store catalog.
{ total: { label: "Total", amount: { currency: "USD", value: "0" } } },
);
let response;
try {
response = await request.show();
} catch (err) {
if (err.name === "AbortError") return { status: "cancelled" };
throw err;
}
const { purchaseToken } = response.details;
// The server verifies with the store API and acknowledges (Play: within 3 days).
const verify = await fetch("/api/store/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, sku, purchaseToken }),
});
if (!verify.ok) {
await response.complete("fail");
return { status: "unverified" };
}
if (consumable) await service.consume(purchaseToken);
await response.complete("success");
return { status: "purchased" };
}
export async function restorePurchases({ provider, service }) {
const purchases = await service.listPurchases();
// Re-verify on the server: never grant entitlements from client data alone.
await fetch("/api/store/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, purchases }),
});
return purchases.map((p) => p.itemId);
}
Security, PCI DSS and fraud¶
Payment code is the most attacked code in a web app, and a PWA adds a service worker and long-lived client state to the attack surface. The rules:
- The server owns the price. Treat every amount in
detailsas display-only. Your server recomputes totals from the cart, looks up the order byrequestId, and charges that. A user can edit any client-side value, including the Payment Requesttotal. - Idempotency everywhere. Use the
PaymentRequestidor your order ID as the PSP idempotency key. A retriedfetch(), a double-tap or a Background Sync replay must not charge twice. - Tokens, not card numbers. Apple Pay and Google Pay give you encrypted or tokenized credentials; hosted fields and redirect checkouts keep raw card data off your origin. Since Chrome 100 there is no
basic-cardmethod that hands raw card numbers to your page, and you shouldn't recreate one. - Webhooks are the source of truth. Asynchronous payment methods, 3-D Secure and store purchases complete out of band. Fulfil orders from verified PSP or store webhooks, not from the page's say-so.
- Keep payment traffic away from the service worker's caches. Serve payment APIs with
Cache-Control: no-store, exclude them from runtime caching routes, and don't precache PSP or wallet SDK scripts (they're versioned by their owners and must be fresh). A cache-first route that accidentally matches/api/orderscan replay a stale response. The Service Worker Security and Handling Fetch Events pages cover route design. - No offline payments. You can queue an order for later (Background Sync), but authorization needs the network and the wallet. Show a clear offline state on the pay button instead of a sheet that will fail (Offline UX & Fallbacks).
- Lock down the payment page. PCI DSS v4.0 introduced requirements for payment pages: maintain an inventory of every script on the page with a justification and an integrity mechanism (6.4.3), and detect unauthorized changes to the page and its security headers (11.6.1). A strict Content Security Policy with nonces, Subresource Integrity on pinned third-party scripts, and CSP reporting are the practical implementation. Your PSP's integration type determines which self-assessment questionnaire applies. The January 2025 revision of SAQ A (for merchants whose payment fields come entirely from a compliant PSP, for example in an iframe) dropped 6.4.3 and 11.6.1 and replaced them with an eligibility criterion: you must confirm that your site isn't susceptible to script attacks that could affect the embedded payment form, which in practice still means the same CSP and script-change controls. Full redirects to the PSP's page are outside that criterion. Confirm the details with your acquirer.
- Delegate
paymentnarrowly. Only giveallow="payment"to the iframe that needs it, and setPermissions-Policy: payment=()on pages that never take payments.
Browser support¶
Support data as of September 2026. See MDN's Payment Request API page and caniuse: Payment Request API for live data.
| Feature | Chrome / Edge desktop | Chrome Android | Safari macOS | Safari iOS / iPadOS | Firefox | Samsung Internet |
|---|---|---|---|---|---|---|
PaymentRequest, show(), canMakePayment() | ✅ 60 / 15 | ✅ 53 | ✅ 11.1 | ✅ 11.3 | 🧪 flag since 55 | ✅ 6.0 |
| Shipping and contact collection | ✅ ⚠️ | ✅ ⚠️ | ✅ ⚠️ | ✅ ⚠️ | ❌ | ✅ |
paymentmethodchange | ✅ 76 / 79 | ✅ 76 | ✅ 12.1 | ✅ 12.2 | ❌ | ✅ 12.0 |
retry() / payerdetailchange | ✅ 78 / 79 | ✅ 78 | ✅ 12.1 | ✅ 11.3 / 12.2 | ❌ | ✅ 10.0 / 12.0 |
| Apple Pay via Payment Request | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| Apple Pay JS SDK (QR handoff) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Google Pay via Payment Request | ✅ | ✅ | ❌ | ❌ | ❌ | ⚠️ |
| Web-based Payment Handler | ✅ 70 / 79 | ✅ 70 | ❌ | ❌ | ❌ | ✅ 10.0 |
| Secure Payment Confirmation | ✅ 95 | ✅ 109 | ❌ | ❌ | ❌ | ✅ 17.0 |
securePaymentConfirmationAvailability() | ✅ 139 | ✅ 139 | ❌ | ❌ | ❌ | ✅ 30.0 |
| Digital Goods API | ⚠️ Edge 134 (Microsoft Store) | ✅ 101 (TWA) | ❌ | ❌ | ❌ | ✅ 19.0 |
Two numbers separated by a slash are Chrome / Edge in the desktop column, and retry() / payerdetailchange in the retry() row. ⚠️ Shipping and contact collection: MDN lists PaymentRequest.shippingAddress, shippingOption, shippingType and the shipping events as deprecated and non-standard (the 2022 Recommendation dropped them; the June 2026 Candidate Recommendation Draft restores them), but they work in Chromium and Safari. Apple Pay JS SDK: in non-Safari browsers on any platform, the SDK hands the payment to an iPhone running iOS 18 or later. Google doesn't document Google Pay through Payment Request in Samsung Internet; test it before relying on it, or use the Google Pay JS library there. Android WebView exposes PaymentRequest from version 136, but only for Android payment apps and when the embedding app enables it. Digital Goods API on desktop: Edge only, for Microsoft Store installs; Chrome desktop doesn't expose it.
A complete checkout implementation¶
The following client module renders the best available button, builds one PaymentRequest with Apple Pay and Google Pay, prices shipping on the server, validates the merchant for Apple Pay, charges on the server and completes the sheet. When nothing is available it reveals the PSP card form.
// Payment Request checkout with Apple Pay and Google Pay, server-side pricing and
// graceful fallback. Assumes a <button id="pay"> and a hidden <form id="card-form">.
const APPLE_PAY = {
supportedMethods: "https://apple.com/apple-pay",
data: {
version: 3,
merchantIdentifier: "merchant.com.example.shop",
merchantCapabilities: ["supports3DS"],
supportedNetworks: ["visa", "masterCard", "amex", "discover"],
countryCode: "US",
},
};
const GOOGLE_PAY = {
supportedMethods: "https://google.com/pay",
data: {
environment: "PRODUCTION",
apiVersion: 2,
apiVersionMinor: 0,
merchantInfo: { merchantName: "Example Coffee", merchantId: "BCR2DN4T0000000" },
allowedPaymentMethods: [{
type: "CARD",
parameters: {
allowedAuthMethods: ["PAN_ONLY", "CRYPTOGRAM_3DS"],
allowedCardNetworks: ["AMEX", "DISCOVER", "MASTERCARD", "VISA"],
},
tokenizationSpecification: {
type: "PAYMENT_GATEWAY",
parameters: { gateway: "example", gatewayMerchantId: "exampleGatewayMerchantId" },
},
}],
},
};
const OPTIONS = { requestPayerEmail: true, requestPayerName: true, requestShipping: true };
const payButton = document.getElementById("pay");
const cardForm = document.getElementById("card-form");
let availableMethods = [];
let activeRequest = null;
async function postJSON(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
cache: "no-store", // payment calls must never be served from any cache
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`${url} failed with ${res.status}`);
return res.json();
}
async function detectMethods() {
if (!("PaymentRequest" in window) || !window.isSecureContext) return [];
const found = [];
for (const method of [APPLE_PAY, GOOGLE_PAY]) {
try {
const probe = new PaymentRequest([method], {
total: { label: "Probe", amount: { currency: "USD", value: "0.00" } },
});
if (await probe.canMakePayment()) found.push(method);
} catch (err) {
// TypeError for data a browser rejects, SecurityError in a blocked iframe.
console.debug(`${method.supportedMethods} unavailable:`, err.name);
}
}
return found;
}
function showCardFallback(message) {
payButton.hidden = true;
cardForm.hidden = false;
if (message) cardForm.querySelector("[data-status]").textContent = message;
}
async function init() {
availableMethods = await detectMethods();
if (availableMethods.length === 0) return showCardFallback();
const isApplePay = availableMethods[0] === APPLE_PAY;
payButton.textContent = isApplePay ? "Pay with Apple Pay" : "Pay with Google Pay";
payButton.hidden = false;
// Reflect connectivity: the sheet can't authorize offline.
const syncOnline = () => { payButton.disabled = !navigator.onLine; };
addEventListener("online", syncOnline);
addEventListener("offline", syncOnline);
syncOnline();
}
function onPayClick() {
if (activeRequest) return; // one sheet at a time
const cart = window.appState.cart; // { orderId, items: [{sku, qty}] }
const provisional = window.appState.provisionalDetails; // server-rendered, pending: true
const request = new PaymentRequest(availableMethods, provisional, OPTIONS);
activeRequest = request;
request.addEventListener("merchantvalidation", (event) => {
event.complete(postJSON("/api/apple-pay/session", { orderId: cart.orderId }));
});
request.addEventListener("shippingaddresschange", (event) => {
const addr = request.shippingAddress; // redacted until acceptance in Chromium
event.updateWith(
postJSON("/api/quote", {
orderId: cart.orderId,
country: addr.country,
region: addr.region,
city: addr.city,
postalCode: addr.postalCode,
}).catch(() => ({ error: "Shipping can't be calculated right now.", shippingOptions: [] })),
);
});
request.addEventListener("shippingoptionchange", (event) => {
// A rejected updateWith() promise aborts the whole request, so turn network
// failures into an in-sheet error instead.
event.updateWith(postJSON("/api/quote", {
orderId: cart.orderId,
shippingOption: request.shippingOption,
}).catch(() => ({ error: "Shipping can't be calculated right now." })));
});
// Price the cart on the server while the sheet opens (detailsPromise).
const finalDetails = postJSON("/api/quote", { orderId: cart.orderId });
request.show(finalDetails)
.then(handleResponse)
.catch((err) => {
if (err.name === "AbortError") return; // user closed the sheet
if (err.name === "NotSupportedError") return showCardFallback("Choose another payment method.");
console.error("Payment sheet error:", err);
showCardFallback("Something went wrong. You can pay by card instead.");
})
.finally(() => { activeRequest = null; });
}
async function handleResponse(response) {
let result;
try {
result = await postJSON("/api/orders", {
orderId: response.requestId, // idempotency key on the server
methodName: response.methodName,
details: response.details, // token: forwarded to the PSP unmodified
payer: { name: response.payerName, email: response.payerEmail },
shippingAddress: response.shippingAddress?.toJSON?.() ?? response.shippingAddress,
shippingOption: response.shippingOption,
});
} catch (err) {
await response.complete("fail");
throw err;
}
if (result.status === "invalid-shipping") {
// Let the user fix the address without starting over.
await response.retry({ shippingAddress: result.fieldErrors });
return handleResponse(response);
}
await response.complete(result.status === "paid" ? "success" : "fail");
if (result.status === "paid") location.assign(`/orders/${encodeURIComponent(result.orderId)}`);
}
// Close an open sheet if the page is being hidden or the cart changes elsewhere.
addEventListener("pagehide", () => activeRequest?.abort().catch(() => {}));
new BroadcastChannel("cart").addEventListener("message", () => activeRequest?.abort().catch(() => {}));
payButton.addEventListener("click", onPayClick);
init();
The server validates the Apple Pay merchant, quotes and charges. The PSP call is a placeholder for your provider's SDK; everything else is complete Node.js (18+, ES modules, Express 4 or 5):
import express from "express";
import https from "node:https";
import { readFileSync } from "node:fs";
const app = express();
app.use(express.json({ limit: "64kb" }));
// Never let a cache, a proxy or the service worker store payment responses.
app.use("/api", (req, res, next) => { res.set("Cache-Control", "no-store"); next(); });
const MERCHANT_ID = "merchant.com.example.shop";
const DOMAIN = "shop.example";
const identityCert = readFileSync(process.env.APPLE_PAY_CERT_PEM);
const identityKey = readFileSync(process.env.APPLE_PAY_KEY_PEM);
// Fixed endpoint: avoids connecting to a client-supplied validationURL.
function requestMerchantSession() {
const body = JSON.stringify({
merchantIdentifier: MERCHANT_ID,
displayName: "Example Coffee",
initiative: "web",
initiativeContext: DOMAIN,
});
return new Promise((resolve, reject) => {
const req = https.request({
hostname: "apple-pay-gateway.apple.com",
path: "/paymentservices/paymentSession",
method: "POST",
cert: identityCert, // mutual TLS with the merchant identity certificate
key: identityKey,
minVersion: "TLSv1.2",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
timeout: 10_000,
}, (res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => { data += chunk; });
res.on("end", () => {
if (res.statusCode !== 200) return reject(new Error(`Apple Pay session ${res.statusCode}: ${data}`));
resolve(JSON.parse(data)); // opaque; single use; expires after 5 minutes
});
});
req.on("timeout", () => req.destroy(new Error("Apple Pay session timeout")));
req.on("error", reject);
req.end(body);
});
}
app.post("/api/apple-pay/session", async (req, res) => {
try {
res.json(await requestMerchantSession());
} catch (err) {
console.error(err);
res.status(502).json({ error: "merchant validation failed" });
}
});
// Pricing lives on the server. loadCart, priceCart, shippingOptionsFor, shippingOptionById,
// markPaid and the psp client are your domain code.
app.post("/api/quote", async (req, res) => {
const cart = await loadCart(req.body.orderId);
if (!cart) return res.status(404).json({ error: "unknown order" });
const options = await shippingOptionsFor(cart, req.body); // [] when you can't ship there
if (options.length === 0) {
return res.json({ error: "We don't ship to this address.", shippingOptions: [] });
}
const selected = options.find((o) => o.id === req.body.shippingOption) ?? options[0];
const { items, total } = priceCart(cart, selected); // decimal math, amounts as strings
res.json({
total: { label: "Total", amount: { currency: cart.currency, value: total } },
displayItems: items,
shippingOptions: options.map((o) => ({ ...o, selected: o.id === selected.id })),
});
});
app.post("/api/orders", async (req, res) => {
const { orderId, methodName, details, shippingOption } = req.body;
const cart = await loadCart(orderId);
if (!cart) return res.status(404).json({ status: "error" });
if (cart.status === "paid") return res.json({ status: "paid", orderId }); // idempotent replay
const { total } = priceCart(cart, await shippingOptionById(cart, shippingOption));
try {
const charge = await psp.charge({ // placeholder for your PSP SDK
idempotencyKey: `order-${orderId}`,
amount: total,
currency: cart.currency,
source: methodName === "https://apple.com/apple-pay"
? { type: "apple_pay", token: details.token }
: { type: "google_pay", token: details.paymentMethodData.tokenizationData.token },
});
await markPaid(orderId, charge.id, req.body);
res.json({ status: "paid", orderId });
} catch (err) {
console.error("charge failed", orderId, err.code);
res.json({ status: "declined" });
}
});
app.listen(8443);
Common pitfalls¶
- Awaiting before
updateWith(). The event finishes dispatching during theawait, and the late call throwsInvalidStateError. Pass a promise instead. - Awaiting before
show()in Safari. Anyawaitbetween the click andshow()loses the user gesture, and Apple Pay rejects. UsedetailsPromisefor late pricing. - Reusing a
PaymentRequest. Aftershow()settles, the object isclosed. Build a new one per attempt. - Number amounts.
value: 10.5is converted to the string"10.5", but floating-point arithmetic (0.1 + 0.2) produces invalid values. Compute on the server with decimals and send strings. - Treating
canMakePayment() === trueas "card on file". It means a handler exists. For Apple Pay useapplePayCapabilities(); otherwise always offer an alternative. - Forgetting
complete(). The sheet keeps spinning until the browser's timeout. Callcomplete()in every branch, including errors. - Unverified domains and expired certificates. Apple Pay silently fails merchant validation on a subdomain you forgot to verify or after the merchant identity certificate expires.
- Caching payment endpoints. A service worker route such as
/api/*with stale-while-revalidate returns yesterday's quote or order status. Exclude payment routes explicitly. - Granting Play purchases on the client. Always verify and acknowledge
purchaseTokenon the server; unacknowledged purchases are refunded after three days. - Probing too eagerly. Repeated
canMakePayment()calls with varying data get throttled. Probe once per page load. allow="payment"everywhere. Delegate the feature only to the iframe that takes the payment.
Debugging¶
- Chrome DevTools → Application → Payment Handler records Payment Handler events; check "Show events from other domains" when debugging a merchant integration. Use remote debugging (
chrome://inspect) for Android and TWAs. - Apple Pay sandbox. Sign in on a test device with an Apple Pay sandbox tester account (App Store Connect → Users and Access → Sandbox), add Apple's test cards to Wallet, and point your server at the sandbox gateway
apple-pay-gateway-cert.apple.comduring development. Safari's Web Inspector shows themerchantvalidationflow in the Network tab. - Google Pay. Use
environment: "TEST"to receive dummy tokens without a real card; switch to"PRODUCTION"after Google approves the integration. - Play Billing. Enable
chrome://flags/#enable-debug-for-store-billingon a development device, use license testers and test SKUs in Play Console, and watchadb logcatfor billing errors. - Local HTTPS. Payment Request needs a secure context;
http://localhostcounts. For other hosts, Chrome's--unsafely-treat-insecure-origin-as-secureflag helps during development only.
Further reading¶
On this site
- Authentication & Passkeys: WebAuthn in depth, the foundation of Secure Payment Confirmation
- Trusted Web Activity: packaging a PWA for Google Play, Digital Asset Links
- Publishing to App Stores: store policies and packaging for Play, Microsoft Store and the App Store
- Content Security Policy: locking down the payment page
- Permissions: Permissions Policy and iframe delegation
- Service Worker Security: keeping payment traffic out of caches
- Background Sync: queueing orders, with idempotency
- Device & OS Integration: the other capability APIs
External references
- Payment Request API, W3C editor's draft and current W3C Candidate Recommendation Draft
- MDN: Payment Request API
- Apple Pay on the Web documentation
- Google Pay API for Web: Payment Request tutorial
- Web-based Payment Handler API, W3C and web.dev: Setting up a payment method
- Secure Payment Confirmation specification and Chrome's SPC overview
- Digital Goods API (WICG), Chrome: Play Billing in TWAs and Microsoft Edge: Digital Goods API
- Google Play Payments policy
- caniuse: Payment Request API