Skip to content

Monetize a PWA

Published

At the end of this guide a checkout button in your PWA opens the browser’s own payment sheet through the Payment Request API, takes the shopper’s card or wallet details, hands your server a payment response, and falls back to your existing form in browsers that reject the request. The API is a standard way to collect payment, address, and contact details; it does not move money itself, your payment processor still does that.

You need a page served over HTTPS (the API is undefined on plain HTTP), a payment method identifier from your processor, and a server endpoint that accepts the processor’s token. Which stores and payment networks allow a web checkout at all is a policy question, kept with dated sources in Payments.

window.PaymentRequest is absent in browsers without the API, so test for it before rendering a native-checkout button. When it exists, build a throw-away request and call canMakePayment(): it resolves true when the browser can handle at least one of the methods you listed. The URL-based identifiers below are the form MDN’s examples use; your processor documents its own.

const methodData = [{ supportedMethods: 'https://example.com/pay' }];
const stubDetails = {
total: { label: 'Stub', amount: { currency: 'USD', value: '0.01' } },
};
async function nativeCheckoutAvailable() {
if (!('PaymentRequest' in window)) return false;
try {
return await new PaymentRequest(methodData, stubDetails).canMakePayment();
} catch {
// The user may have disabled the query in privacy settings, or the
// browser rate-limits repeated calls with a DOMException.
return false;
}
}

Call canMakePayment() once per page, not on every render: MDN notes the browser may reject the promise with a DOMException when it is called too often. Keep the methodData passed to the stub identical to the one you pass to the real request, otherwise the pre-check answers a different question.

2. Build the request inside the click handler

Section titled “2. Build the request inside the click handler”

Create a fresh PaymentRequest for every click: show() can be called once per instance. Call show() synchronously in the handler. The specification lets the browser reject show() with a SecurityError when the page has no transient user activation, and Chrome does so, which is why an await for a network call between the click and show() breaks checkout. If the totals are not ready yet, pass a promise as show()’s argument instead of awaiting first.

function buildDetails(cart) {
return {
id: cart.orderId,
displayItems: cart.lines.map((line) => ({
label: line.name,
amount: { currency: 'USD', value: line.price },
})),
total: { label: 'Total', amount: { currency: 'USD', value: cart.total } },
};
}
document.querySelector('#checkout').addEventListener('click', async () => {
const request = new PaymentRequest(methodData, buildDetails(currentCart()));
try {
const response = await request.show();
const result = await fetch('/api/charge', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ orderId: currentCart().orderId, details: response.details }),
});
await response.complete(result.ok ? 'success' : 'fail');
} catch (error) {
if (error.name === 'NotSupportedError') {
location.assign('/checkout/form'); // no payment app for this method
} else if (error.name !== 'AbortError') {
throw error; // a closed sheet is not an error
}
}
});

response.details carries whatever the payment method returns: with Google Pay that is a payment token your backend forwards to the processor, which is why the charge happens server-side and complete() is called with the server’s verdict. Google Pay also ships its own JavaScript library (pay.js) that drives the same sheet; the trade-off is one more script on the page against a button that renders in browsers where the Payment Request API is unavailable.

3. Keep the form path and show the right button

Section titled “3. Keep the form path and show the right button”

Render the native-checkout button only when step 1 returned true; otherwise render the link to your form checkout. The native sheet saves the shopper typing on a phone, at the cost of a second checkout path you have to test. The form is the fallback for browsers without the API (see Payment Request API support) and for shoppers who closed the sheet.

nativeCheckoutAvailable().then((ok) => {
document.querySelector('#checkout').hidden = !ok;
document.querySelector('#checkout-form-link').hidden = ok;
});

When the sheet closes with success, the browser dismisses it and your page continues; when it closes with fail, the browser shows its own error state. You now have a checkout that uses the browser’s sheet where it exists and your form everywhere else.

← Back to the Guides overview.