Skip to content

KMP / Compose-Multiplatform integration

Add the shared Compose-Multiplatform card UI to your KMP app: one @Composable HiPayCardEntry from commonMain renders the HiPay card-entry component on Android and iOS, backed by hipaycard-cmp. The card token stays inside the controller (PCI); your host owns the Pay button and calls pay(...), which presents the 3DS challenge and returns the final transaction.

Prefer to build your own UI (or drive payments from a backend)? A headless core is available for full control — see Headless core (advanced) at the end.

A runnable reference consumer lives in the separate HiPay-SDK-CMP-Demo repo — its shared/commonMain renders HiPayCardEntry and pays on Android + iOS.

Requirements

  • Kotlin 2.2.20, AGP 8.13.0, Gradle 8.14.3; targets androidTarget, iosArm64, iosSimulatorArm64.
  • The Compose-Multiplatform plugin/runtime your app already uses for shared UI.
  • Artifact (KMP, resolved per-target via Gradle Module Metadata):
  • com.hipay.payments:card-cmp — the Compose-Multiplatform card-entry UI (@Composable HiPayCardEntry + HiPayCardController), shared Android and iOS. It brings the headless core transitively — this is the only dependency you add for the UI integration.

Add the SDK (commonMain)

// settings.gradle.kts — mavenCentral() is usually already there
dependencyResolutionManagement {
    repositories { google(); mavenCentral() }
}

// build.gradle.kts (your KMP/CMP module)
kotlin {
    androidTarget(); iosArm64(); iosSimulatorArm64()
    sourceSets {
        commonMain.dependencies {
            implementation("com.hipay.payments:card-cmp:1.2.0")   // card UI (+ core, transitively)
            // Needed to LAUNCH the suspend API (coroutines are `implementation` in the SDK).
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
        }
    }
}

Use the card UI (shared Android + iOS)

Render one @Composable from commonMain. The card token stays inside the controller (PCI); the host owns the Pay button and calls pay(...).

// commonMain — shared screen (renders on Android + iOS)
import androidx.compose.runtime.*
import com.hipay.card.cmp.HiPayCardController
import com.hipay.card.cmp.HiPayCardEntry
import com.hipay.core.Environment
import com.hipay.core.HiPayConfig
import com.hipay.core.gateway.model.TransactionState
import kotlinx.coroutines.launch

@Composable
fun CardScreen() {
    val config = remember { HiPayConfig(username = "…", password = "…", environment = Environment.STAGE) }
    val controller = remember { HiPayCardController(config) }   // allowedNetworks: [] by default
    DisposableEffect(controller) { onDispose { controller.dispose() } }
    val scope = rememberCoroutineScope()

    HiPayCardEntry(controller)                                  // the shared card fields

    var status by remember { mutableStateOf<TransactionState?>(null) }

    // The card fields lock themselves while pay() runs (controller.isProcessing) — mirror it on
    // your own Pay button; no isPaying flag to maintain.
    Button(enabled = controller.canPay && !controller.isProcessing, onClick = {
        scope.launch {
            val signature = StageSignature.compute("ORD-1", "10.00", "EUR")   // ⚠️ stage-only, see below
            // The SDK presents any 3DS itself (iOS in-app session / Android Custom Tabs) and
            // returns the FINAL, server-confirmed tx — see "3DS presentation" below.
            val tx = controller.pay(
                orderId = "ORD-1", amount = "10.00", currency = "EUR",
                description = "Order ORD-1", redirectScheme = "yourscheme",
                authenticationIndicator = 0, signature = signature,
            )
            status = tx.state                       // COMPLETED / PENDING / DECLINED / ERROR
        }
    }) { Text("Pay") }
}

The card fields lock themselves while pay() is in flight — the SDK exposes controller.isProcessing and HiPayCardEntry disables its fields on it (no parameter to wire); mirror it on your own Pay button. After a successful order the SDK clears the card (PCI), so canPay is false — a new payment needs a fresh card entry.

Accepted card networks — the account decides

The component asks your HiPay account which card products it is contracted for as soon as it is composed, and only offers those. You do not configure this, and you cannot widen it: the optional allowed list you pass narrows that set.

val controller = HiPayCardController(
    config,
    // Optional. Narrows what the account already accepts — never widens it.
    allowedNetworks = listOf(CardNetwork.VISA, CardNetwork.MASTERCARD),
    // Optional (default "EUR"). A contract can differ per currency, so pass the currency the
    // order will be created in.
    currency = "EUR",
)

What the payer sees for a card the account does not accept: no brand icon, the inline "Card type not allowed" message once the network is known, and a blocked pay button (canPay false).

Two behaviours worth knowing before you file a bug:

  • No brand icon is shown while that first answer is in flight. Whether the detected network is offerable at all is exactly what is unknown at that moment.
  • If the query fails (device offline, gateway unreachable), the ceiling is left OPEN and the component behaves exactly as it did before this feature existed — the locally detected icon is shown and nothing is refused. This is deliberate: a payment form must not be unusable because of a network hiccup. It also means that "the restriction does nothing" is usually a connectivity problem on the device, not a broken SDK — check that the device can reach the gateway first.

Headless callers of AllowedNetworks should note that its allowed parameter is now nullable: null means "no restriction", an EMPTY list means "authorizes nothing". The two used to be the same value, which is what let a component accept networks its account could not process.

Styling

HiPayCardEntryStyle is a shared contract from commonMain: ARGB Long colors (0xAARRGGBB), Float metrics, font enums (fontFamily reserved = system font). Validated at construction (IllegalArgumentException on out-of-range values). Omit style for hipayDefault.

val style = HiPayCardEntryStyle(
    textColor = 0xFF1A1A1A,
    iconColor = 0xFF6200EE,
    borderColor = 0xFFBDBDBD,
    borderWidth = 1f,
    cornerRadius = 12f,
    backgroundColor = 0xFFFFFFFF,
    fieldHeight = 42f,      // a MINIMUM (heightIn); grows under large font scales
    fieldSpacing = 8f,      // gap between fields; omit to keep this platform's own spacing
)
HiPayCardEntry(controller = controller, style = style)   // shared expect/actual, Android + iOS

Light and dark are handled for you — as long as you don't override the colours. Omit style (or pass HiPayCardEntryStyle.hipayDefault) and the component derives its palette from your MaterialTheme.colorScheme, so it follows the system appearance with nothing to wire. The moment you pass your own colours they are used verbatim, in both appearances: adapting them per theme is then yours to do, since only you know what your surface looks like. The non-colour metrics — font size, border width, corner radius, field height — always come from the shared contract, so the geometry stays identical across platforms either way. Your colours have no layout constraints. The field label floats to a position ABOVE the field's border rather than onto it, so it never sits on the field and your screen at the same time. Two consequences worth knowing: backgroundColor can be any colour, contrasting with your screen or not (the derived default uses your scheme's surfaceContainerHighest so the field reads as an input area); and placeholderColor, which is the label colour, has to contrast with your background while the label is floated — not with the field's fill. The float respects the system "reduce motion" setting.

Localization

Follows the device locale (fr/en/it; English fallback). Set the display language once for the whole SDK via HiPaySettings on the config:

val settings = HiPaySettings()
val config = HiPayConfig(user, pass, env, settings = settings)
settings.setLocaleOverride("fr")   // once for the whole SDK; live, case-insensitive; null = follow device

HiPaySettings is observable — changing the locale re-localizes every card live, no re-init. A per-component localeOverride still wins. For a one-off, force a language on a single component:

HiPayCardEntry(controller = controller, localeOverride = "fr")

One-click / saved cards

A returning payer pays with a card saved on a previous purchase — no card number, no security code. Off by default; nothing is stored and no card store is created until you enable it.

val controller = HiPayCardController(
    config,
    oneClickEnabled = true,
    // Optional (default 3, clamped 1..10): how many cards show before "Show more".
    savedCardsDisplayCount = 3,
)

Give the component a scrollable host. HiPayCardEntry renders a plain Column and never scrolls on its own. With one-click enabled the payer can reveal every stored card at once via "Show more" (up to 20 are kept), so the component can grow past a screen height. Put it inside a verticalScroll container — otherwise "Show less", "New card" and your own Pay button end up off-screen with no way back:

Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    HiPayCardEntry(controller)
    // your Pay button
}

Offer to save on a successful payment — the component asks the payer for consent:

val tx = controller.pay(/* … */, saveCard = true)
controller.lastSaveOutcome   // SAVED / NOT_ELIGIBLE / STORAGE_FAILED

Paying with a saved card needs no new call. When the payer selects one, your existing pay(...) routes through the stored token by itself, so your Pay button stays a single touch-point:

controller.savedCards          // List<SavedCard>: maskedPan, network, holder, expiry
controller.selectSavedCard(card)
controller.selectNewCard()     // back to card entry
controller.deleteSavedCard(card)
controller.refreshSavedCards()

payWithSavedCard(...) exists for headless hosts that drive the choice themselves.

How the payer deletes a card. A left-swipe or a long-press on a row reveals a trash affordance; tapping the trash deletes, swiping the row back cancels. That is two deliberate steps, so there is no confirmation dialog by default — pass confirmCardDeletion = true if your checkout wants one anyway:

val controller = HiPayCardController(
    config,
    oneClickEnabled = true,
    confirmCardDeletion = true,   // optional; off by default
)

The dialog is shown regardless of that flag when the deletion comes from the screen-reader "Delete card" action: that path is a single step, with no trash to aim at and no reverse swipe to undo it.

The payer's card list is filtered by the same account rules as a new entry: a stored card on a network your account no longer accepts is dropped from the list.

Only a token is stored — never the card number, never the security code. On Android it is AES/GCM-encrypted with a non-exportable Keystore key and kept in the SDK's DataStore file; on iOS it is held in the Keychain. Android hardening: exclude datastore/hipay_saved_cards.preferences_pb from backup in your app's dataExtractionRules / fullBackupContent, so a device transfer cannot carry an undecryptable blob.

A stored token can stop being accepted: the card expired, was replaced, or the issuer revoked it. The payment then fails with HiPayErrorCode.CARD_NO_LONGER_VALID, the card is dropped from the list, and the payer must enter a card again. Handle that case explicitly — it is the one one-click failure that is not worth retrying.

3DS presentation (turnkey, default on)

By default pay(...) presents the 3DS challenge and returns the final, server-confirmed transaction (confirmed via getTransaction, FR9). Choose the mode with threeDS = HiPayThreeDSMode.IN_APP_SESSION (default) or EXTERNAL_BROWSER:

  • iOS IN_APP_SESSION: an in-app ASWebAuthenticationSession — it self-captures the yourscheme:// callback and auto-dismisses. No host wiring. EXTERNAL_BROWSER opens Safari → forward the return via resume3DS from the iOS host's .onOpenURL (+ register the URL scheme).
  • Android (both modes): Chrome Custom Tabs — forward the deep-link return once (host Activity.onNewIntent):
// Android host only (the iOS in-app session needs nothing). Register the scheme in the manifest.
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    intent.data?.let { controller.resume3DS(it.toString()) }   // SDK confirms + resumes pay()
}

On any non-callback return (sheet dismissed / Custom Tab or Safari closed without finishing), the SDK never assumes an abort — it reconciles with the authoritative server state: a completed payment is reported COMPLETED, a genuine abort stays FORWARDING (not-completed). If the server is unreachable during that check, pay() returns an indeterminate PENDING ("verification required") rather than a false abort or a thrown error — re-query getTransaction later to resolve it.

A lost connection during the order is indeterminate too. If the connection drops while the order is in flight — an OS suspension during a long background does exactly that — the SDK cannot know whether the gateway took the payment, so pay() returns PENDING instead of throwing. Treat it as "to be verified", never as a failure: the payment may well have been captured. It stays listed by the recovery API below.

Interrupted payments

A payment can be interrupted in ways a callback cannot survive: the OS suspends your app during a 3DS challenge and drops the connection, or the process is killed outright. The SDK records what it launched, keyed on your own order id, so you can catch up afterwards.

@Composable
fun RecoveryScreen(config: HiPayConfig) {
    // A composable rather than a plain factory: Android needs a platform context, and Compose is
    // where a Multiplatform host can reach one.
    val recovery = rememberHiPayPaymentRecovery(config)
    var payments by remember { mutableStateOf(emptyList<HiPayPendingPayment>()) }
    val scope = rememberCoroutineScope()

    // Every call suspends — the secure store does blocking I/O.
    LaunchedEffect(recovery) { payments = recovery.unresolvedPayments() }

    payments.forEach { payment ->
        payment.orderId        // the id YOU passed to pay()
        payment.lastState      // PENDING while nothing final is known
        payment.referenceKnown // false ⇒ the order was never answered

        Button(onClick = {
            scope.launch {
                recovery.refreshPayment(payment.orderId, signature)   // asks the gateway
                recovery.acknowledge(payment.orderId)      // once you have recorded the outcome
                payments = recovery.unresolvedPayments()
            }
        }) { Text("Refresh") }
    }
}

You never handle a HiPay transaction reference: you ask with the id you created, and the SDK keeps the correspondence. Nothing here re-submits an order — the stored entry holds no card token, so it cannot.

An entry survives its own resolution. Only acknowledge(...) removes it, or its lifetime running out — seven days for a payment left unanswered, forty-eight hours once final. Deleting on resolution would lose the case this exists for: an app that dies between learning the outcome and recording it. At most fifty entries are kept, oldest first out, so a longer lifetime cannot grow the cost of the payment path. Both lifetimes are arguments of rememberHiPayPaymentRecovery(...), so shorten them to test the flow.

Signing the read. An account that signs its orders refuses an unsigned transaction read with 401, so pass the HS signature your backend computed for that order. The snapshot carries the amount and the currency precisely so it can be recomputed from the entry alone.

refreshPayment throws when the gateway cannot be reached. That says the question could not be asked, never that the payment failed — the entry is kept, so retry later.

referenceKnown == false is still recoverable. The HiPay reference only exists once the order has been answered, so an order whose response was lost has none — the SDK then finds the transaction from your own order id instead, and keeps the reference it gets back.

This is a convenience, not the source of truth. Payment finality remains the server-to-server webhook. This API exists so your app can re-open the right screen and never conclude "failed" from an interruption.

First stage test — local signature (⚠️ STAGE / TEST ONLY)

The HS signature MUST be computed on your backend in production — never ship the stage passphrase, and the SDK never computes it. For a first stage test from commonMain, an expect/actual helper computes SHA-1 per target (MessageDigest on Android, CC_SHA1 on iOS). Delete it before release.

The hash algorithm must match your HiPay account (Stage ▸ Integration ▸ Security ▸ signature algorithm): this helper uses SHA-1 — if your account is set to SHA-256/512 the request is rejected (HTTP 401), so change both actuals accordingly. The signed string must also exactly equal the orderId / amount / currency you pass to pay(...) (e.g. amount "10.00"). This is the SDK's HS auth (Authorization: HS base64(username:signature)) — the signature replaces the password on the wire, so a wrong algorithm fails authentication.

// commonMain
internal expect fun sha1Hex(input: String): String

// ⚠️ STAGE / TEST ONLY
object StageSignature {
    private const val PASSPHRASE = "<your-stage-passphrase>"   // Stage ▸ Integration ▸ Security
    // HiPay HS scheme: SHA-1(orderId + amount + currency + passphrase), lowercase hex.
    fun compute(orderId: String, amount: String, currency: String): String =
        sha1Hex(orderId + amount + currency + PASSPHRASE)
}
// androidMain
import java.security.MessageDigest
internal actual fun sha1Hex(input: String): String =
    MessageDigest.getInstance("SHA-1").digest(input.toByteArray(Charsets.UTF_8))
        .joinToString("") { "%02x".format(it) }
// iosMain
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
import kotlinx.cinterop.*
import platform.CoreCrypto.CC_SHA1
import platform.CoreCrypto.CC_SHA1_DIGEST_LENGTH

internal actual fun sha1Hex(input: String): String {
    val bytes = input.encodeToByteArray()
    val out = UByteArray(CC_SHA1_DIGEST_LENGTH)
    bytes.usePinned { b -> out.usePinned { d ->
        CC_SHA1(b.addressOf(0), bytes.size.convert(), d.addressOf(0))
    } }
    return out.joinToString("") { it.toString(16).padStart(2, '0') }
}

Crash reports and symbols

On this channel the SDK is a Maven klib: it is compiled into your Shared.framework, so its symbols live in your .dSYM and nowhere else. Keep the archive's dSYMs/ folder and upload it to your crash tool as usual — there is no separate HiPay symbol file to fetch, and HiPay holds none for your build.

A HiPay frame reads kfun:com.hipay.…, sometimes suffixed COROUTINE$0 or #internal. Your own shared Kotlin produces kfun: frames too; the com.hipay package prefix is what tells ours apart. Neither shape is a corrupted trace.

Reporting a crash inside the SDK. Symbolicate it first — you are the only one who can — then open an issue with the symbolicated frames and the SDK version. Send the trace, not your .dSYM. The SDK embeds no crash reporter and collects nothing on its own.

Upgrading to 1.2.0

BREAKING: the card component no longer adds its own outer margin, matching iOS. Add your own padding around it if you were relying on the one it used to insert — otherwise your layout tightens by that amount.

A lost connection no longer reports a failure. The outcome comes back as pending: the SDK cannot know whether the gateway took the payment, and reporting a failure would be the one way it could make you refuse an order that was charged. If your code treats "not completed" as "not paid", change it to read the outcome — a pending payment is reconciled, not refused.

The entered card is cleared when a payment ends, whatever the outcome. A payer retrying after a refusal now re-enters the card; previously the fields stayed filled.

Deleting the selected saved card now selects the most recent card left, instead of dropping to the card-entry fields while other cards are still saved.

New: an interrupted payment can be found again — see Interrupted payments. Nothing to do to keep the old behaviour, but a payment left unresolved by a crash or a kill is now recoverable on your own order id.

New: the SDK reports each payment journey to HiPay, carrying your order id, HiPay's transaction reference, the amount and your application's identifier. Nothing to configure — but you have to declare it when you publish. See Privacy and store declarations.

Upgrading from 1.0.0

Required, and silent if you miss it: the return deep link changed host. It is now {yourScheme}://hipay-payments/gateway/orders/{orderId}/{status} — hipay-fullservice is gone. On the Android side of your CMP app, update the intent-filter (<data android:scheme="yourscheme" android:host="hipay-payments" />); if you build the redirect URLs yourself against the headless core, read the host from HIPAY_CALLBACK_HOST or build the prefix with hipayCallbackBase(scheme, orderId). Leave the old host and the payment never resumes after 3DS, with nothing logged.

No source break. One behaviour change: opening the new-card form no longer collapses the saved-card list, and the "Saved cards" header is no longer a toggle — a "Show more" control reveals the cards beyond the display count.

The controller also accepts currency and savedCardsDisplayCount, both optional with defaults, so existing call sites compile unchanged.

Notes

  • Localization / accessibility: the shared card UI carries FR/EN/IT strings and the same accessibility behaviours as the native components.
  • Signature — controller.pay(…, signature) takes a backend-computed HS signature; the SDK never computes it (see the stage-only helper above for a first test).
  • PCI — the raw PAN never leaves the controller; never log card data.
  • Privacy — card data leaves the device to be tokenized, and the SDK reports each journey to HiPay with your order id, transaction reference and amount. Both have to be declared when you publish — see Privacy and store declarations.
  • Version — 1.2.0; pin the same number as the iOS SPM tag / Android AARs (single-version policy across platforms).

Headless core (advanced)

If you don't want the SDK card UI — you build your own UI, or drive payments from shared/backend logic — use the headless core directly. It has no UI and never opens a browser: pay/order returns forwardUrl as data and you present + confirm 3DS yourself.

Add the headless artifact instead of (or alongside) the card UI:

// commonMain — headless only
implementation("com.hipay.payments:core:1.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
import com.hipay.core.HiPayConfig
import com.hipay.core.Environment
import com.hipay.card.CardTokenizer
import com.hipay.core.gateway.GatewayClient
import com.hipay.core.gateway.model.OrderRequest
import com.hipay.core.gateway.model.TransactionState

val config = HiPayConfig(username = "…", password = "…", environment = Environment.STAGE)
val tokenizer = CardTokenizer(config)   // the host owns these (no singleton/DI)
val gateway = GatewayClient(config)

suspend fun pay(): TransactionState {
    val token = tokenizer.generateToken(
        cardNumber = "…", expiryMonth = "12", expiryYear = "2030",
        holder = "…", cvc = "…", multiUse = false,
    )                                       // PAN → Secure Vault token (never persisted)
    val tx = gateway.requestNewOrder(
        OrderRequest(
            orderId = "ORD-1", paymentProduct = "visa", amount = "10.00",
            description = "Order ORD-1",
            acceptUrl = "myapp://hipay-payments/gateway/orders/ORD-1/accept",
            declineUrl = "myapp://hipay-payments/gateway/orders/ORD-1/decline",
            pendingUrl = "myapp://hipay-payments/gateway/orders/ORD-1/pending",
            exceptionUrl = "myapp://hipay-payments/gateway/orders/ORD-1/exception",
            cancelUrl = "myapp://hipay-payments/gateway/orders/ORD-1/cancel",
            cardToken = token.token,
        ),
        signature = StageSignature.compute("ORD-1", "10.00", "EUR"),
    )
    // capture tx.transactionReference BEFORE 3DS (FR9)
    return tx.state
}

Payment progress

controller.paymentPhase reports which step the payment is on — TOKENIZING, CREATING_ORDER, AUTHENTICATING, CONFIRMING — and is null when idle. Read-only, observable from Compose, so a host can replace one opaque spinner with its own wording:

controller.paymentPhase?.let { phase -> Text(myLabelFor(phase)) }

The payer-facing wording is deliberately yours: the SDK ships no localized strings for these.

Optional gateway parameters

The order above sends what the SDK models. For anything else the gateway accepts — a per-order notification URL, a bank-statement descriptor, the basket — attach an OrderOptions:

val options = OrderOptions.Builder()
    .notifyUrl("https://your-backend.example/hipay/notify")   // overrides the back-office URL
    .softDescriptor("MY SHOP")                                // shown on the payer's statement
    .customData("internal_reference", "ORD-987465")           // your own data, shown in the back office
    .custom("shipping", "1.00")                               // a gateway parameter the SDK does not model
    .build()

gateway.requestNewOrder(order.withOptions(options), signature)

On the component path the same options are a parameter of the payment call itself:

val tx = controller.pay(/* … */, options = options)   // and payWithSavedCard(...)

build() throws HiPayException with HiPayErrorCode.VALIDATION if a value is rejected, so a bad notifyUrl fails before any order is created rather than at the gateway.

A method rather than a constructor parameter, so future parameters never break your build. The same withOptions exists on ApplePayOrder — a wallet payment ends in an ordinary order.

Fields the SDK owns are refused by custom(...): the signature-covered ones (orderid, amount, currency), the card token, the return URLs, and the customer/shipping blocks, which have typed parameters. OrderOptions.RESERVED_FIELDS is the full list.

On notifyUrl specifically: it overrides your account configuration and is not covered by the order signature, so a tampered build of your app could point the notification elsewhere. Notifications remain signed, so nothing can be forged in your name — but yours can be suppressed, leaving a reconciliation gap. Prefer the back-office setting in production and keep this for testing, unless you have a reason to route per order.

Enrolling a card-on-file takes TWO parameters, and they go to different APIs. The example above is a one-shot payment: multiUse = false, no oneClick. To let the payer pay with the same card later, set both:

  • multiUse = true on generateToken(...) — the Secure Vault answers with a reusable token. This describes the token.
  • oneClick = true on the OrderRequest — this describes the order, and the gateway asks for it on the enrolling order as well as on every later payment made from the token.

Sending only the first is what leaves an order carrying a reusable token to be classified on the token alone, which can surface as a recurring payment. Neither parameter makes the transaction recurring: a recurring (merchant-initiated) payment is declared with recurring_payment and eci = 9, and the SDK sends neither — eci stays 7.

3DS the headless way (FR9)

The core never opens a browser — forwardUrl is exposed as data:

import com.hipay.core.callback.CallbackUrlParser

// 1. when tx.state == FORWARDING → the HOST opens tx.forwardUrl (Custom Tab / SFSafariViewController)
// 2. on the return deep link:
val cb = CallbackUrlParser.parse(returnUrl)               // orderId, status, queryParams
val reference = capturedReference ?: cb.queryParams["reference"]
val finalTx = gateway.getTransaction(reference!!, signature) // authoritative outcome

The headless core is also what the per-platform native UIs (Android :hipaycard, iOS HiPayCard) and the CMP card UI build on — you can always drop to it for full control.


Other integration paths: Android · iOS See also: Overview · Changelog · Report an issue