# Monetize a PWA

> Take a payment in a PWA with the Payment Request API: detect support, build the request inside a click handler, call show(), and keep a form fallback.

At the end of this guide a checkout button in your PWA opens the browser's own payment
sheet through the [Payment Request API](/reference/capabilities/payment-request/), 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](/ecosystem/payments/).

## 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.

```js
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

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.

```js
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.

:::observed
Chrome rejects a `show()` call made after the click's activation has expired with a
`SecurityError` whose message reads `PaymentRequest.show() requires either transient user
activation or delegated payment request capability`. The same message appears when a
cross-origin `<iframe>` calls `show()` without a `delegate: "payment"` `postMessage()` from
the top frame. Reproduce it by inserting `await new Promise((r) => setTimeout(r, 10000))`
before `show()` in the handler above.
:::

## 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](/compatibility/payment-request/) support) and for shoppers who closed the sheet.

```js
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

- [Payment Request API: browser-mediated checkout](/reference/capabilities/payment-request/)
- [Digital Goods API (Play Billing in TWAs)](/reference/capabilities/digital-goods/)
- [Payments](/ecosystem/payments/)
- [Payment Request API: show() method](https://developer.mozilla.org/en-US/docs/Web/API/PaymentRequest/show) (developer.mozilla.org)
- [Payment Request API specification](https://www.w3.org/TR/payment-request/) (w3.org)

← Back to the [Guides](/guides/) overview.