Skip to main content
If our SDK solutions do not meet your requirements, you can open the Tabby Hosted Payment Page in your own mobile app — in a WebView or the system browser — using backend API calls.

Choose how to render the checkout

There are two ways to open the checkout. The payment-status and recovery rules on this page apply to both — only the rendering and the permissions differ.
Recommended — the system browser + deep links. Open the Hosted Payment Page in a Chrome Custom Tab (Android) or SFSafariViewController / ASWebAuthenticationSession (iOS), and pass your merchant_urls as deep links back into your app. This is the most resilient option: the browser session survives even if the OS reclaims your app while the customer confirms the payment, and you avoid managing in-app camera permissions.
An embedded WebView is also supported, and many merchants use one — but then you own more of the flow: the camera/gallery permissions for ID upload, and state recovery when your app is reclaimed. WebView-specific guidance below is marked as such; everything else applies to both.

Checkout flow

The Checkout session request, payment method display, and code snippets are integrated the same way as in the Online Custom Integration. The mobile-specific part is what happens after you open the page:
1

Open the checkout

Create the session on your backend and open the returned URL in your WebView (or a Custom Tab / ASWebAuthenticationSession).
2

The customer may leave your app

To confirm the payment, the customer can be handed off to the Tabby app or their banking app, then returned to your app.
3

Read the result from your backend, not the UI

On return, confirm the payment status server-side — see Handling the payment status below. The returning navigation is a trigger to verify, not proof of the outcome.
When the customer finishes, read the outcome from your merchant_urls (as deep links with the browser approach, or redirect URLs inside a WebView) together with your backend webhooks — always confirming server-side, as described below. The example below shows in-WebView event handling for iOS (Swift), and applies only if you embed a WebView:
tabby-ios-manual-integration.md

Handling the payment status

This is the most important part of an in-app integration. Getting it wrong is the single biggest source of corner cases — customers who paid successfully but are shown a failure or a reset checkout.
The client is never proof of payment — the status lives on Tabby’s backend.Closing the WebView, backing out of the checkout, or returning from the Tabby or banking app is a navigation event, not a payment result. The customer may also never reach your success page at all — they can pay and then lose connection, or the OS can kill your app first.So never map a checkout close, back‑press, or app return to reject/cancel. Always follow the same chain: event trigger → retrieve the payment (getPayment) → decide. Treat webhooks as notifications only, and confirm the status with getPayment (see the FAQ).
There are two separate channels — don’t conflate them:

Confirm the status from Tabby

Make confirmed-by-backend status the thing that drives your UI, and re-fetch at the moments where customers are most likely to think the flow “ended”:
1

Poll getPayment (default)

Your backend receives Tabby webhooks and keeps the latest status by calling getPayment (which needs your secret key, so call it server-side — never from the app). Your app polls your backend for the active payment_id. Use a capped, backed-off interval and show a “Confirming your payment…” state rather than a hard success/failure screen.
2

Re-fetch when the app returns to the foreground

On onResume (Android) / sceneDidBecomeActive / applicationDidBecomeActive (iOS), re-fetch the status. This single addition resolves the large majority of false “rejections” caused by the customer returning from a banking app.
3

Re-fetch when the WebView closes

Treat WebView close as “go ask the backend”, not as a result. Fetch the status once more before you decide what to show.
A pending status is not a failure. Right after the checkout closes, the payment is often still CREATED — the customer has not been authorized yet — and can move to AUTHORIZED moments later. Never finalize on a non-terminal status: keep polling until Tabby returns a terminal status (AUTHORIZED, CLOSED, REJECTED, or EXPIRED), and let your backend reconcile late transitions from the webhook — it receives the AUTHORIZED webhook even if the customer never returned to your app. See Payment Statuses. Silent push (optional accelerator). If you already deliver push notifications, a data-only / content-available push can wake the app to fetch the status sooner. Delivery is not guaranteed (throttled in the background, dropped under Low Power Mode, disabled Background Refresh, poor network, or after force-quit), so always keep the polling above as the fallback. A real-time channel you already operate (WebSocket / gRPC) is also a valid transport, but is not worth building solely for checkout.

Recovering from corner cases

While the customer is in an external app (the Tabby app, their banking app, or a browser handling the confirmation), the OS may terminate your app’s process in the background to free memory. This is normal OS behaviour, not an error. When the customer returns, the system recreates your screen — and, if you embed a WebView, it is recreated too, reloading its initial URL instead of resuming the in-progress checkout. Heavy apps (such as banking apps) make this far more likely, because launching them pushes your app deep into the background. The one rule that prevents lost checkouts: persist the payment_id to durable storage as soon as the checkout session is created — before the customer can leave your app. Then, even after a full process restart, your return handler can re-query getPayment and restore the correct outcome, regardless of whether the WebView survived.
Persist first. Store the active payment_id immediately, and re-verify on sceneDidBecomeActive / applicationDidBecomeActive.Restore the screen. Use UIKit/SwiftUI state restoration (stateRestorationActivity on the scene, or restorationIdentifier + encodeRestorableState) so the hosting screen is rebuilt instead of reset.Redirect via the system browser. For any external confirmation redirect, prefer ASWebAuthenticationSession (or SFSafariViewController) over an external Safari launch — it keeps your app foregrounded for the round-trip and delivers the callback URL back to you, which markedly reduces the chance of the OS reclaiming your app.WKWebView does not persist in-page JavaScript state across a process restart, so the persisted payment_id is what guarantees continuity.
Save and restore state. Implement onSaveInstanceState() / onRestoreInstanceState() on the Activity/Fragment hosting the WebView, using webView.saveState() and webView.restoreState() to keep navigation history. Back durable state with a ViewModel + SavedStateHandle so it survives system-initiated process death.Redirect via a Custom Tab. For any external confirmation redirect, use Chrome Custom Tabs with a warmed-up session (bind to CustomTabsService, call warmup() early, reuse a single CustomTabsSession, reconnect on onServiceDisconnected()). Custom Tabs keep your process at foreground priority for the redirect, far better than a plain Intent.ACTION_VIEW browser launch.WebView.saveState() restores navigation history but not arbitrary in-page JavaScript state, so the persisted payment_id is still required for full continuity.
If the confirmation happens in a separate banking app (via deep link / App Link) rather than a browser, neither WebView state nor Custom Tabs / ASWebAuthenticationSession can prevent your process from being reclaimed — control has passed to another app entirely. In that case the persisted payment_id + backend re-verify is the only reliable solution. This is the recommended approach from the top of the page — here is why it holds up. Because the checkout runs in the system browser (or a Custom Tab / SFSafariViewController) rather than your WebView, the session survives even if your app is evicted from memory: the browser recovers the Tabby confirmation screen, the payment completes, and your merchant_urls.success deep link re-opens your app to show the result — while your backend confirms the final status in parallel.

Best-practice checklist

  • Persist payment_id to durable storage at session creation, so recovery works even after a full process restart.
  • Treat webhooks as notifications only, and dedupe by payment_id — a webhook, a push, and your polling can all fire for the same payment, and a webhook can be delayed or delivered more than once.
  • Never map a checkout close, back-press, or external-app return to reject/cancel — always confirm with getPayment first.
  • Wait for a terminal status — a CREATED/pending payment can still turn AUTHORIZED after the checkout closes; never finalize on a non-terminal status, and let your backend reconcile the late webhook.
  • Always provide merchant_urls (success, cancel, failure) so a deep-link recovery path exists regardless of WebView survival.
  • Don’t tear down the WebView the instant a merchant_url loads — confirm the status first.
  • Show a “Confirming your payment…” state with capped, backed-off polling instead of an immediate success/failure screen.

WebView permissions

Only needed if you embed a WebView — the browser approach handles this natively. Your WebView must be able to access the camera and pick images from the gallery on both iOS and Android — required so new customers can capture and upload their national ID during checkout. Without them, a new customer who needs to verify their ID can’t complete the purchase. The platform-specific setup is below.

Next steps

Reading the status in the app is only half of it — the order is then captured and processed on your backend, exactly as in the web integration: