Skip to content

Android integration

Native Jetpack Compose card-entry + headless payment, consuming the shared KMP core directly in Kotlin (no facade). pay() presents the 3DS challenge itself through Custom Tabs, which needs androidx.browser and an intent-filter — see 3DS presentation.

Requirements

  • Kotlin 2.2.20, AGP 8.13.0, Gradle 8.14.3, Compose BOM 2025.06.01
  • minSdk 24, compileSdk 36; android.useAndroidX=true
  • Two artifacts on Maven Central: com.hipay.payments:core (headless) and com.hipay.payments:card (Compose UI, which api-exposes the core)

Add the SDK

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

// app/build.gradle.kts
dependencies {
    implementation("com.hipay.payments:card:1.2.0")
}

That single line is enough: the POM pulls the headless core, Ktor, Compose UI/Foundation/Material 3, androidx.browser (3DS Custom Tabs) and DataStore transitively. Add com.hipay.payments:core:1.2.0 on its own only if you want the headless core without the UI.

Use the component

import com.hipay.core.HiPayConfig
import com.hipay.core.Environment
import com.hipay.card.HiPayCardEntry
import com.hipay.card.HiPayCardEntryController

val config = HiPayConfig(username = "…", password = "…", environment = Environment.STAGE)
val controller = HiPayCardEntryController(config, allowedNetworks = emptyList())

setContent { HiPayCardEntry(controller) }   // or host in an XML/Fragment via ComposeView

Pay (suspend; the card token never leaves the controller):

// Production: get `signature` from YOUR backend. For a first STAGE test, compute it locally
// with the StageSignature helper below (⚠️ stage/test only).
val signature = StageSignature.compute(orderId, amount = "10.00", currency = "EUR")
val tx = controller.pay(
    orderId = orderId, amount = "10.00", currency = "EUR",
    description = "Order …", redirectScheme = "yourscheme",
    authenticationIndicator = 0,            // 0 bypass / 1 if-available / 2 mandatory 3DS
    signature = signature,
    // autoPresent3DS = true is the default — see "3DS presentation" below
)
when (tx.state) {                           // tx is the FINAL state (3DS already handled)
    TransactionState.COMPLETED -> { /* success */ }
    TransactionState.PENDING -> { /* pending */ }
    TransactionState.DECLINED -> { /* declined */ }
    else -> { /* ERROR (FORWARDING only if you set autoPresent3DS = false) */ }
}

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). Disable your own Pay button the same way: enabled = controller.canPay && !controller.isProcessing. After a successful order the SDK also clears the card (PCI), so canPay is false — a new payment needs a fresh card entry.

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

pay(...) and payWithSavedCard(...) send what the SDK models. For anything else the gateway accepts — a per-order notification URL, a bank-statement descriptor, the basket — pass 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()

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

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.

customData(...) and custom(...) are not interchangeable. Your own data goes through customData(...), which fills the gateway's custom_data; the order schema accepts no arbitrary top-level parameter, so an invented wire name passed to custom(...) is refused by the gateway.

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.

Accepted card networks — the account decides

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

val controller = HiPayCardEntryController(
    config,
    // Optional. Narrows what the account already accepts — never widens it.
    allowedNetworks = listOf(HiPayCardNetwork.VISA, HiPayCardNetwork.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.

Styling

HiPayCardEntryStyle is a shared contract: 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)

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 = HiPayCardEntryController(
    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 = HiPayCardEntryController(
    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 the token blob is AES/GCM-encrypted with a non-exportable Keystore key and kept in the SDK's DataStore file. Recommended 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 in Chrome Custom Tabs and returns the final, server-confirmed transaction — you don't open a browser or call getTransaction. The one piece of host wiring (Custom Tabs returns via your app's URL scheme): forward the deep-link return once to resume3DS(...).

<!-- AndroidManifest.xml — host activity, launchMode="singleTop" -->
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="yourscheme" android:host="hipay-payments" />
</intent-filter>
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    intent.data?.let { controller.resume3DS(it.toString()) }   // SDK confirms + resumes pay()
}

pay() suspends across the challenge and returns the final tx (confirmed via getTransaction, FR9 — redirect params never trusted). Pass autoPresent3DS = false to get the raw FORWARDING transaction and handle the redirect yourself (advanced / headless).

Abort & indeterminate returns. If the user dismisses the Custom Tab without finishing, the SDK never assumes an abort — it reconciles with the server: a payment actually completed comes back COMPLETED, a genuine abort stays FORWARDING. If the server is unreachable during that check, pay() returns an indeterminate PENDING ("verification required") rather than a false abort or a thrown exception — 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.

val recovery = hiPayPaymentRecovery(context, config)          // build it at launch — it is cheap

// Every call suspends: the secure store does blocking I/O, so call them from a coroutine.
for (payment in recovery.unresolvedPayments()) {              // reads the device, not the network
    payment.orderId        // the id YOU passed to pay()
    payment.lastState      // PENDING while nothing final is known
    payment.referenceKnown // false ⇒ the order was never answered

    val refreshed = recovery.refreshPayment(payment.orderId, signature)  // asks the gateway
    recovery.acknowledge(payment.orderId)                     // once you have recorded the outcome
}

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 hiPayPaymentRecovery(...), 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 inside an app, and the SDK never computes signatures. The helper below exists only so you can get a first end-to-end stage payment working from copy-paste, before wiring your backend. 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 "SHA-1" accordingly. The signed string must also exactly equal the orderId / amount / currency you pass to pay(...) (e.g. amount "10.00", not "10"). 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.

import java.security.MessageDigest

// ⚠️ STAGE / TEST ONLY — replace with a backend call for production.
object StageSignature {
    // HiPay Stage account ▸ Integration ▸ Security ▸ "Secret passphrase".
    private const val PASSPHRASE = "<your-stage-passphrase>"

    // HiPay HS scheme: SHA-1(orderId + amount + currency + passphrase), lowercase hex.
    fun compute(orderId: String, amount: String, currency: String): String =
        MessageDigest.getInstance("SHA-1")
            .digest((orderId + amount + currency + PASSPHRASE).toByteArray(Charsets.UTF_8))
            .joinToString("") { "%02x".format(it) }
}

Manual 3DS confirmation (headless / advanced — FR9)

Only if you set autoPresent3DS = false (or use the headless GatewayClient): register the redirect scheme as above, then confirm yourself with the captured reference:

import com.hipay.core.callback.CallbackUrlParser
import com.hipay.core.gateway.GatewayClient

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    val uri = intent.data ?: return
    val cb = CallbackUrlParser.parse(uri.toString())              // orderId, status, queryParams
    val reference = capturedReference ?: cb.queryParams["reference"]   // prefer the captured one (FR9)
    lifecycleScope.launch {
        val tx = GatewayClient(config).getTransaction(reference!!, signature) // authoritative outcome
        // render COMPLETED / PENDING / DECLINED
    }
}

A complete, runnable example is the demo at src/HiPay-SDK-android-Demo (PaymentViewModel + MainActivity).

Crash reports and symbols

The SDK ships unobfuscated, so your own R8 pass renames its classes along with yours and your mapping.txt is the only thing that can read a stack trace back — HiPay holds nothing for your build. Keep the mapping of every release you ship, and upload it to your crash tool as usual.

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

Our frames sit under the com.hipay package. The SDK ships no consumer R8 rules and needs none: the one class that must survive, HiPayInitProvider, is kept from the merged manifest.

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. Update the intent-filter that catches the return:

<data android:scheme="yourscheme" android:host="hipay-payments" />

Leave the old host in place and the browser returns to a URL nothing handles: the payment simply never resumes, with no error raised and nothing logged. Nothing changes on the gateway side — the SDK sends these URLs per order.

One source break, and it can only reach your UI tests: HiPayCardEntryTags.SAVED_CARDS_HEADER is gone. The collapsible "Saved cards" header it identified no longer exists — the list now shows the most recent cards with a "Show more" control (HiPayCardEntryTags.SHOW_MORE), and the expand/collapse state moved onto the "New card" row. Application code is unaffected.

Behaviour to expect: opening the new-card form no longer collapses the saved-card list.

Notes

  • Localization: FR/EN/IT (default EN) ship in the card module's strings.xml; device locale by default, overridable SDK-wide via HiPaySettings on the config or per component via HiPayCardEntry(..., localeOverride = "fr") (which wins).
  • Accessibility: TalkBack labels/state, relative traversal order (opt-out setsAccessibilityOrder = false), inline errors announced politely.
  • PCI: the raw PAN and the vault token never leave 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.
  • Merged manifest: the card module declares one ContentProvider, com.hipay.card.HiPayInitProvider (authority ${applicationId}.hipaycardinit, not exported). It captures the application context the secure stores need and does nothing else — no work, no I/O, never queried. The authority comes from your applicationId, so build variants and other apps never collide, and R8 keeps the class from the merged manifest with no rule of yours. You can drop it if your startup budget demands it:
    <provider android:name="com.hipay.card.HiPayInitProvider"
              android:authorities="${applicationId}.hipaycardinit"
              tools:node="remove" />
    
    What you lose: nothing is recorded for recovery until HiPayCardEntry has been rendered once, and a payment saving a card throws until then. Call controller.bindPresentationContext(context) yourself to restore both.
  • Multi-process apps: the provider lives in your default process, so a payment screen running in another process has no application context until HiPayCardEntry renders there. The saved-card store is single-process anyway — a Preferences DataStore must not be opened from two processes.
  • Testing: instrumented Compose UI tests must run on an API ≤ 35 emulator (Compose ui-test 1.8.3 does not attach on API 37); your own app runs on any supported API.

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