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.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.
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. 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.
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 thepayment_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.
iOS — state restoration and external redirects
iOS — state restoration and external redirects
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.Android — state restoration and external redirects
Android — state restoration and external redirects
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.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.
Most robust pattern: browser + deep links
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.
Deep-link return setup
Deep-link return setup
To make the return leg reliable, register your return URLs as app links before you start:Register the URLs. Set up a custom URL scheme plus Universal Links (iOS) / App Links (Android) for your success / cancel / failure URLs.Wire the platform hooks. Add the matching intent-filter (Android) / associated-domains entitlement (iOS).Avoid duplicate tasks. Launch your return activity with
launchMode="singleTask" (or singleTop) so the deep link re-enters your existing task instead of spawning a duplicate.Best-practice checklist
- Persist
payment_idto 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
getPaymentfirst. - Wait for a terminal status — a
CREATED/pending payment can still turnAUTHORIZEDafter 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_urlloads — 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.- iOS
- Android
Camera and gallery (Info.plist)
Camera and gallery (Info.plist)
Declare the usage descriptions below in your Our web app accepts images only — other file types (e.g. PDF) are not supported by the picker.
Info.plist. WKWebView uses the native camera and photo picker once these keys are present — no extra runtime-permission plumbing is required. Adapt the description copy to your app: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:- Payment Processing — capture the payment and process the order.
- Webhooks — receive payment status updates server-side.