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

# Mobile apps SDK

> Integrate the TapiPay widget into your Android or iOS app with the native SDK.

The **TapiPay native SDK** opens the payment widget inside your Android or iOS app, on a screen owned by the SDK, and tells you through events what the user is doing. It is the mobile counterpart of the [payment methods SDK](https://devs.tapipay.la/en/payment-portal/payment-methods-sdk.md): it mounts the same views (payments and [autopay management](https://devs.tapipay.la/en/payment-portal/autopay-sdk.md)) and uses the same data to identify your company and your user.

> **[Android](#android)**
>
> Kotlin, Jetpack Compose or Views. Installed with Gradle.

> **[iOS](#ios)**
>
> Swift, SwiftUI or UIKit. Installed with Swift Package Manager.

## How it works

1. Your app calls `Tapipay.present(...)` (or shows the SDK's `TapipayButton`) with your user's identifier and your company's.
2. The SDK opens the TapiPay widget on a native screen, on top of your app.
3. While the user pays or manages their autopay enrollments, the SDK delivers events to a callback.
4. Your app closes the widget when it needs to, or the user closes it with the **Close** button or the back gesture.

> [!WARNING]
>
> No SDK event confirms a payment. When `PaymentSubmitted` (Android) or `.paymentSubmitted` (iOS) arrives, the user submitted the payment, but it is not settled yet. The real status reaches your backend through the [payment notification](https://devs.tapipay.la/en/webhooks/payment-notification.md).

## Requirements

| | Android | iOS |
| - | - | - |
| Minimum OS version | Android 8.0 (`minSdk` 26) | iOS 16 |
| Language | Kotlin | Swift |
| Tooling | JDK 17 or later to build | Xcode 26 or later |
| Distribution | TapiPay Maven repository | Swift Package Manager |
| Current SDK version | `0.1.0` | `0.1.0` |

On both platforms you need:

* Your **end user's identifier** (`identifier`), the same one you send when you create the debt.
* Your **company alias** (`organization`) **or** your **company code** (`companyCode`). They work the same as in the web SDK: see [Identify your company](https://devs.tapipay.la/en/payment-portal/payment-methods-sdk.md#identify-your-company).

## Android

### 1. Add the TapiPay repository

The SDK is distributed through a TapiPay Maven repository. Add it to your project's `settings.gradle.kts`:

```kotlin settings.gradle.kts theme={null}
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://sdk-tapipay.apps.tapila.cloud/maven") // TapiPay Maven repository
    }
}
```

### 2. Add the dependency

In your app module's `build.gradle.kts`:

```kotlin build.gradle.kts theme={null}
dependencies {
    implementation("la.tapipay.ui:button:0.1.0")
}
```

> [!NOTE]
>
> The artifact is named `button` for compatibility, but it contains the full SDK: the widget screen, the events and the optional button.

You do not need to change your `AndroidManifest.xml`: the SDK already declares the `INTERNET` permission and its own screen. You do not need ProGuard or R8 rules either: the SDK ships its own.

The SDK brings Jetpack Compose and Material 3 as dependencies. If your app uses Views (XML), you can still open the widget with `Tapipay.present()`; only `TapipayButton` requires Compose.

### 3. Open the widget

Call `Tapipay.present()` from any `Context` (ideally an `Activity`):

```kotlin theme={null}
import la.tapipay.ui.button.Tapipay
import la.tapipay.ui.button.TapipayConfig
import la.tapipay.ui.button.TapipayEnvironment
import la.tapipay.ui.button.TapipayEvent

Tapipay.present(
    context = this,
    config = TapipayConfig(
        identifier = "CLI-00042",
        organization = "acme-corp", // or companyCode = "MX-S-12345"
        environment = TapipayEnvironment.HOMO, // omit it in production
    ),
) { event ->
    when (event) {
        TapipayEvent.PaymentWindowOpened -> Unit
        is TapipayEvent.PaymentSubmitted -> {
            // Submitted, not settled: show "processing" until the payment notification arrives.
            // Reconcile with externalRequestId, not with paymentId (see "After the payment").
            showProcessing()
        }
        is TapipayEvent.PaymentFailed -> Unit // the user can retry inside the widget
        TapipayEvent.PaymentCancelled -> Unit
        TapipayEvent.Dismissed -> Unit        // the user closed the widget
        is TapipayEvent.LoadFailed -> showError(event.reason)
        else -> Unit                          // required, see below
    }
}
```

`present()` throws `IllegalArgumentException` if the configuration is invalid: `identifier` is missing, you pass neither `organization` nor `companyCode`, or you use `selectionStrategy` with the autopay view.

The event callback is always invoked on the main thread.

### 4. Close the widget

The widget **does not close by itself** after a payment. Close it when you need to:

```kotlin theme={null}
Tapipay.dismiss()
```

`dismiss()` does not emit any event (you already know you closed it) and does nothing if no widget is open. You can call it from any thread.

### Compose button (optional)

If your app uses Jetpack Compose, `TapipayButton` renders a Material 3 button that opens the widget when tapped:

```kotlin theme={null}
import la.tapipay.ui.button.TapipayButton
import la.tapipay.ui.button.TapipayButtonConfig

TapipayButton(
    config = TapipayConfig(identifier = "CLI-00042", organization = "acme-corp"),
    button = TapipayButtonConfig(text = "Pay my bill"),
    onEvent = { event -> viewModel.onTapipayEvent(event) },
)
```

It takes colors and typography from your `MaterialTheme`. If you pass `onClick`, the button does not open the widget and hands the tap to you.

### `TapipayConfig` options

| Option | Type | Required | Default | Description |
| - | - | - | - | - |
| `identifier` | `String` | Yes | | End user identifier. |
| `organization` | `String?` | Yes\* | `null` | Your company `slug`. \*Required unless you pass `companyCode`. |
| `companyCode` | `String?` | Yes\* | `null` | Company code. \*Alternative to `organization`; if you pass both, this one wins. |
| `view` | `TapipayView` | No | `PAYMENTS` | `PAYMENTS` mounts the payments view; `AUTOPAY`, [autopay management](#autopay-management). |
| `environment` | `TapipayEnvironment` | No | `PRODUCTION` | `PRODUCTION` or `HOMO` (staging). |
| `externalRequestId` | `String?` | No | `null` | Debt to preselect. |
| `selectionStrategy` | `TapipaySelectionStrategy?` | No | `null` | `OLDEST_CREATED` picks the oldest pending debt. `PAYMENTS` only. |
| `appearance` | `TapipayAppearance` | No | your app theme | Native top bar of the widget. See [Appearance](#appearance-on-android). |

### Events on Android

`TapipayEvent` is a `sealed interface`:

| Event | When it arrives |
| - | - |
| `PaymentWindowOpened` | The provider's payment window opened. |
| `PaymentSubmitted(paymentId, status, data)` | The user submitted the payment. `status` is `PENDING`, `SUCCEEDED` or `UNKNOWN` (see [What each `status` means](#what-each-status-means)). It is **not** the settlement. |
| `PaymentFailed(paymentId, status, reason)` | The payment was declined (`FAILED`) or is waiting for an action (`REQUIRES_ACTION`, for example 3DS). The user stays in the widget and can retry. |
| `PaymentCancelled` | The user closed the payment window without completing the flow. |
| `Dismissed` | The **user** closed the widget (**Close** button or back). It is the last event of the session. It does not arrive when you close it with `Tapipay.dismiss()`. |
| `LoadFailed(reason)` | The widget could not load: `reason` is `network_error` or `http_<code>`. |
| `AutopayLoaded`, `AutopayActivated`, `AutopayRemoved`, `AutopayError` | [Autopay management](#autopay-management) events. |

> [!WARNING]
>
> Always include an `else ->` branch in your `when`. New SDK versions can add events: without `else`, your code stops compiling or silently ignores the event. Kotlin may flag the `else` as redundant; keep it anyway.

#### Payment event fields on Android

`PaymentSubmitted`:

| Field | Type | Description |
| - | - | - |
| `paymentId` | `String?` | ID of the charge attempt at the card processor. It arrives when the user pays with the widget's card form; it is `null` when they pay in the provider's payment window. **It is not included in the payment notification**: do not use it to reconcile (see [Reconcile with the payment notification](#reconcile-with-the-payment-notification)). |
| `status` | `TapipayPaymentStatus` | `PENDING` (default when missing), `SUCCEEDED` or `UNKNOWN` (a value this SDK version does not know). See [What each `status` means](#what-each-status-means). |
| `data` | `Map<String, String>` | Parameters the provider returns when a payment in the provider's payment window finishes. Empty when the user pays with the card form. The keys depend on the provider and are not guaranteed: do not use them to reconcile or to decide the status. Nested objects arrive serialized as JSON and `null` values are omitted. |

`PaymentFailed`:

| Field | Type | Description |
| - | - | - |
| `paymentId` | `String?` | ID of the charge attempt, if it was created. It is `null` when the payment fails before it is created (for example, if the payment session could not start). |
| `status` | `TapipayPaymentStatus` | `FAILED` (declined; default when missing), `REQUIRES_ACTION` (the bank asked for an extra authentication the widget did not complete) or `UNKNOWN`. |
| `reason` | `String?` | Short reason code. See [`reason` values](#reason-values-for-failed-payments). |

`LoadFailed`:

| Field | Type | Description |
| - | - | - |
| `reason` | `String` | `network_error` if the widget page could not be downloaded (no connection, DNS, TLS or timeout). `http_<code>` if the server responded with an HTTP error, for example `http_404` or `http_503`. |

### Appearance on Android

`TapipayAppearance` configures the native top bar that wraps the widget. If you pass nothing, it uses your `MaterialTheme` colors.

| Option | Type | Default | Description |
| - | - | - | - |
| `title` | `String?` | "Pagar" | Bar title. |
| `showToolbar` | `Boolean` | `true` | With `false`, the widget takes the whole screen and closes with the system back button. |
| `closeText` | `String?` | "Cerrar" | Close button text. |
| `closeContentDescription` | `String?` | `closeText` | Text TalkBack reads for the close button. |
| `toolbarContainerColorArgb` | `Int?` | theme | Bar background color (ARGB). |
| `toolbarContentColorArgb` | `Int?` | theme | Title and close button color (ARGB). |

You can also redefine the default texts in your app with the `tapipay_button_text`, `tapipay_checkout_title`, `tapipay_checkout_close` and `tapipay_checkout_load_failed` resources.

### Android best practices

* **One widget at a time.** If you call `present()` while a widget is already open (for example, on a double tap), the call is ignored.
* **Do not capture the `Activity` in the callback.** The SDK keeps the callback while the widget is open. Forward events to a `ViewModel` or check the `Lifecycle` before touching the UI.
* **Rotation and dark mode** do not recreate the widget screen. If Android destroys the screen due to low memory, the widget reloads and the user has to fill in the form again.

## iOS

### 1. Add the package

The SDK is distributed through **Swift Package Manager** from this repository:

```text theme={null}
https://bitbucket.org/tapila/tapi-tapipay-ios-sdk
```

**Xcode**

1. Open **File → Add Package Dependencies…**
2. Paste the repository URL.
3. Under **Dependency Rule**, choose **Up to Next Minor Version** from `0.1.0`.
4. Add the `Tapipay` product to your app target.

Xcode embeds the framework with no extra steps.

**Package.swift**

```swift Package.swift theme={null}
dependencies: [
    .package(
        url: "https://bitbucket.org/tapila/tapi-tapipay-ios-sdk.git",
        .upToNextMinor(from: "0.1.0")
    ),
],
targets: [
    .target(
        name: "MyApp",
        dependencies: [
            .product(name: "Tapipay", package: "tapi-tapipay-ios-sdk"),
        ]
    ),
]
```

> [!NOTE]
>
> While the SDK is on `0.x` versions, use **Up to Next Minor Version** (`.upToNextMinor`): you get fixes (`0.1.x`) without a `0.2.0` release changing your build unexpectedly. The SDK is only distributed through Swift Package Manager (no CocoaPods or Carthage).

You do not need to add permissions or keys to your `Info.plist`.

### 2. Open the widget

`Tapipay.present()` presents the widget modally over the visible screen. Call it from the main thread:

```swift theme={null}
import Tapipay

do {
    try Tapipay.present(
        config: TapipayConfig(
            identifier: "CLI-00042",
            organization: "acme-corp",   // or companyCode: "MX-S-12345"
            environment: .homologation   // omit it in production
        )
    ) { event in
        switch event {
        case .paymentWindowOpened:
            break
        case .paymentSubmitted:
            // Submitted, not settled: show "processing" until the payment notification arrives.
            // Reconcile with externalRequestId, not with paymentId (see "After the payment").
            showProcessing()
        case .paymentFailed:
            break                        // the user can retry inside the widget
        case .paymentCancelled:
            break
        case .closed:
            break                        // the user closed the widget
        case .failed(let error):
            showError(error)
        case .autopayLoaded, .autopayActivated, .autopayRemoved, .autopayError:
            break                        // only with view: .autopay
        @unknown default:
            break                        // required, see below
        }
    }
} catch {
    // Invalid configuration (TapipayError)
}
```

By default the SDK presents the widget from your app's visible screen. To choose where it is presented from, pass the `from:` parameter with your `UIViewController`.

### 3. Close the widget

The widget **does not close by itself** after a payment. Close it when you need to:

```swift theme={null}
Tapipay.dismiss()
// or, if you need to chain something:
Tapipay.dismiss(animated: true) {
    // the widget is closed
}
```

`dismiss()` does not emit `.closed` (you already know you closed it) and does nothing if no widget is open.

### SwiftUI button (optional)

`TapipayButton` is a SwiftUI button that opens the widget when tapped:

```swift theme={null}
import SwiftUI
import Tapipay

struct PaymentView: View {
    let config = TapipayConfig(identifier: "CLI-00042", organization: "acme-corp")

    var body: some View {
        TapipayButton(title: "Pay my bill", config: config) { event in
            print(event)
        }
        .buttonStyle(.borderedProminent)
    }
}
```

If the configuration is invalid, the button does not throw: it delivers the error as a `.failed` event. If you pass `onClick`, the button does not open the widget and hands the tap to you.

### `TapipayConfig` options on iOS

| Option | Type | Required | Default | Description |
| - | - | - | - | - |
| `identifier` | `String` | Yes | | End user identifier. |
| `organization` | `String?` | Yes\* | `nil` | Your company `slug`. \*Required unless you pass `companyCode`. |
| `companyCode` | `String?` | Yes\* | `nil` | Company code. \*Alternative to `organization`; if you pass both, this one wins. |
| `view` | `TapipayViewMode` | No | `.payments` | `.payments` mounts the payments view; `.autopay`, [autopay management](#autopay-management). |
| `environment` | `TapipayEnvironment` | No | `.production` | `.production` or `.homologation`. |
| `externalRequestId` | `String?` | No | `nil` | Debt to preselect. |
| `selectionStrategy` | `TapipaySelectionStrategy?` | No | `nil` | `.oldestCreated` picks the oldest pending debt. `.payments` only. |
| `appearance` | `TapipayAppearance` | No | `TapipayAppearance()` | Native navigation bar of the widget. See [Appearance](#appearance-on-ios). |

### Events on iOS

| Event | When it arrives |
| - | - |
| `.paymentWindowOpened` | The provider's payment window opened. |
| `.paymentSubmitted(TapipayPaymentSubmission)` | The user submitted the payment. Carries `paymentId`, `status` (`.pending`, `.succeeded` or `.unknown`; see [What each `status` means](#what-each-status-means)) and `data`. It is **not** the settlement. |
| `.paymentFailed(TapipayPaymentFailure)` | The payment was declined (`.failed`) or is waiting for an action (`.requiresAction`). The user stays in the widget and can retry. |
| `.paymentCancelled` | The user closed the payment window without completing the flow. |
| `.closed` | The **user** closed the widget (**Close** button or swiping the sheet down). It does not arrive when you close it with `Tapipay.dismiss()`. |
| `.failed(TapipayError)` | The widget could not load (`.navigationFailed`). With `TapipayButton`, it also carries the [configuration errors](#configuration-errors). |
| `.autopayLoaded`, `.autopayActivated`, `.autopayRemoved`, `.autopayError` | [Autopay management](#autopay-management) events. |

> [!WARNING]
>
> Always include `@unknown default` in your `switch`. New SDK versions can add events: without `@unknown default`, if an event your app does not know arrives, the app crashes.

#### Payment event fields on iOS

`TapipayPaymentSubmission` (in `.paymentSubmitted`):

| Field | Type | Description |
| - | - | - |
| `paymentId` | `String?` | ID of the charge attempt at the card processor. It arrives when the user pays with the widget's card form; it is `nil` when they pay in the provider's payment window. **It is not included in the payment notification**: do not use it to reconcile (see [Reconcile with the payment notification](#reconcile-with-the-payment-notification)). |
| `status` | `TapipayPaymentStatus` | `.pending` (default when missing), `.succeeded` or `.unknown` (a value this SDK version does not know). See [What each `status` means](#what-each-status-means). |
| `data` | `[String: String]` | Parameters the provider returns when a payment in the provider's payment window finishes. Empty when the user pays with the card form. The keys depend on the provider and are not guaranteed: do not use them to reconcile or to decide the status. Only text values are kept. |

`TapipayPaymentFailure` (in `.paymentFailed`):

| Field | Type | Description |
| - | - | - |
| `paymentId` | `String?` | ID of the charge attempt, if it was created. It is `nil` when the payment fails before it is created (for example, if the payment session could not start). |
| `status` | `TapipayPaymentFailureStatus` | `.failed` (declined; default when missing), `.requiresAction` (the bank asked for an extra authentication the widget did not complete) or `.unknown`. |
| `reason` | `String?` | Short reason code. See [`reason` values](#reason-values-for-failed-payments). |

`TapipayError` in `.failed`:

| Error | When it arrives |
| - | - |
| `.navigationFailed(String)` | The widget could not load, or its content stopped responding and could not recover. The `String` is the system error description (localized to the device language): use it for your logs, not for your logic or to show it as is to the user. |
| Configuration errors | Only with `TapipayButton`, which does not throw: it delivers as an event the same [configuration errors](#configuration-errors) `Tapipay.present()` would throw. |

Unlike `LoadFailed` on Android, an HTTP error response from the server does not trigger `.failed` on iOS.

### Configuration errors

`Tapipay.present()` validates the configuration before opening the widget and throws a `TapipayError`:

| Error | Cause |
| - | - |
| `.missingIdentifier` | Empty `identifier`. |
| `.invalidIdentifier` | `identifier` contains `/` or `\`, or is `.` or `..`. |
| `.missingTarget` | You passed neither `organization` nor `companyCode`. |
| `.invalidTarget` | `organization` or `companyCode` contain `/` or `\`, or are `.` or `..`. |
| `.selectionStrategyNotSupported` | You used `selectionStrategy` with `view: .autopay`. |
| `.noPresenterAvailable` | There is no visible screen to present the widget from. Pass `from:`. |
| `.invalidURL` | The configuration did not produce a valid address for the widget. |

### Appearance on iOS

| Option | Type | Default | Description |
| - | - | - | - |
| `navigationTitle` | `String` | "Pago" | Navigation bar title. |
| `closeButtonTitle` | `String` | "Cerrar" | Close button text. |
| `tint` | `Color?` | your app tint | Color of the native controls (it also tints `TapipayButton`). |
| `backgroundColor` | `Color?` | system | Background color while the widget loads. |
| `showsLoadingIndicator` | `Bool` | `true` | Shows an indicator while the widget loads. |
| `showsNavigationBar` | `Bool` | `true` | With `false`, the widget is shown without a bar and closes by swiping the sheet down. |

## Filter payment methods (beta)

By default, the widget shows every payment method your company has enabled. If you want your user to see only some of them, pass `paymentMethods` in `TapipayConfig` with the methods to show.

**Android**

```kotlin theme={null}
import la.tapipay.ui.button.TapipayPaymentMethod

Tapipay.present(
    context = this,
    config = TapipayConfig(
        identifier = "CLI-00042",
        organization = "acme-corp",
        paymentMethods = setOf(TapipayPaymentMethod.CARD, TapipayPaymentMethod.CASH),
    ),
) { event -> handle(event) }
```

**iOS**

```swift theme={null}
try Tapipay.present(
    config: TapipayConfig(
        identifier: "CLI-00042",
        organization: "acme-corp",
        paymentMethods: [.card, .cash]
    )
) { event in
    handle(event)
}
```

| Payment method | Android | iOS |
| - | - | - |
| Credit or debit card | `TapipayPaymentMethod.CARD` | `.card` |
| SPEI bank transfer | `TapipayPaymentMethod.BANK_TRANSFER` | `.bankTransfer` |
| Cash at convenience stores | `TapipayPaymentMethod.CASH` | `.cash` |
| Digital payment (wallets and digital banks) | `TapipayPaymentMethod.DIGITAL_WALLET` | `.digitalWallet` |
| BBVA Direct Payment | `TapipayPaymentMethod.BBVA_PAY_BY_BANK` | `.bbvaPayByBank` |
| Direct debit | `TapipayPaymentMethod.DIRECT_DEBIT` | `.directDebit` |

How it behaves:

* **It only restricts.** The widget shows the methods you asked for **that** your company has enabled. It never enables a method your company does not have.
* **TapiPay defines the order**, not the order of your list.
* **If no method is left**, the widget shows its empty state.
* **If you don't pass `paymentMethods` or pass an empty set**, every enabled method is shown.
* **A single method:** for example, with only `CARD` your user only sees card and taps it to continue. This way you can have one button per payment method in your app and open the widget with the method they picked.
* It only applies to the payments view. With the autopay view, Android throws `IllegalArgumentException` from `present()` and iOS ignores the option.

> [!WARNING]
>
> This option is in beta. When the [two-way channel](#coming-soon) is available, **the way you use it will change**. We will let you know in the [Changelog](https://devs.tapipay.la/en/changelog.md).

## Autopay management

With `view = TapipayView.AUTOPAY` (Android) or `view: .autopay` (iOS), the SDK mounts [autopay management](https://devs.tapipay.la/en/payment-portal/autopay-sdk.md): your user sees their enrolled payment methods, enrolls a new one or removes an existing one.

**Android**

```kotlin theme={null}
Tapipay.present(
    context = this,
    config = TapipayConfig(
        identifier = "CLI-00042",
        organization = "acme-corp",
        view = TapipayView.AUTOPAY,
    ),
) { event ->
    when (event) {
        is TapipayEvent.AutopayLoaded -> updateCounter(event.count)
        is TapipayEvent.AutopayActivated -> Unit
        is TapipayEvent.AutopayRemoved -> Unit
        is TapipayEvent.AutopayError -> Unit
        TapipayEvent.Dismissed -> Unit
        is TapipayEvent.LoadFailed -> Unit
        else -> Unit
    }
}
```

**iOS**

```swift theme={null}
try Tapipay.present(
    config: TapipayConfig(
        identifier: "CLI-00042",
        organization: "acme-corp",
        view: .autopay
    )
) { event in
    switch event {
    case .autopayLoaded(let loaded):
        updateCounter(loaded.count)
    case .autopayActivated, .autopayRemoved, .autopayError:
        break
    case .closed, .failed:
        break
    default:
        break
    }
}
```

| Event | When it arrives |
| - | - |
| `AutopayLoaded` / `.autopayLoaded` | Active enrollments finished loading, and every time the set changes. Carries `count`, `mediaTypes` and `adhesions`. |
| `AutopayActivated` / `.autopayActivated` | A new enrollment was registered. |
| `AutopayRemoved` / `.autopayRemoved` | The user removed a payment method successfully. |
| `AutopayError` / `.autopayError` | Loading (`LOAD` / `.load`) or removal (`REMOVE` / `.remove`) failed. `UNAVAILABLE` / `.unavailable` is not a failure: your organization does not have autopay management enabled. |

As on the web, autopay events only expose the payment method type, the last 4 digits and an opaque `id`. Account holder data and full account or card numbers never travel.

## Staging environment

Use staging while you build and test your integration, and remove the parameter (or use production) before you publish your app.

| Platform | Staging | Production (default) |
| - | - | - |
| Android | `TapipayEnvironment.HOMO` | `TapipayEnvironment.PRODUCTION` |
| iOS | `.homologation` | `.production` |

| Environment | Widget origin |
| - | - |
| Production | `https://app.tapipay.la` |
| Staging | `https://homo.tapipay.la` |

## After the payment

The widget does not close by itself when the user submits the payment: it shows a "payment submitted" screen and stays open until someone closes it.

| Event | What we recommend |
| - | - |
| `PaymentSubmitted` / `.paymentSubmitted` | Wait 1.5 to 2 seconds so the user sees the confirmation and close with `Tapipay.dismiss()`, or replace it with your own "payment in progress" screen. Show the payment as pending until the [payment notification](https://devs.tapipay.la/en/webhooks/payment-notification.md) arrives (see [Reconcile with the payment notification](#reconcile-with-the-payment-notification)). |
| `PaymentFailed` / `.paymentFailed` | Do not close the widget: the user can retry right there. |

### What each `status` means

The `status` of `PaymentSubmitted` / `.paymentSubmitted` is the state of the attempt **at the moment the user submitted the payment**. It does not change afterwards: the final result only arrives through the payment notification.

| Android | iOS | What it means | What to do in your app |
| - | - | - | - |
| `PENDING` | `.pending` | The payment was submitted and the result is not known yet. It is the only value when the user pays in the provider's payment window; with the card form, it means the processor is still processing the charge. | Show the payment as "in progress". |
| `SUCCEEDED` | `.succeeded` | Card form only: the processor approved the charge at submission time. | You can show "payment approved, we are confirming it", but in your system the debt is still pending. |
| `UNKNOWN` | `.unknown` | The widget reported a status your SDK version does not know. | Treat it as `PENDING` / `.pending`. |

> [!WARNING]
>
> **A `SUCCEEDED` can still end without a settled payment.** After the approval, the payment is confirmed and applied to the debt asynchronously; if that process does not complete, the notification with `status: "confirmed"` does not arrive. Also, an already confirmed payment can be reversed later: in that case you receive a notification with `status: "reversed"`. Mark a debt as paid only when the notification with `status: "confirmed"` arrives.

### `reason` values for failed payments

The `reason` of `PaymentFailed` / `.paymentFailed` is a short code: only letters, digits, `_`, `.` or `-`, up to 64 characters. It never carries card or cardholder data, and it may be missing (`null` / `nil`). It can be:

* **The processor's or bank's response code**, when there is one. For example, `INSUFFICIENT_FUNDS`, `DO_NOT_HONOR` or `51`. The catalog depends on the processor and is not closed.
* **A TapiPay label**, when there is no processor code:

| `reason` | `status` | When it arrives |
| - | - | - |
| `PAYMENT_DECLINED` | `FAILED` | The processor declined the charge without a code. |
| `PAYMENT_REJECTED` | `FAILED` | The charge could not be processed and there was no processor code. |
| `REQUIRES_ACTION` | `REQUIRES_ACTION` | The bank asked for an extra authentication (for example, 3DS) the widget did not complete. |
| `CHECKOUT_SESSION_ERROR` | `FAILED` | The card payment session could not start. |
| `MISSING_SESSION_PAYLOAD` | `FAILED` | Data was missing to start the card payment session. |
| `PAYMENT_LINK_ERROR` | `FAILED` | The provider's payment window could not be prepared. |
| `UNEXPECTED_ERROR` | `FAILED` | Unexpected error during the payment. |

Use `reason` for your logs and metrics, or to pick your own message. Do not show it as is to the user, and handle values that are not on this list: the widget already shows the user a message about the decline.

### Reconcile with the payment notification

The `paymentId` in the events identifies the charge attempt at the card processor and **is not included in the [payment notification](https://devs.tapipay.la/en/webhooks/payment-notification.md)**: no notification field matches it. To move your "in progress" screen to "paid", reconcile by **debt** and **user**, which the notification does carry:

| What you pass to the SDK | Payment notification field |
| - | - |
| `identifier` | `externalClientId` |
| `externalRequestId` | `externalRequestId` |
| `paymentId` (from the event) | Not included. Keep it only as a reference to the attempt. |

1. Open the widget with `externalRequestId`, the debt you want the user to pay. That way you know in advance which debt will change status.
2. When `PaymentSubmitted` / `.paymentSubmitted` arrives, mark that debt as "in progress" in your app.
3. In your backend, when the payment notification arrives, find the debt by `externalRequestId` and check that `externalClientId` is the user's `identifier`. With `status: "confirmed"`, mark it as paid; with `failed`, move it back to pending; with `reversed`, reverse the payment.
4. Use `operationId` as the idempotency key, as with any [payment notification](https://devs.tapipay.la/en/webhooks/payment-notification.md#retries-and-idempotency).

If you open the widget without `externalRequestId` (for example, with `selectionStrategy`), the widget picks the debt: you only learn which one when the notification arrives, through its `externalRequestId`.

If the notification takes longer than you expect, retrieve the debt with [`GET /debts/{id}`](https://devs.tapipay.la/en/resources/debts#retrieve-and-list) using its `externalRequestId`: while its `status` does not move to `PAID` or `PARTIALLY_PAID`, the payment has not been applied yet.

## External links

The widget always opens inside your app. If the user taps a link to another site, it opens in the browser; `tel:`, `mailto:` or other app links open with the matching system app.

## Coming soon

> [!NOTE]
>
> **Two-way channel between your app and the SDK.** We are building a two-way communication channel between your app and the SDK. It will bring new features to the TapiPay SDKs. We will let you know in [What's new](https://devs.tapipay.la/en/changelog.md) when it is available.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.