> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tabby.sa/llms.txt
> Use this file to discover all available pages before exploring further.

# Integration without SDK (WebView)

> Open the Tabby checkout in your mobile app without our SDK: how to render it, read the payment status correctly, and recover from corner cases.

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.

<Info>
  **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.
</Info>

An **embedded WebView** is also supported, and many merchants use one — but then you own more of the flow: the camera/gallery [permissions](#webview-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 <a href="/pay-in-4-custom-integration/checkout-flow">Online Custom Integration</a>. The mobile-specific part is what happens **after** you open the page:

<Steps>
  <Step title="Open the checkout">
    Create the session on your backend and open the returned URL in your WebView (or a Custom Tab /
    `ASWebAuthenticationSession`).
  </Step>

  <Step title="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.
  </Step>

  <Step title="Read the result from your backend, not the UI">
    On return, confirm the payment status server-side — see [Handling the payment
    status](#handling-the-payment-status) below. The returning navigation is a trigger to
    **verify**, not proof of the outcome.
  </Step>
</Steps>

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:

<Card horizontal color="#00A462" href="https://3345738593-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FX4TmIyMsX35rbmwHL104%2Fuploads%2FsInrT6TorM3MkCfUUnAQ%2Ftabby-ios-manual-integration.md?alt=media&token=68596cff-0c83-4d71-8aac-cdb0f3d709c8" icon="file-arrow-down" iconType="light">
  <span style={{ textDecoration: 'none', fontWeight: 'bold' }}>
    tabby-ios-manual-integration.md
  </span>
</Card>

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

<Warning>
  **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 <a href="/api-reference/payments/retrieve-a-payment">getPayment</a> (see the <a href="/introduction/faq">FAQ</a>).
</Warning>

There are two separate channels — don't conflate them:

| Channel                                      | Purpose                                                 | Trust                                    |
| -------------------------------------------- | ------------------------------------------------------- | ---------------------------------------- |
| `merchant_urls` (success / cancel / failure) | Client-side UX signal — where to send the customer next | A trigger to verify, **not** the outcome |
| Webhook → `getPayment`                       | Server-side notification + retrieval                    | **Source of truth** for the final status |

### 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":

<Steps>
  <Step title="Poll getPayment (default)">
    Your backend receives Tabby webhooks and keeps the latest status by calling{' '}
    <a href="/api-reference/payments/retrieve-a-payment">getPayment</a> (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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

**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](/pay-in-4-custom-integration/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.

<AccordionGroup>
  <Accordion title="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`](https://developer.apple.com/documentation/authenticationservices/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.
  </Accordion>

  <Accordion title="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](https://developer.android.com/topic/libraries/architecture/saving-states).

    **Redirect via a Custom Tab.** For any external confirmation redirect, use [Chrome Custom Tabs](https://developer.android.com/develop/ui/views/layout/webapps/overview-of-android-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.
  </Accordion>
</AccordionGroup>

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.

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

<Accordion title="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.
</Accordion>

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

<Tabs>
  <Tab title="iOS">
    <Accordion title="Camera and gallery (Info.plist)">
      Declare the usage descriptions below in your `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:

      ```xml theme={"dark"}
      <key>NSCameraUsageDescription</key>
      <string>This allows Tabby to take a photo</string>
      <key>NSPhotoLibraryUsageDescription</key>
      <string>This allows Tabby to select a photo</string>
      <key>NSMicrophoneUsageDescription</key>
      <string>For secure verification on the checkout step</string>
      ```

      Our web app accepts **images only** — other file types (e.g. PDF) are not supported by the picker.
    </Accordion>
  </Tab>

  <Tab title="Android">
    On Android you have to relay the WebView's permission and file-chooser requests to the OS yourself.

    <Accordion title="Camera and microphone (onPermissionRequest)">
      When a web page requests the camera or microphone, the WebView calls `onPermissionRequest` on your `WebChromeClient` with a `PermissionRequest` listing the requested resources. The simplest possible handler grants everything immediately — fine for testing, but **not recommended for production**, where you should request the matching Android runtime permission from the user first.

      **1. Map the WebView resource to an Android permission**

      ```kotlin theme={"dark"}
      // PermissionRequest.RESOURCE_VIDEO_CAPTURE -> Manifest.permission.CAMERA
      // PermissionRequest.RESOURCE_AUDIO_CAPTURE -> Manifest.permission.RECORD_AUDIO
      ```

      **2. Declare the permissions and features in `AndroidManifest.xml`**

      ```xml theme={"dark"}
      <uses-feature
          android:name="android.hardware.camera"
          android:required="false" />

      <uses-permission android:name="android.permission.CAMERA"/>
      <uses-permission android:name="android.permission.RECORD_AUDIO" />
      <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
      ```

      `android:required="false"` declares that the camera hardware is not mandatory for the app to run.

      **3. Request the "dangerous" permissions at runtime, then grant them to the WebView**

      `CAMERA` and `RECORD_AUDIO` are classified as dangerous and must be requested at runtime. Grant the WebView request only after the user approves:

      <CodeGroup>
        ```kotlin Kotlin theme={"dark"}
        class MainActivity : ComponentActivity() {

            private val permissionRequester: PermissionRequester = PermissionRequester()

            private val permissionLauncher = registerForActivityResult(
                ActivityResultContracts.RequestPermission(),
                permissionRequester
            )

            override fun onCreate(savedInstanceState: Bundle?) {
                super.onCreate(savedInstanceState)
                enableEdgeToEdge()
                permissionRequester.activityResultLauncher = permissionLauncher
                setContent {
                    TabbyLiveCodingTheme {
                        Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                            CheckoutWebScreen(
                                modifier = Modifier.padding(innerPadding),
                                url = "https://url.to.web-app.net",
                                webChromeClient = object : WebChromeClient() {
                                    override fun onPermissionRequest(request: PermissionRequest) {
                                        lifecycleScope.launch {
                                            val result = permissionRequester.requestPermissions(
                                                request.resources.mapNotNull {
                                                    WebViewPermissions.byPermission(it)
                                                }
                                            )
                                            val granted = result.filter { it.isGranted == true }

                                            if (granted.isNotEmpty()) {
                                                request.grant(granted.map { it.permission.permission }
                                                    .toTypedArray())
                                            } else {
                                                request.deny()
                                            }
                                        }
                                    }
                                },
                            )
                        }
                    }
                }
            }
        }

        class PermissionRequester : ActivityResultCallback<Boolean> {

            lateinit var activityResultLauncher: ActivityResultLauncher<String>

            private var currentRequest: CompletableDeferred<Boolean>? = null

            private var inProgress: Boolean = false

            suspend fun requestPermissions(permissions: List<WebViewPermissions>): List<PermissionsCheck> {
                if (inProgress) {
                    return permissions.map { PermissionsCheck(it, false) }
                }

                inProgress = true

                val result = permissions.map {
                    currentRequest = CompletableDeferred()
                    activityResultLauncher.launch(it.androidPermission())
                    val result = currentRequest?.await()
                    currentRequest = null
                    PermissionsCheck(it, result)
                }

                inProgress = false

                return result
            }

            override fun onActivityResult(result: Boolean) {
                currentRequest?.complete(result)
            }
        }

        data class PermissionsCheck(
            val permission: WebViewPermissions,
            var isGranted: Boolean? = null,
        )

        enum class WebViewPermissions(val permission: String) {
            VideoCapture(PermissionRequest.RESOURCE_VIDEO_CAPTURE),
            AudioCapture(PermissionRequest.RESOURCE_AUDIO_CAPTURE);

            companion object {
                fun byPermission(permission: String): WebViewPermissions? {
                    return entries.find { it.permission == permission }
                }
            }
        }

        fun WebViewPermissions.androidPermission(): String = when (this) {
            WebViewPermissions.VideoCapture -> Manifest.permission.CAMERA
            WebViewPermissions.AudioCapture -> Manifest.permission.RECORD_AUDIO
        }
        ```

        ```java Java theme={"dark"}
        import android.webkit.PermissionRequest;
        import android.webkit.WebChromeClient;

        import androidx.activity.result.ActivityResultCallback;
        import androidx.activity.result.ActivityResultLauncher;

        import java.util.ArrayList;
        import java.util.List;
        import java.util.Map;

        public class PermissionRequester implements ActivityResultCallback<Map<String, Boolean>> {
            private PermissionRequest permissionRequest;

            public ActivityResultLauncher<String[]> multiplePermissionLauncher;

            public PermissionRequester() {}

            public WebChromeClient getWebChromeClient() {
                return new WebChromeClient() {
                    @Override
                    public void onPermissionRequest(PermissionRequest request) {
                        permissionRequest = request;
                        List<String> permissions = getRuntimePermissions(request.getResources());
                        if (permissions.isEmpty()) {
                            request.grant(request.getResources());
                        } else {
                            multiplePermissionLauncher.launch(permissions.toArray(new String[0]));
                        }
                    }
                };
            }

            @Override
            public void onActivityResult(Map<String, Boolean> o) {
                boolean allGranted = true;
                for (Boolean granted : o.values()) {
                    if (!granted) {
                        allGranted = false;
                        break;
                    }
                }
                if (allGranted) {
                    permissionRequest.grant(permissionRequest.getResources());
                } else {
                    permissionRequest.deny();
                }
            }

            private List<String> getRuntimePermissions(String[] resources) {
                List<String> permissions = new ArrayList<>();
                for (String resource : resources) {
                    switch (resource) {
                        case PermissionRequest.RESOURCE_VIDEO_CAPTURE:
                            permissions.add(android.Manifest.permission.CAMERA);
                            break;
                        case PermissionRequest.RESOURCE_AUDIO_CAPTURE:
                            permissions.add(android.Manifest.permission.RECORD_AUDIO);
                            break;
                    }
                }
                return permissions;
            }
        }
        ```
      </CodeGroup>
    </Accordion>

    <Accordion title="Files / gallery (onShowFileChooser)">
      To let the customer pick an image, override `onShowFileChooser` on your `WebChromeClient`. We recommend the **Android photo picker** (`PickVisualMedia`) — it requires no storage permissions. If you wire up a custom file chooser instead, you must handle the required permissions yourself. Our web app accepts **images only**.

      ```kotlin theme={"dark"}
      var uploadMessageCallback: ValueCallback<Array<Uri>>? = null

      val fileChooserContract = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { result ->
          val callback = uploadMessageCallback ?: return@registerForActivityResult

          val URIs = result?.let { arrayOf(it) }

          callback.onReceiveValue(URIs)
          uploadMessageCallback = null
      }

      // Web chrome client implementation here. This implementation should be set to your WebView.
      object : WebChromeClient() {
          override fun onShowFileChooser(
              webView: WebView?,
              filePathCallback: ValueCallback<Array<Uri>>?,
              fileChooserParams: FileChooserParams?
          ): Boolean {
              uploadMessageCallback = filePathCallback
              fileChooserContract.launch(
                  PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
              )
              return true
          }
      }
      ```
    </Accordion>
  </Tab>
</Tabs>

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

* <a href="/pay-in-4-custom-integration/payment-processing">Payment Processing</a> — capture the payment and process the order.
* <a href="/pay-in-4-custom-integration/webhooks">Webhooks</a> — receive payment status updates server-side.
