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.1.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
)
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.

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') }
}

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.
  • Signaturecontroller.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.
  • Version1.1.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.1.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
}

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