PWA Payment Integration: The Three Things That Actually Differ
The short answer
A PWA takes payments the same way any website does. There is no PWA-specific payment SDK, no separate merchant account, and no store commission — your existing gateway integration (Stripe, Adyen, PayPal, a local PSP, a hosted checkout page) works unchanged.
What does change is the environment the checkout runs in. An installed PWA launches in standalone display mode, sits behind a service worker, and often lives in a slightly different storage context than the same site in a browser tab. Every payment bug specific to PWAs traces back to one of those three facts. This article is about those, not about picking a gateway.
Rule zero: the service worker must not touch checkout
Most generic service-worker recipes cache aggressively by default. On a store, that is a financial-incident generator.
Draw the line explicitly:
|
Route |
Strategy |
|---|---|
|
App shell, CSS, JS, fonts, logos |
Cache-first, versioned cache name |
|
Category and product content |
Network-first, cache fallback |
|
Prices, stock, cart contents |
Network-only |
|
Checkout, payment, order confirmation |
Network-only — and excluded from the fetch handler entirely |
|
Gateway domains and payment iframes |
Never intercepted |
A short-circuit at the top of the fetch handler is the cleanest form:
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Never intervene in payment traffic: let the network handle it.
if (url.origin !== self.location.origin) return;
if (/^\/(checkout|payment|cart|orders|api\/(cart|checkout|pay))/.test(url.pathname)) return;
if (event.request.method !== 'GET') return;
event.respondWith(handleGet(event.request));
});Two details in there matter as much as the path list. Returning early leaves the request to the browser's default handling instead of a cached copy. And skipping non-GET requests means no POST to a payment endpoint is ever replayed from a cache or a retry queue — a replayed payment POST is a double charge.
The redirect problem, which is the real one
Card payments in most markets involve a hop away from your origin: a 3-D Secure challenge, a bank app handoff, a local wallet, a hosted checkout page. In a browser tab that hop is invisible. In an installed PWA it is where things break.
Three failure modes to design against:
The user leaves standalone mode and does not come back. On some Android configurations a redirect to a third-party origin opens a Custom Tab or the default browser. The payment completes — in a different context from the one holding your session. The fix is a return URL that points at your own origin and a server-side order state you can re-read on arrival, rather than relying on in-page JavaScript state surviving the round trip.
Session or state is lost across the hop. Storage partitioning and ITP-style restrictions mean anything you stashed in sessionStorage before the redirect may not be there afterwards. Carry the order reference in the return URL itself and rehydrate from the server, never from client storage alone.
The confirmation page is reached twice, or not at all. Users refresh, background the app, or lose connectivity mid-redirect. Make the confirmation route idempotent and safe to open cold with nothing but an order ID.
Where the gateway offers one, an in-page or iframe-based flow avoids the hop entirely and is the more robust choice inside a PWA. Prefer it; keep the redirect flow as the fallback and test it explicitly.
Native-feeling payment sheets on the web
Two APIs get you the OS payment sheet without leaving the web:
- Payment Request API — a browser-level sheet holding saved cards and addresses. Support is uneven across browsers and markets, so it must be a progressive enhancement layered on a working form, never the only path.
- Google Pay / Apple Pay on the web — both work in a PWA. Apple Pay requires domain verification (a file served from
/.well-known/) and runs only in Safari and Safari-backed web views, which includes an installed iOS PWA. Google Pay works on Android Chrome including standalone mode.
Detect capability at runtime, show the wallet button only when it resolves, and always keep the plain card form reachable. A wallet button that fails silently costs more conversions than it wins.
Server-side rules that PWAs make non-negotiable
The environment amplifies ordinary mistakes, so a few standard practices become mandatory:
- Idempotency keys on every payment call. A flaky mobile connection means retries. Generate the key when the checkout starts, not per attempt, so a retry of the same purchase cannot become a second charge.
- Webhooks are the source of truth for order state. The browser may never reach your confirmation page — app backgrounded, connection dropped, redirect swallowed. Fulfilment must be driven by the gateway's server-to-server callback, with the page merely displaying what the server already knows.
- Re-validate price and stock server-side before charging. In a cached-shell app you must assume the client is showing something stale. The authoritative price is the one your server computes at charge time.
- Verify webhook signatures and treat delivery as at-least-once. Store processed event IDs and ignore repeats.
Offline behaviour: be honest, not clever
The instinct to queue payments with Background Sync should be resisted. A queued order the shopper believes succeeded is worse than a clear failure — prices move, stock runs out, and the charge lands minutes later with no one watching.
The honest design:
- Detect offline before the payment step and say so plainly.
- Preserve the cart (server-side where the user is signed in) and promise only that.
- Disable the pay button rather than letting it fail into an ambiguous state.
- On reconnect, re-fetch the cart and re-validate price and stock before re-enabling payment.
Background Sync is right for saving a cart or submitting a review. It is wrong for money.
A pre-launch test pass that catches the real bugs
Run these on an installed PWA on a real device, not in a desktop browser tab — several of them are unreproducible anywhere else:
- Complete a card payment from the installed home-screen icon, including the 3-D Secure challenge, and confirm you land back in standalone mode with the order state correct.
- Kill the app during the redirect, reopen from the icon, and confirm the order resolves to a single correct state.
- Turn on airplane mode at the payment step; confirm the message is clear and the pay button is disabled.
- Deploy a new service-worker version mid-session and confirm no checkout route is served from the old cache.
- Double-tap pay on a slow connection and confirm exactly one charge (this is the idempotency-key test).
- Verify the confirmation page renders correctly when opened cold from the order URL with no client state.
- Repeat the whole pass on iOS, where the installed context differs most.
Where this sits in the build
Payments are the last thing to wire and the first thing to break, so the sequence matters: get the manifest right so the app installs at all, set the caching policy per content type, and only then wire checkout with the payment routes explicitly carved out. The broader store-level trade-offs are in PWA for ecommerce; if you are still weighing packaging, PWA vs APK covers that decision.
For teams shipping web storefronts to Android audiences, ROIBest handles the delivery layer — install behaviour, service-worker scope, and push — so the payment integration stays a normal web integration rather than a PWA-specific project.


