WinkPay POS Integration

WinkPay SDK — Native Android Integration Guide

WinkPay is a biometric (face + palm) payment SDK for Android POS applications. This guide is for partner POS developers integrating the SDK into an Android host app.

The SDK ships as a single Maven artifact:

com.wink:winkpay-sdk:1.2.0-native

The customer-facing capture and confirmation UI is implemented as native Android views .


Integration paths at a glance

You want…UseSection
One APK that contains your POS UI and the WinkPay biometric engine in-processNative embedded SDK (this doc)§1–§11 below
Two separate APKs on the same device (your POS + a standalone WinkPay app) talking via Android intentsCompanion app (intent-based IPC)TBA
Your POS to drive a WinkPay device over the LAN (mDNS + HTTPS + WebSocket)Wireless local serverTBA

Most integrators want the embedded SDK. Use the wireless / companion paths only when you specifically need cross-process or cross-device delivery.


Table of contents

  1. Quick start
  2. Prerequisites & build setup
  3. AndroidManifest.xml contributions
  4. Public API surface
  5. Environment file (.env content)
  6. Lifecycle & threading
  7. Customer-facing display (dual-display devices)
  8. Palm capture details
  9. Error handling
  10. Build notes & APK size
  11. Troubleshooting

1. Quick start

The minimum integration is roughly 30 lines of Kotlin: initialize the SDK, register a callback, launch the customer-display Activity.

1.1 settings.gradle.kts

The SDK is delivered as a local Maven repository (a directory tree). Unzip the deliverable under vendor/winkpay-sdk/winkpay-sdk-repo in your project and declare the repos:

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
    repositories {
        google()
        mavenCentral()

        // WinkPay SDK (vendored local Maven repo, shipped in the SDK zip).
        maven { url = uri("${rootDir}/vendor/winkpay-sdk/winkpay-sdk-repo") }
    }
}

1.2 app/build.gradle.kts

android {
    defaultConfig {
        minSdk = 24
        ndk { abiFilters += "arm64-v8a" }   // SDK ships only arm64-v8a
    }

    packaging {
        jniLibs {
            useLegacyPackaging = true       // bundled native libs must extract to lib/, see §2
        }
    }
}

dependencies {
    implementation("com.wink:winkpay-sdk:1.2.0-native")
}

That single line pulls in everything the SDK needs: OkHttp, CameraX, ML Kit Face Detection, AndroidX Fragment + Lifecycle, ViewPager2, RecyclerView, Kotlinx serialization + coroutines, plus the SDK's bundled native runtime.

1.3 Initialize once and start a payment

import android.app.ActivityOptions
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Bundle
import android.view.Display
import androidx.appcompat.app.AppCompatActivity
import com.wink.winkpay.WinkPaySdk
import com.wink.winkpay.embedded.EmbeddedPaymentCallback
import com.wink.winkpay.embedded.EmbeddedPaymentError
import com.wink.winkpay.embedded.EmbeddedPaymentRequest
import com.wink.winkpay.embedded.EmbeddedPaymentResult
import com.wink.winkpay.embedded.WinkPayEmbedded
import java.util.UUID

class PosMainActivity : AppCompatActivity() {

    private lateinit var winkPay: WinkPayEmbedded

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 1. Read the env file you were issued by WinkPay (issue per-merchant).
        val envContent = resources.openRawResource(R.raw.winkpay_env)
            .bufferedReader().readText()

        // 2. Initialize the SDK.
        winkPay = WinkPayEmbedded.init(
            context = this,
            config = WinkPaySdk.Config(
                envFileContent = envContent,
                sdkEnvironment = "stage",   // "stage" or "prod"
                useNativeUi = true,
            ),
        )

        findViewById<View>(R.id.payBtn).setOnClickListener { startFacePayment() }
    }

    private fun startFacePayment() {
        // 3. Register the result callback for this payment.
        val request = EmbeddedPaymentRequest(
            requestId = UUID.randomUUID().toString(),
            requestType = "PAY_FACE",      // PAY | PAY_FACE | PAY_PALM
            amount = 1500L,                // minor units (cents)
            currency = "USD",
            orderId = "ORDER-${System.currentTimeMillis()}",
            tenderId = "TENDER-001",
            cameraIndex = 0,               // 0 = back, 1 = front
            rotationAngle = 0,
        )

        winkPay.startPayment(request, object : EmbeddedPaymentCallback {
            override fun onSuccess(result: EmbeddedPaymentResult) {
                // Payment processed by the SDK (returnExternalTokens=false default):
                //   result.transactionId, result.amount, result.winkTag
                // External-token mode (returnExternalTokens=true):
                //   result.winkCardToken, result.winkTag, result.amount
            }
            override fun onCancelled(requestId: String) { /* user cancelled */ }
            override fun onFailure(error: EmbeddedPaymentError) {
                // error.errorCode is "AUTH_FAILED" or "LIVENESS_FAILED" today.
                // See §9 for the full list.
            }
        })

        // 4. Launch the customer display. On single-display devices pass
        //    Display.DEFAULT_DISPLAY (or omit) and you are done.
        val displayId = pickCustomerDisplayId()
        val intent = winkPay.createNativeIntent(this, displayId)
        val opts = ActivityOptions.makeBasic().setLaunchDisplayId(displayId)
        startActivity(intent, opts.toBundle())
    }

    private fun pickCustomerDisplayId(): Int {
        val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
        return dm.displays.firstOrNull {
            it.displayId != Display.DEFAULT_DISPLAY && it.isValid
        }?.displayId ?: Display.DEFAULT_DISPLAY
    }
}

That's the whole integration. Everything below is reference material.


2. Prerequisites & build setup

RequirementValueNotes
Android Gradle Plugin8.5+ (tested with 8.5.2)AAR metadata format
Gradle8.7+required by AGP 8.5
Kotlin2.0+SDK is compiled with 2.1.0
minSdk24SDK runtime minimum
compileSdk / targetSdk34+AndroidX compatibility
Java toolchain11+
Device ABIarm64-v8a onlyThe SDK ships native libraries for arm64-v8a only; x86_64 emulators are not supported

useLegacyPackaging = true is mandatory. Without it, the SDK's bundled native libraries stay inside base.apk instead of being extracted to lib/arm64-v8a/, and the SDK fails to initialize at runtime. AGP 8 makes legacy packaging opt-in.


3. AndroidManifest.xml contributions

The SDK manifest declares the permissions and Activities the customer display needs; manifest merger pulls them into your app automatically. You do not need to copy these into your own manifest.

Inherited from the SDK:

ItemWhy
android.permission.CAMERAFace + palm capture
android.permission.INTERNETAuth + payment APIs
android.permission.RECORD_AUDIO, MODIFY_AUDIO_SETTINGSRequired by the camera plugin's Manifest.permission set; not actually recorded by the native path
android.permission.FOREGROUND_SERVICEReserved for the wireless server flow
<uses-feature android:name="android.hardware.camera"/>Play Store filter
The SDK's customer-display ActivityLaunched by createNativeIntent

You only add what you need for your own POS UI — typically just your launcher Activity and any platform-specific intent-filter (e.g. a Clover custom-tender filter, if applicable).

CAMERA is required at runtime, but the SDK does not request it for you. You must request android.permission.CAMERA from the user yourself before launching a payment — typically in your app's first-launch flow or right before calling startPayment. If the permission is not granted when the customer-display Activity starts, the camera preview shows black and the biometric session eventually fails (there is no "permission denied" callback — request the permission proactively).


4. Public API surface

These are the only types third-party integrators are expected to touch. Everything else under com.wink.winkpay.* is internal.

4.1 WinkPaySdk and WinkPaySdk.Config

com.wink.winkpay.WinkPaySdk is a singleton. You don't normally call it directly — WinkPayEmbedded.init(...) calls it for you. Configuration is the data class WinkPaySdk.Config:

WinkPaySdk.Config(
    envFileContent: String? = null,
    sdkEnvironment: String  = "stage",
    useNativeUi: Boolean    = false,
    enableHttpsServer: Boolean = false,
    serverPort: Int         = 8443,
)
FlagDefaultEffect
envFileContentnullRaw .env-formatted content (see §5). Always pass content explicitly; the null branch only exists for internal builds and is not a supported integrator path.
sdkEnvironment"stage""stage" or "prod". Selects baked-in URLs for the auth, payment, and palm APIs. Any other value throws.
useNativeUifalseAlways set to true. Retained as a constructor parameter for backward compatibility; only true is supported for new integrations. Pass it explicitly in every Config(...) call.
enableHttpsServerfalseStarts the Ktor HTTPS server on serverPort for wireless POS integration (POS_INTEGRATION.md). Off by default — enabling it costs ~30–50 MB of RAM (Netty thread pools, direct ByteBuffers, TLS).
serverPort8443TCP port for the wireless server.

4.2 WinkPayEmbedded

com.wink.winkpay.embedded.WinkPayEmbedded is the integration facade.

object Companion {
    fun init(
        context: Context,
        config: WinkPaySdk.Config = WinkPaySdk.Config(),
        envFileName: String = ".env.clover979",   // legacy fallback if config.envFileContent is null
    ): WinkPayEmbedded

    fun getInstance(): WinkPayEmbedded   // throws if init() not called
}

fun startPayment(request: EmbeddedPaymentRequest, callback: EmbeddedPaymentCallback)

fun startCheckin(request: EmbeddedCheckinRequest, callback: EmbeddedPaymentCallback)

fun createNativeIntent(context: Context, displayId: Int = -1): Intent

fun cancelPayment()
fun shutdown()
MethodWhen to callEffect
init(context, config)Once, early — typically Activity.onCreate or Application.onCreate. Subsequent calls are no-ops.Initializes the native API layer (parses env, picks stage/prod URLs), wires up the optional wireless server, prepares for startPayment.
startPayment(request, callback)Once per payment, before launching the Activity.Stores order data and registers your callback. Both must be called from the same process that will host the customer-display Activity.
startCheckin(request, callback)Identity-only flow (no payment).Same as startPayment but skips card selection — returns user identity + saved cards. See §4.6.
createNativeIntent(ctx, displayId)Right after startPayment.Returns an Intent for the SDK's customer-display Activity. Pass displayId >= 0 for a secondary display; the method adds `FLAG_ACTIVITY_NEW_TASKFLAG_ACTIVITY_MULTIPLE_TASKautomatically. You still need to passActivityOptions.makeBasic().setLaunchDisplayId(displayId)tostartActivity` — see §7.
cancelPayment()If you need to abort programmatically (e.g. POS-side timeout).Triggers onCancelled on the active callback.
shutdown()When you're done with WinkPay (e.g. POS app exiting).Unregisters callbacks, resets state, stops the wireless server, releases internal singletons.

4.3 EmbeddedPaymentRequest

data class EmbeddedPaymentRequest(
    val requestId: String,                        // your unique id; UUIDv4 recommended
    val requestType: String,                      // "PAY" | "PAY_FACE" | "PAY_PALM"
    val amount: Long,                             // minor units (cents). Must be > 0
    val currency: String = "USD",                 // ISO 4217
    val orderId: String,                          // your POS order id; non-blank
    val tenderId: String,                         // your tender id (free-form, non-blank)
    val ageRestrictedPurchase: Boolean = false,   // gates capture behind merchant override
    val rotationAngle: Int = 0,                   // 0/90/180/270 — set per device orientation
    val cameraIndex: Int = 0,                     // 0 = back, 1 = front
    val returnExternalTokens: Boolean = false,    // see §4.7
    val returnOnFailure: Boolean = false,         // see §4.8
)

PAY shows the face/palm chooser; PAY_FACE and PAY_PALM jump straight into the corresponding capture screen.

4.4 EmbeddedPaymentResult

data class EmbeddedPaymentResult(
    val requestId: String,
    val transactionId: String?,    // null in external-token mode
    val amount: Long,
    val winkTag: String? = null,        // biometric-resolved customer id
    val winkToken: String? = null,      // payment token, when available
    val winkCardToken: String? = null,  // selected card token, populated in external-token mode
    val mfaUsed: Boolean = false,       // true if MFA was performed during this auth
)

4.5 EmbeddedPaymentError and EmbeddedPaymentCallback

data class EmbeddedPaymentError(
    val requestId: String,
    val errorCode: String,
    val errorMessage: String,
)

interface EmbeddedPaymentCallback {
    fun onSuccess(result: EmbeddedPaymentResult)
    fun onCancelled(requestId: String)
    fun onFailure(error: EmbeddedPaymentError)
    fun onCheckinSuccess(result: EmbeddedCheckinResult) { /* default delegates to onSuccess */ }
}

All callback methods are dispatched on the main thread.

4.6 Check-in mode (identity-only, no payment)

Use this for kiosk check-in and similar flows where you want to identify a returning user but not charge a card. The SDK runs the biometric capture and returns the full login response.

data class EmbeddedCheckinRequest(
    val requestId: String,
    val currency: String = "USD",
    val rotationAngle: Int = 0,
    val cameraIndex: Int = 0,
    val biometricType: String = "face",   // "face" or "palm"
)

data class EmbeddedCheckinResult(
    val requestId: String,
    val winkTag: String?,
    val accessToken: String?,
    val loginResponseJson: String,        // full server JSON: user, cards, addresses, faceResponse
)

winkPay.startCheckin(EmbeddedCheckinRequest(requestId = UUID.randomUUID().toString()),
    object : EmbeddedPaymentCallback {
        override fun onCheckinSuccess(result: EmbeddedCheckinResult) {
            // Parse result.loginResponseJson — userPaymentDetails.user, .cards, .shippingAddresses
        }
        override fun onSuccess(r: EmbeddedPaymentResult) {}
        override fun onCancelled(requestId: String) {}
        override fun onFailure(error: EmbeddedPaymentError) {}
    })

4.7 returnExternalTokens — let the host process the charge

If your processor already handles the actual card charge and you only need WinkPay to identify the customer and pick a card:

returnExternalTokensUI labelSDK behaviourResult fields
false (default)Pay NowSDK charges the selected card via WinkPay's payment rails.transactionId, amount, winkTag
trueConfirmSDK does not charge the card. Returns the selected card's winkCardToken + winkTag.winkCardToken, winkTag, amount

4.8 returnOnFailure — surface auth failures to the host

By default, when biometric authentication fails (face not recognized, liveness rejected, etc.) the native UI shows a Retry / See Options error screen. If you want control to return to your POS app instead:

EmbeddedPaymentRequest(..., returnOnFailure = true)

The native Activity finishes with onFailure(EmbeddedPaymentError(...)) on the registered callback (see §9 for the error codes).


5. Environment file (.env content)

WinkPay is configured per merchant via a small .env-style file you receive during onboarding. Pass its raw text as WinkPaySdk.Config.envFileContent. Format is KEY=VALUE per line; # comments and blank lines are ignored; surrounding ' or " on values are stripped.

KeyRequiredUsed byNotes
CLIENT_IDAuth APIOAuth client id issued by WinkPay
MERCHANT_CLIENT_SECRETAuth APIOAuth client secret
BASE_LOGIN_URLoptionalAuth APIOverride the baked-in stage/prod URL — only set for non-standard deployments
BASE_PAYMENT_URLoptionalPayment APIOverride of baked-in URL
BASE_API_URL_PALMoptionalPalm APIOverride of baked-in URL
PMS_PROJECT_IDoptionalPalm APIOverride of baked-in project id

Stage vs prod URLs are baked into the SDK and selected via Config.sdkEnvironment. The override keys above are honored if present, but for the vast majority of integrations you only need CLIENT_ID and MERCHANT_CLIENT_SECRET.

Where to keep it. A common pattern is app/src/main/res/raw/winkpay_env (note: no extension — Android forbids dots in res/raw filenames). Read with resources.openRawResource(R.raw.winkpay_env).bufferedReader().readText() as in §1.3. Treat the file as a secret in your build pipeline.


6. Lifecycle & threading

What happens when you call startPayment and launch the Activity

  1. startPayment stores the order data in process-scoped state and registers your callback.
  2. createNativeIntent returns an Intent for the SDK's customer-display Activity.
  3. The Activity reads the order data in onCreate. If requestType was PAY_FACE or PAY_PALM, it routes straight to the corresponding capture screen; otherwise it shows the face/palm chooser.
  4. Capture → API auth → consent / MFA dialogs as required → card selection → either pay or return external tokens.
  5. The Activity finishes. Your callback fires (on the main thread):
    • onSuccess — payment processed or external tokens returned
    • onCancelled — user tapped cancel, or you called cancelPayment()
    • onFailure — auth/payment failed and returnOnFailure=true, or a terminal error occurred

Threading

  • All callback methods are dispatched on the main thread.
  • API calls (auth, payment, palm) run on a single-threaded executor inside the Activity; the Activity tears it down in onDestroy.
  • Camera analysis runs on CameraX's analyzer executor; results are posted back to the main thread before any UI / API call.

What gets torn down on Activity destroy

The SDK's customer-display Activity releases all biometric, network, and camera resources automatically when it finishes. You don't need to do anything in your own lifecycle to "clean up" the SDK between payments — just register a fresh callback before the next startPayment.

Calling startPayment twice

startPayment overwrites the previously-registered callback. It does not serialize concurrent calls — only one biometric session can be in flight at a time. If you need to start a second payment after the first completes, wait for the callback to fire, then call startPayment again. There is no explicit "session id" — the requestId you supply is what threads back to your callback.

Process death / re-entry

The order data and callback registration are process-scoped and do not survive process death. If the OS kills your app while the Activity is running, restart the flow from your POS — there is no resumable session.


7. Customer-facing display (dual-display devices)

On single-display devices you can ignore this section: pass Display.DEFAULT_DISPLAY (or omit displayId) and you're done.

On dual-display POS hardware (separate merchant + customer screens), the capture UI should land on the customer display. Two things must be true:

  1. The Intent must include FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_MULTIPLE_TASKcreateNativeIntent(ctx, displayId) adds these for you when displayId >= 0.
  2. You must pass an ActivityOptions bundle. Flags alone do not target a display.
val displays = (getSystemService(Context.DISPLAY_SERVICE) as DisplayManager).displays
val customerDisplayId = displays.firstOrNull {
    it.displayId != Display.DEFAULT_DISPLAY && it.isValid
}?.displayId ?: Display.DEFAULT_DISPLAY

val intent = winkPay.createNativeIntent(this, customerDisplayId)
val opts   = ActivityOptions.makeBasic().setLaunchDisplayId(customerDisplayId)
startActivity(intent, opts.toBundle())

Without the options bundle you'll see Failed to put TaskRecord on display N in logcat and the Activity will land on the merchant display.

Relaunching your own POS Activity on the customer display

If your POS UI also belongs on the customer display:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    if (relaunchOnCustomerDisplayIfNeeded()) return
    // ... normal onCreate
}

private fun relaunchOnCustomerDisplayIfNeeded(): Boolean {
    if (intent.getBooleanExtra("__relaunched", false)) return false
    val dm = getSystemService(DisplayManager::class.java)
    val customer = dm.displays.firstOrNull {
        it.displayId != Display.DEFAULT_DISPLAY && it.isValid
    } ?: return false

    val current = if (Build.VERSION.SDK_INT >= 30) display?.displayId
                  else windowManager.defaultDisplay.displayId
    if (current == customer.displayId) return false

    val relaunch = Intent(this, javaClass).apply {
        putExtra("__relaunched", true)
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
    }
    startActivity(relaunch, ActivityOptions.makeBasic()
        .setLaunchDisplayId(customer.displayId).toBundle())
    finish()
    return true
}

Add android:taskAffinity=".your_customer_task" to your Activity in the manifest so Android grants a fresh task on the customer display.

The SDK's customer-display Activity is already configured to land on a fresh task — you do not need to configure it.


8. Palm capture details

The SDK runs palm capture on-device, including liveness verification. The capture screen positions the user's hand, prompts a randomized gesture, and verifies the gesture before delivering a palm model to the WinkPay backend for identification. The full flow — including model build and identify call — is internal to the SDK; your host code only sees the eventual onSuccess or onFailure callback.

If liveness is rejected the errorMessage starts with "Liveness check…" (e.g. "Liveness check timed out", "Liveness check failed"). The SDK turns this into errorCode = "LIVENESS_FAILED" if returnOnFailure is set; otherwise it routes to the SDK's retry screen.

When debugging false-rejects, capture logcat with tag PalmCaptureSession — every reject logs a one-line reason.


9. Error handling

9.1 errorCode values delivered via EmbeddedPaymentError

The native flow currently produces two error codes:

errorCodeCause
LIVENESS_FAILEDThe biometric session failed because liveness was rejected (face or palm). The user-visible message starts with "Liveness check…".
AUTH_FAILEDAny other terminal authentication / payment failure: face/palm not recognized, server error, network error, payment declined, etc. The errorMessage carries the human-readable reason (server-supplied where available).

onFailure is only called when:

  • You set EmbeddedPaymentRequest.returnOnFailure = true, or
  • A terminal error occurs that the SDK does not have a retry path for (e.g. network failure during a payment that already debited).

By default (returnOnFailure = false) the native UI shows a Retry / See Options screen and the user can re-attempt without your code being notified.

9.2 User-visible strings you may see in logcat / errorMessage

Not all of these surface to your callback (most route to the native retry screen unless returnOnFailure = true), but you may see them while debugging:

Message prefixMeaning
Liveness check failed.Palm or face liveness rejected the gesture/blink
Liveness check timed outGesture window expired before a PASS verdict
Liveness check incompletePalm left the frame mid-gesture
Face not recognized / User not foundThe captured face did not match any enrolled user for this merchant
Payment declinedCard-level decline from the processor
Network errorOkHttp transport error reaching the auth or payment API

9.3 Initialization-time exceptions

These come out as standard Kotlin exceptions, not via the callback:

ExceptionCause
IllegalStateException("WinkPaySdk not initialized")You accessed an API that requires init before calling WinkPayEmbedded.init
IllegalStateException("$key is not configured in .env")A required env key (CLIENT_ID, MERCHANT_CLIENT_SECRET, …) was missing or blank when the API layer tried to use it
IllegalArgumentException("environment must be \"stage\" or \"prod\"")Config.sdkEnvironment was something else

9.4 Companion-app error codes

When integrating via the companion app (intent-based IPC) instead of the embedded SDK, you'll see an additional set of error codes — INVALID_REQUEST, UNAUTHORIZED_CALLER, IDEMPOTENT_REJECT, PAYMENT_FAILED, NOT_IMPLEMENTED. Those are documented in POS_INTEGRATION.md, which is the authoritative reference for that integration path.


10. Build notes & APK size

10.1 ProGuard / R8 — consumer rules are auto-propagated

The SDK ships consumer-rules.pro and declares it via consumerProguardFiles, so it is automatically merged into your app's R8 rules at build time. You do not need to copy any WinkPay-specific rules into your app — the bundled set covers the public API surface (WinkPaySdk, WinkPaySdk.Config, WinkPayEmbedded, the EmbeddedPayment* data classes) and the kotlinx.serialization companion serializers used by the API model classes.

If you minify your app and see R8 warnings about other transitive dependencies you pull in (OkHttp, ML Kit, your POS vendor SDK, etc.), add the relevant -keep / -dontwarn rules in your own app/proguard-rules.pro — those live outside the WinkPay SDK and are your app's responsibility.

10.2 APK size impact

The SDK adds roughly 10 MB compressed (~25 MB uncompressed, arm64-v8a only) to your APK. The ML Kit face-detection model is delivered at runtime by Google Play Services — zero APK cost.

To keep the build lean, set ndk.abiFilters += "arm64-v8a" (the SDK only ships arm64-v8a anyway — including other ABIs just inflates your APK with native libs that won't run).

10.3 Versioning

Maven coordinates are com.wink:winkpay-sdk:<version>. The current recommended version is 1.2.0-native. The SDK versioning convention is:

  • 1.x.0 — feature releases
  • 1.x.y — bug-fix releases

When you upgrade the SDK, swap the version in app/build.gradle.kts and copy the new local Maven repo over vendor/winkpay-sdk/winkpay-sdk-repo. No code changes are required for patch-level upgrades within the same major.


11. Troubleshooting

SymptomLikely causeFix
IllegalStateException: WinkPaySdk not initializedAPI used before WinkPayEmbedded.init() returnedMove init earlier (Application.onCreate or top of Activity.onCreate).
IllegalStateException: CLIENT_ID is not configured in .envenvFileContent was null / empty / missing the keyVerify the file you read into envContent actually contains the keys; check that Config(envFileContent = …) was passed (and not silently dropped).
SDK init crashes with a native-library load errorBundled native libs are being packaged compressed inside base.apkSet packaging.jniLibs.useLegacyPackaging = true in your app's build.gradle.kts.
Failed to put TaskRecord on display N (then Activity lands on the merchant display)startActivity called without ActivityOptions.setLaunchDisplayId(...)Pass opts.toBundle() as the second arg. Flags alone are insufficient — see §7.
Customer display shows a black preview; capture eventually times out or fails with LIVENESS_FAILEDHost app didn't request android.permission.CAMERA at runtime before launching the payment. There is no "permission denied" callback — the camera just opens to black and the biometric session never sees a usable frame.Call ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.CAMERA), REQ_CODE) before winkPay.startPayment(...). Don't proceed to createNativeIntent until the user grants it.
Payment succeeds but winkCardToken / transactionId is null in the resultMode confusion: returnExternalTokens=true populates winkCardToken and leaves transactionId null; returnExternalTokens=false does the oppositeRead §4.7 — pick the mode that matches who is processing the charge.
Palm liveness consistently fails for a real user on a real deviceOne of the SDK's local liveness guards is rejectingCapture logcat with tag PalmCaptureSession — each reject logs the precise reason. If you see false rejects in normal use, file a bug with the log excerpt.
INSTALL_FAILED_INVALID_APK: Failed to extract native libraries, res=-2 on adb installSame root cause as the native-library load error aboveuseLegacyPackaging = true.