Skip to content

Web Checkout in a Native Mobile App (WebView)

Unsupported Integration

Web checkout in a mobile app is an unsupported integration. Mobile WebViews are not compatible with Glomo's Unified Web SDK (and Checkout) due to known platform-specific complications, bottlenecks, and limitations. And alignment with compliance requirements such as PCI DSS information security is an added challenge. These custom integrations are considered experimental and flaky - prone to payment failures, race conditions, and unstability.

We officially recommend our first-party native SDKs: Flutter SDK and React Native SDK. They are lightweight, work out of the box, and receive frequent releases with active support.

The Glomo web checkout is built for browsers. To use it inside a native mobile app you have to host it in a system WebView (WKWebView on iOS, android.webkit.WebView on Android). A WebView is not a full browser — it omits or changes behaviour that payment and bank pages rely on, so a checkout that works perfectly in mobile Safari or Chrome can fail when the same page is loaded inside an app.

Glomo ships native Flutter and React Native SDKs precisely so you don't have to solve these problems yourself. Under the hood they load the hosted checkout page directly in a correctly-configured WebView, inject a bridge so the page can talk to the native layer, and handle every issue listed below.

The native SDK is the fastest path to a stable live integration, not a later optimisation — all of the handling on this page already comes solved. A self-built WebView host is slower to get right, not quicker, because you take on every item here yourself. If your app is written in a framework our SDKs cover, use the SDK.

This page is for teams who have no supported native SDK and must build their own WebView host. Treat it as a non-trivial integration: every item below is a real class of issue we have had to handle in our own native SDKs, a raw WebView host inherits none of that handling by default, and behaviour differs between iOS and Android, so both platforms must be tested independently.

How the native SDKs load the checkout

The native SDKs do not run the Unified (Web) SDK JavaScript. That SDK is for web pages — it injects the checkout as an <iframe> into your page and communicates with the surrounding browser window. Instead, the native SDKs:

  1. Build the checkout page URL for the order type and load it directly as the top-level document in a WebView.
  2. Inject a native bridge into that WebView. The checkout page detects this bridge and switches into WebView mode — it routes results to the native layer instead of a parent web window, and uses in-app handling for 3D-Secure / OTP instead of browser popups.
  3. Handle new windows, redirects, keyboard, and the other issues below around that page.

This is why dropping the Unified (Web) SDK into a WebView does not give you a working native integration: you get an iframe inside a WebView whose results are delivered to an intermediate web page (not to native), and whose bank/3D-Secure popups fail. Loading the page URL directly, plus the bridge and handling below, is the approach that works.

Entry URL

Load a single entry URL as the top-level document in the WebView. It resolves the order type (standard vs LRS) on the server and 302-redirects the WebView to the correct checkout page — so you never hardcode the individual checkout hosts, and you don't need to know the order type up front:

https://api.glomopay.com/api/public/v1/checkout/redirect?resource_id={RESOURCE_ID}&public_key={PUBLIC_KEY}

The same URL works for both live and test — the gateway routes to the correct environment from your public key's prefix.

ParameterRequiredWhere it comes from
resource_idYesThe order / subscription / payment-session id you create on your server. Standard: Checkout → Steps to integrate Checkout (use the returned order_id) or the Order reference. LRS: LRS → Integration guide → Create an order on the server.
public_keyYesYour merchant public key from the dashboard. The prefix (live_ / test_) selects the environment.
color_schemeNoYou. light, dark or system - the colour scheme checkout should present. Omit it and checkout is light. See Dark mode.

Two things to get right:

  • Navigate to it; do not fetch() it. Set the URL as the WebView's document (a navigation). A cross-origin fetch from your own JavaScript would be blocked by CORS; a top-level navigation is not.
  • Do not pass mode. The checkout derives live/mock from your public key.

The entry URL gets you to the right checkout page, but loading it is not a complete integration on its own. The checkout still runs inside your WebView, so you must supply the WebView handling below — the message bridge (so results reach your app), new-window handling (bank / 3D-Secure), the iOS fixes, and back navigation. These are exactly what our native SDKs provide and a self-built WebView host does not get for free.

What you must handle in your own WebView

WebView configuration prerequisites

Several WebView settings are off or restrictive by default and will break the checkout if not enabled:

  • JavaScript must be enabled.
  • DOM storage / local storage must be enabled — the checkout keeps session state there.
  • Third-party cookies must be allowed. On Android these are disabled by default and are a common cause of bank sessions silently failing partway through authentication.
  • Multiple windows / automatic window opening must be enabled, because bank and 3D-Secure pages open new windows (see below).
  • Mixed content often needs to be allowed — some bank pages serve a mix of secure and non-secure sub-resources.
  • File inputs need a file chooser on Android. Without one, every upload control in the checkout is dead (see Uploading documents from the device).
  • Caching should generally be disabled (or the WebView run in an incognito/no-persistence mode) so that state does not leak between one checkout attempt and the next. Clear cookies and storage when a checkout instance is torn down.
  • Avoid overriding the WebView's user agent unless you have tested it — some bank pages serve a different (and sometimes broken) layout based on the user-agent string.

Blank or white checkout screen

Symptom: The checkout container opens but shows nothing — a blank white screen with no content and no error.

Cause: Several distinct startup problems present the same way: any host-side script the checkout expects failed to load; the URL passed to the WebView was empty or malformed; a repeat attempt for the same order was served a cached 304 Not Modified instead of a fresh page; or the host tried to post a message to the page after the WebView was already torn down, crashing the page context.

What to handle: Validate the URL before loading and don't render if it is missing. Send no-cache headers (or otherwise bust the cache) so that retrying the same order does not return a stale cached response. Guard any message you post into the WebView so that posting after teardown fails safely instead of crashing. Surface a clear error state rather than leaving the customer on a blank screen.

New windows and popups (bank and 3D-Secure pages)

Symptom: The customer reaches the point where a bank or 3D-Secure page should open, and nothing happens — the flow stalls on a blank or unchanged screen.

Cause: Bank and card-authentication pages routinely open a new window (window.open), and some submit a form targeting a new tab (target="_blank"). A WebView has no concept of a new tab, so these calls are silently dropped.

What to handle: Your host must intercept new-window requests and load the target URL somewhere the user can see it — typically a second WebView presented as an overlay on top of the checkout. You also need to handle the page programmatically closing that window when authentication finishes, and cases where the bank page navigates via a form submission intended for a new tab. Getting this wrong is the single most common reason bank flows appear "frozen" inside an app.

Keeping bank redirects inside the WebView

Symptom: A multi-step bank flow dead-ends or loops midway through authentication.

Cause: Bank and 3D-Secure flows chain through several redirects before returning to the checkout.

What to handle: Let these redirects proceed inside the overlay WebView rather than trying to intercept and route each one natively — over-eager interception is a common cause of broken bank flows. Keeping bank authentication inside the app (rather than bouncing out to an external browser) also keeps the session and cookies consistent across the whole redirect chain, which is how our native SDKs handle it.

iOS input zoom and OTP fields

Symptom (iOS): When the customer taps an OTP box or other input, the page visibly zooms in and the layout jumps; moving between single-character OTP boxes makes the screen and keyboard jitter.

Cause: iOS WebViews automatically zoom the viewport when an input with a computed font size below 16px receives focus, then zoom back out afterward.

What to handle: The zoom fires whenever an input's effective font size is below 16px — including OTP and login fields on the bank pages, which you do not control. The way to neutralise it is to inject CSS into the WebView that forces a 16px-or-larger font size on inputs. This is exactly how our native SDKs handle it — they inject CSS into the hosted page rather than modifying its source, which is how a WebView host reaches fields it does not own. OTP entry is the most affected area because it uses many small single-character inputs in a row.

On-screen keyboard and sticky footers

Symptom (mainly iOS): When the keyboard opens, it covers the active input, or a fixed bottom element (such as a "Pay now" / amount footer) rides up and hides the field the customer is typing into.

Cause: Opening the keyboard shrinks the visible viewport. Fixed and sticky elements anchored to the bottom reposition into the reduced viewport and can overlap the input.

What to handle: React to the keyboard opening and closing so the active field stays visible, and avoid letting bottom-anchored elements cover inputs while the keyboard is up.

Viewport, zoom, and page scrolling

Symptom: The checkout loads already zoomed in, stays pinch-zoomable when it shouldn't, or auto-scrolls unexpectedly (for example jumping to the top of a bank consent page when a checkbox is tapped).

Cause: Default viewport settings differ from a browser, and naive "scroll the focused element into view" logic conflicts with how bank pages lay out their controls (some hide the real control off-screen).

What to handle: Establish and lock a sensible viewport (fit to device width, disable user scaling), and be very conservative about programmatic scrolling — over-eager auto-scroll causes more problems on bank pages than it solves. In our own SDK we ultimately removed aggressive auto-scroll behaviour because it fought with bank consent pages.

Text selection and paste on secured pages (iOS)

Symptom (iOS): The customer cannot paste into an input — for example, pasting a copied OTP or reference number does nothing and no paste menu appears.

Cause: Some checkout and bank pages disable text selection globally for security. iOS WebViews honour this even inside legitimate input fields, so the paste/selection menu never shows.

What to handle: Re-enable text selection and the paste menu specifically on editable fields so customers can paste into inputs, without re-enabling selection across the whole page.

Session cookies and credentialed requests (iOS)

Symptom (iOS): The checkout fails to bootstrap, or the bank session is lost between steps, even though the same flow works on the web.

Cause: iOS WebViews can be strict about sending cookies with background requests and, in some cases, about parsing the top-level document response.

What to handle: Ensure session cookies are sent with the checkout's own requests, and be prepared to retry the initial document load if the WebView reports a parse error on it. Confirming that third-party cookies are enabled (see configuration above) addresses most of the session-loss cases.

Uploading documents from the device

Symptom (Android): The customer taps Upload file on a document step — the ITR or bank statement a bank asks for in an LRS payment, or a KYC document — and nothing happens. No picker opens, no error is shown, and the customer cannot get past the step. The same page works in mobile browsers and on iOS.

Cause: The upload control is a standard <input type="file">. A browser opens its own picker for it; an Android WebView hands the tap to WebChromeClient.onShowFileChooser, and the default implementation drops it. This applies to every frame in the WebView, including the checkout iframe if you load the Unified (Web) SDK inside one. On iOS, WKWebView presents the system picker itself.

What to handle: On Android, implement onShowFileChooser and answer every file request, including when the customer cancels. On iOS, declare NSCameraUsageDescription and NSPhotoLibraryUsageDescription in your Info.plist. To try an upload before release, see Trigger the document upload step.

If your app is built on Flutter (1.11.0 and later), React Native, our Android SDK (v2), or our iOS SDK, file uploads already work with nothing for you to implement.

Downloading documents to the device

Symptom: The customer taps a download or save control — a bank form, an agreement, a receipt — and nothing happens. No file, no error, no sign the tap registered.

Cause: A WebView cannot write a file to the device on its own. On Android, an <a download> on a blob: URL is dropped unless the host registers a DownloadListener, and the Web Share API is absent from the Android WebView entirely — so feature detection on the page reports a capability that is not actually there. On iOS the download has to be taken by the host as well. The checkout page therefore delegates the save to whatever is hosting it, and falls back to the browser download path when no host claims the capability. In a browser that fallback works; inside a WebView it is the silent failure above.

This is live today for bank flows that ask the customer to keep a document — the Kotak flow is the first of them.

What to handle: The page will only delegate to a host that advertises the capability, so a WebView host has to opt in explicitly:

  1. Advertise the capability. Append the string file.save to window.__glomoSdkCapabilities__ (create the array if absent) from your injected script, and only in builds where the handler actually exists. The page reads this array inside the click handler, not at load, so you do not need to signal it — but a host that advertises a handler it does not have makes the tap vanish, because the page treats the message as delivered and skips its own fallback.

  2. Receive the message. The page posts a top-level JSON object on your bridge channel — not wrapped in whatever envelope you use for lifecycle events:

    { "type": "file.save", "requestId": "…", "url": "…", "filename": "…", "mimeType": "application/pdf" }

    url is a short-lived signed link. Treat it as a credential: do not log it, and do not let an error message that interpolates it reach your crash reporter.

  3. Report delivery honestly. If your injected sender swallows exceptions — a collected native peer, a throwing postMessage, a re-entrancy guard — it must return false when nothing was actually sent, and true only once postMessage has run without throwing. The page reads exactly send(...) === false to decide whether to fall back. Without a return value a dropped send is indistinguishable from a delivered one, and the customer's tap disappears with no trace on either side.

  4. Fetch the document and save it natively. This is fire-and-forget: the page sends no follow-up, waits for no acknowledgement, and shows no error if the save fails. Everything after the message is yours, including telling the customer when it did not work.

What we do in our own SDK, as a reference point — your stack will differ, but these are the decisions that cost us time:

  • Fetch over HTTPS only. The signed link becomes a file the customer is told is theirs; a permissive URL check is right for bank redirects and wrong here.
  • Stream the fetch with a size cap (we use 100 MB) enforced mid-stream rather than after, plus a 30s timeout. The whole document has to be held in memory to hand to the save dialog, so an unbounded response takes the host app down with it.
  • Use the platform save dialog, not a share sheet. ACTION_CREATE_DOCUMENT on Android and the document picker in export mode on iOS. An Android share sheet (ACTION_SEND) only offers apps that accept a stream, and the Files app is not one — there is no direct save on it at all. Writing straight into the Downloads collection instead means MediaStore and WRITE_EXTERNAL_STORAGE on API 28 and below.
  • Sanitise the filename. It comes from the page and becomes a path segment: take the basename only, allowlist the characters, cap the length, and fall back to a constant. On iOS also make sure your staging name cannot collide with a file your app already owns.
  • Clean up after the picker. The iOS document-picker export path can leave a copy in your app's documents directory that nothing removes — the customer's agreement then sits in your container indefinitely.
  • Fail loudly but locally. A failed save raises a dismissible message naming what went wrong and is reported to the integrator as a validation error. It never ends the checkout — a document that would not save is not a reason to drop a payment session.
  • Correlate your telemetry with requestId. An agreements screen can have several download controls and saves are not serialised on either side, so without echoing it onto your own events two interleaved saves cannot be told apart after the fact.

If your app is built on Flutter, all of the above ships in Flutter SDK 2.1.0 with nothing for you to implement.

Receiving payment results

Symptom: The payment completes but the app never finds out, so the checkout screen stays open, or the app reports the wrong final status.

Cause: The checkout communicates results by posting messages to its host, not by any native callback. A WebView host must explicitly listen for these messages via its bridge.

What to handle: Wire up a message listener in your host and react to the checkout's lifecycle events — payment success, payment failure, and checkout closed — to decide when to dismiss the WebView and what status to record. In a WebView the message bridge is your result channel; always verify the signed result server-side.

If you host bank pages in a second (overlay) WebView, be aware that the same completion event can be emitted from both WebViews. De-duplicate on your side so a single payment does not fire your completion handling twice.

Back navigation and in-flight payments

Symptom: The hardware/gesture back button (Android) or swipe-back (iOS) exits the whole checkout instead of stepping back within it — and in the worst case interrupts a payment that is already being processed.

Cause: A WebView does not tie the system back gesture to the checkout's own navigation state.

What to handle: Map back navigation to the checkout: if a bank overlay is open, close the overlay and return to the checkout rather than exiting; if the main checkout can go back, do that; only then treat back as a cancel. Critically, do not allow back to abort a payment that is already in progress — block it during that window so a customer cannot break an in-flight transaction.

Loading and timeout handling

Symptom: After the WebView opens, the customer stares at a blank white screen while the entry URL redirects and the checkout page loads and boots — then the underlying WebView may flash through before the checkout paints.

What to handle: Cover the gap with a native loading overlay — this is the single biggest perceived-quality item and it is entirely host-side. Render your own full-screen loader (spinner or branded skeleton) on top of the WebView from the moment you start loading, and hide it only once the checkout signals it has rendered: listen for the rendered/loaded event on your message bridge, falling back to the WebView's page-finished / load-progress callback. Also set the WebView's background colour to your brand colour so the unavoidable pre-paint moment is never a stark white flash. Apply a timeout so that a stuck load (for example under poor network) fails into a clear error state instead of an indefinite spinner — never leave the customer on an unbounded blank screen.

The redirect hop itself is fast (a bodyless 302); most of the gap is the checkout page fetching and booting, which you would see loading the page directly too. Our native SDKs solve this with exactly this pattern — a full-screen overlay held until the checkout's rendered event, with a timeout fallback — and a self-built WebView host must implement it as well.

Also distinguish a genuine network/connection failure (no connectivity, host unreachable, timeout) from an HTTP or payment-level error. The two surface through completely different signals on iOS and Android, and they need different responses: a connection failure should let the customer retry, whereas a payment failure is a real outcome you must record. Treating one as the other leads to misleading messaging.

Dark mode

Checkout can render dark, and this is the one item on this page you get close to free: a query parameter on the URL you already load. The host-side work after that is small, but two of the items below are platform fixes you will not know are missing until the page renders half of each scheme.

Ask for a scheme on the entry URL. The parameter is color_scheme - snake_case, like the rest of that endpoint - and it takes light, dark or system. Anything else is ignored.

https://api.glomopay.com/api/public/v1/checkout/redirect?resource_id={RESOURCE_ID}&public_key={PUBLIC_KEY}&color_scheme=dark

The endpoint forwards it to the checkout page as colorScheme. Both spellings are correct; they belong to different hops, and the property on our native SDKs is appColorScheme, which is a third. Dark mode has the cross-SDK picture.

It is a request, not a switch. Dark mode is enabled per account and the checkout decides, against your dashboard settings. Until it is enabled for yours the entry URL drops the parameter and checkout stays light whatever you pass - so you can ship this ahead of the switch-on and it starts matching once the account is on. White-labelled checkouts always stay light. Contact Glomo support to enable it.

Resolve system yourself and send light or dark. system is resolved inside the page from the prefers-color-scheme media query, and what a WebView reports there is not your app's appearance. On Android it is tied to the algorithmic-darkening setting you are about to switch off, so it does not track the host app. On iOS it comes from the web view's trait collection, which is yours to set (see below) rather than something to read. None of our native SDKs ever puts system on the wire: each reads the host app's own appearance and sends the resolved value. Do the same.

Changing the scheme mid-checkout: call the page, do not reload it. The checkout page exposes a setter on window:

window.glomoSetColorScheme('dark'); // 'light' | 'dark'

Evaluate that in the checkout WebView (evaluateJavascript on Android, evaluateJavaScript on iOS) when your app's theme changes. Do not rebuild the URL and reload - that replaces the document and loses the payment in flight. Three things to know about it:

  • The setter is defined when the checkout app mounts, which can be later than your WebView reporting the page finished. A call before then does nothing, and nothing retries it. The URL parameter is the only reliable way to set the scheme the page opens with.
  • Re-send after any load of your own checkout URL - a retry, a re-entry - and only then. Compare what actually loaded against the URL you asked the WebView to load: a WebView that has redirected to a bank page is on someone else's origin, and that page is not yours to restyle.
  • The page strips colorScheme from its address with history.replaceState once it has read it. If you retry by re-loading a URL you captured from the WebView, the parameter will not be on it. Rebuild from your own source URL.

Android: turn algorithmic darkening off. The checkout handles its own colours and is told which scheme to present; letting the platform invert it on top gives a page nobody designed. Set both APIs, on every WebView you create, the bank overlay included:

  • WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false) governs hosts targeting SDK 33 and above, and is ignored below that.
  • WebSettingsCompat.setForceDark(settings, FORCE_DARK_OFF) is deprecated and is what the platform still reads below targetSdk 33.

Setting only the first leaves every targetSdk-below-33 host darkened by a WebView modern enough to support the replacement, which is most of them. Catch UnsupportedOperationException around the first call and carry on: its feature check is answered by the framework and passes on every API 33+ device, but the call itself routes through the installed WebView provider and throws when that provider predates the feature. Skipping it leaves the page as the WebView would have rendered it; letting it propagate takes your checkout screen down before the customer can pay. The bank overlay is sent no scheme - it is the bank's page, not ours - but it must not be algorithmically darkened either.

iOS: pin the web view's interface style to the scheme you sent. Set overrideUserInterfaceStyle to .dark when you asked for dark and .light when you asked for light, on each WebView you create. WKWebView reports prefers-color-scheme from its trait collection, which inherits your app, so a page told colorScheme=dark inside a light app still matches prefers-color-scheme: light in its own CSS and renders half of each. There is no algorithmic darkening on iOS, so the Android item above has no counterpart here.

Match your loading overlay to the scheme you asked for. The page paints light while it loads and switches once it has confirmed dark mode is enabled for the account, so a native overlay and a WebView background colour left light give a white flash into a dark checkout. See Loading and timeout handling.

Message delivery reliability (Android)

Symptom (Android): Events the checkout posts are occasionally not received by the host.

Cause: Some Android WebView versions do not reliably deliver same-frame posted messages to a listener.

What to handle: Make the host resilient — for example by not assuming the very first messages will arrive and by having a fallback path — rather than trusting a single message listener to catch every event.

Security: do not inspect response bodies

Do not try to detect payment outcomes by reading network response bodies inside the WebView. It is unreliable, differs between WebView implementations, and risks exposing sensitive payment data. Rely only on the checkout's posted lifecycle events (and server-side verification of the signed response) to determine payment status.

Ongoing maintenance

Every fix above lives in the native SDKs and ships as an SDK version update. With a hand-built WebView host there is no SDK version to bump — each change you make must be re-implemented and re-released through your own app-store cycle, and new WebView or bank-page behaviours become your team's responsibility to track. This maintenance burden is the main reason we recommend moving to a native SDK for production.