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.
1. Detect the API and pre-check a method
Section titled “1. Detect the API and pre-check a method”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.
See also
Section titled “See also”- Payment Request API: browser-mediated checkout
- Digital Goods API (Play Billing in TWAs)
- Payments
- Payment Request API: show() method (developer.mozilla.org)
- Payment Request API specification (w3.org)
← Back to the Guides overview.