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.6.1
The customer-facing capture and confirmation UI is implemented as native Android views .
Integration paths at a glance
| You want… | Use | Section |
|---|---|---|
| One APK that contains your POS UI and the WinkPay biometric engine in-process | Native embedded SDK (this doc) | §1–§11 below |
| Two separate APKs on the same device (your POS + a standalone WinkPay app) talking via Android intents | Companion app (intent-based IPC) | TBA |
| Your POS to drive a WinkPay device over the LAN (mDNS + HTTPS + WebSocket) | Wireless local server | TBA |
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
- Quick start
- Prerequisites & build setup
AndroidManifest.xmlcontributions- Public API surface
- Credentials & device registration
- Lifecycle & threading
- Customer-facing display (dual-display devices)
- Palm capture details
- Error handling
- Build notes & APK size
- 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
settings.gradle.ktsThe 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
app/build.gradle.ktsandroid {
defaultConfig {
minSdk = 24
ndk { abiFilters += "arm64-v8a" } // for the standard (64-bit) SDK variant — see §8.2 for 32-bit POS hardware
}
packaging {
jniLibs {
useLegacyPackaging = true // bundled native libs must extract to lib/, see §2
}
}
}
dependencies {
implementation("com.wink:winkpay-sdk:1.6.1")
}That single line pulls in everything the SDK needs at runtime — the networking, camera, biometric, and UI components, plus the SDK's bundled native runtime — as transitive dependencies. You do not declare any of them yourself.
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.os.Build
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. Initialize the SDK. Pass merchant credentials and the device
// serial number directly — no .env file required.
winkPay = WinkPayEmbedded.init(
context = this,
config = WinkPaySdk.Config(
clientId = "YOUR_CLIENT_ID",
merchantClientSecret = "YOUR_MERCHANT_CLIENT_SECRET",
deviceSerialNumber = @Suppress("DEPRECATION") Build.SERIAL,
sdkEnvironment = "stage", // "stage" or "prod"
),
)
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
| Requirement | Value | Notes |
|---|---|---|
| Android Gradle Plugin | 8.5+ (tested with 8.5.2) | AAR metadata format |
| Gradle | 8.7+ | required by AGP 8.5 |
| Kotlin | 2.0+ | SDK is compiled with 2.1.0 |
minSdk | 24 | SDK runtime minimum |
compileSdk / targetSdk | 34+ | AndroidX compatibility |
| Java toolchain | 11+ | — |
| Device ABI | arm64-v8a (default variant) or armeabi-v7a (32-bit variant) | The SDK is published in two ABI variants — pick one per §8.2. x86_64 emulators are not supported |
useLegacyPackaging = trueis mandatory. Without it, the SDK's bundled native libraries stay insidebase.apkinstead of being extracted tolib/<abi>/, and the SDK fails to initialize at runtime. AGP 8 makes legacy packaging opt-in.
3. AndroidManifest.xml contributions
AndroidManifest.xml contributionsThe 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:
| Item | Why |
|---|---|
android.permission.CAMERA | Face + palm capture |
android.permission.INTERNET | Auth + payment APIs |
android.permission.RECORD_AUDIO, MODIFY_AUDIO_SETTINGS | Declared by the camera subsystem; the SDK does not record audio |
android.permission.FOREGROUND_SERVICE | Reserved for the wireless server flow |
<uses-feature android:name="android.hardware.camera"/> | Play Store filter |
| The SDK's customer-display Activity | Launched by createNativeIntent |
You only add what you need for your own POS UI — typically just your launcher Activity and any platform-specific intent-filter your processor or POS shell requires.
CAMERA is required at runtime, but the SDK does not request it for you. You must request
android.permission.CAMERAfrom the user yourself before launching a payment — typically in your app's first-launch flow or right before callingstartPayment. 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
WinkPaySdk and WinkPaySdk.Configcom.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(
clientId: String? = null,
merchantClientSecret: String? = null,
deviceSerialNumber: String? = null,
sdkEnvironment: String = "stage", // "qa" | "stage" | "prod"
palmMfaEnabled: Boolean = true,
enableHttpsServer: Boolean = false,
serverPort: Int = 8443,
readWinkConfig: Boolean = false, // on-device test override; see below
debugLogging: Boolean = false, // verbose SDK logs (off in production)
envFileContent: String? = null, // legacy fallback
)| Flag | Default | Effect |
|---|---|---|
clientId | null | Merchant OAuth CLIENT_ID issued by WinkPay. Required — pass it directly here (preferred) or via envFileContent. When both are present, the direct field wins. |
merchantClientSecret | null | OAuth client secret. Required. Same precedence rules as clientId. |
deviceSerialNumber | null | POS device serial number. Required for biometric flows. On first run the SDK registers this serial with the WinkPay platform, persists the returned device identifier locally, and sends it on every subsequent biometric login. Safe to pass on every init — once a device identifier is cached locally, subsequent values are ignored. See §5. |
sdkEnvironment | "stage" | "qa", "stage", or "prod". Selects the backend endpoints the SDK talks to. Any other value throws. |
palmMfaEnabled | true | When true (default), the Use palm option appears on the MFA screen wherever the device hardware supports palm capture. Set false to hide palm as a fallback entirely (e.g. kiosks that ship face + PIN only). May also be toggled at runtime via the WinkPaySdk.palmMfaEnabled property. |
enableHttpsServer | false | Starts the local HTTPS server on serverPort for the wireless POS integration path. Off by default — enabling it costs ~30–50 MB of RAM. |
serverPort | 8443 | TCP port for the wireless server. |
readWinkConfig | false | When true, the SDK reads an on-device config file at init and lets it override the camera index and rotation values you pass in the request. Intended for on-device testing only — leave false in production. Missing or malformed file falls back silently to the request values. |
debugLogging | false | When true, the SDK emits verbose debug/info logs to logcat. Off by default so production builds stay quiet and don't surface operational detail. Warnings and errors are always logged regardless. You can also enable logs for a single tag at runtime without rebuilding: adb shell setprop log.tag.<TAG> DEBUG (e.g. PalmCaptureSession). |
envFileContent | null | Legacy path: raw .env-formatted content (see §5). Use the direct clientId / merchantClientSecret fields above for new integrations — envFileContent is supported only for backward compatibility. |
4.2 WinkPayEmbedded
WinkPayEmbeddedcom.wink.winkpay.embedded.WinkPayEmbedded is the integration facade.
object Companion {
fun init(
context: Context,
config: WinkPaySdk.Config = WinkPaySdk.Config(),
): WinkPayEmbedded
fun getInstance(): WinkPayEmbedded // throws if init() not called
}
fun startPayment(request: EmbeddedPaymentRequest, callback: EmbeddedPaymentCallback)
fun startCheckin(request: EmbeddedCheckinRequest, callback: EmbeddedPaymentCallback)
fun startLiteRegistration(request: EmbeddedLiteRegistrationRequest, callback: EmbeddedPaymentCallback)
fun createNativeIntent(context: Context, displayId: Int = -1): Intent
fun createCaptureFragment(): Fragment // embed in your own Activity — see §4.12
fun setAgeRestrictionOverride() // merchant-side ID override — see §4.13
fun cancelPayment()
fun shutdown()| Method | When to call | Effect | |
|---|---|---|---|
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_TASK | FLAG_ACTIVITY_MULTIPLE_TASKautomatically. You still need to passActivityOptions.makeBasic().setLaunchDisplayId(displayId)tostartActivity` — see §7. |
setAgeRestrictionOverride() | When the merchant has visually verified the customer's ID for an age-restricted order. | Clears the customer-side ID gate so payment can proceed without profile verification. See §4.13. Safe to call from any thread. | |
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
EmbeddedPaymentRequestdata 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 — pose hint for face detection
val cameraIndex: Int = 0, // 0 = back, 1 = front
val returnExternalTokens: Boolean = false, // see §4.7
val returnOnFailure: Boolean = false, // see §4.8
val maxRetries: Int = 3, // max face-capture attempts; see §4.9
// — Tuning knobs (see §4.9) —
val minFaceRatio: Double? = null, // 0.0–1.0; null = device-class default
val livenessEnabled: Boolean? = null, // null = SDK default (ON unless local liveness ran)
val previewRotation: Int? = null, // 0/90/180/270; null = camera default
val activityOrientation: Int? = null, // ActivityInfo.SCREEN_ORIENTATION_* constant
// — Palm-specific overrides (see §4.10) —
val palmRotationAngle: Int = 0, // -1 = fall back to rotationAngle
val palmCameraIndex: Int = -1, // -1 = fall back to cameraIndex
val palmPreviewRotation: Int? = null, // null = fall back to previewRotation
)PAY shows the face/palm chooser; PAY_FACE and PAY_PALM jump straight into the corresponding capture screen.
4.4 EmbeddedPaymentResult
EmbeddedPaymentResultdata 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
val appID: String? = null, // processor app id — populated for processors that require it, see §4.7
)4.5 EmbeddedPaymentError and EmbeddedPaymentCallback
EmbeddedPaymentError and EmbeddedPaymentCallbackdata 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 */ }
fun onLiteRegistrationSuccess(result: EmbeddedLiteRegistrationResult) { /* 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"
val returnOnFailure: Boolean = false, // see §4.8
val maxRetries: Int = 3, // max face-capture attempts; see §4.9
// — Tuning knobs (see §4.9) —
val minFaceRatio: Double? = null,
val livenessEnabled: Boolean? = null,
val previewRotation: Int? = null,
val activityOrientation: Int? = null,
// — Palm-specific overrides (see §4.10) —
val palmRotationAngle: Int = 0,
val palmCameraIndex: Int = -1,
val palmPreviewRotation: Int? = null,
)
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) {}
})isProfileVerified is emitted at two locations inside loginResponseJson for host integrator convenience:
userPaymentDetails.isProfileVerified— the canonical field on the outerUserPaymentDetails.userPaymentDetails.user.isProfileVerified— mirrored onto the innerUserShortso hosts that read profile fields from theuserobject don't have to reach back up.
Both fields carry the same boolean and are written from the same source on the server response. Read whichever is convenient — they are kept in sync.
4.7 returnExternalTokens — let the host process the charge
returnExternalTokens — let the host process the chargeIf your processor already handles the actual card charge and you only need WinkPay to identify the customer and pick a card:
returnExternalTokens | UI label | SDK behaviour | Result fields |
|---|---|---|---|
false (default) | Pay Now | SDK charges the selected card via WinkPay's payment rails. | transactionId, amount, winkTag |
true | Confirm | SDK does not charge the card. Returns the selected card's winkCardToken + winkTag. | winkCardToken, winkTag, amount, appID (when applicable) |
When returnExternalTokens = true and the configured clientId is for a processor that requires an app id to route the charge, the SDK additionally populates result.appID with that value. For processors that do not need it, appID is null.
4.8 returnOnFailure — surface auth failures to the host
returnOnFailure — surface auth failures to the hostBy 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).
4.9 Capture tuning knobs
These are all optional overrides on EmbeddedPaymentRequest / EmbeddedCheckinRequest / EmbeddedLiteRegistrationRequest. Leave them off and the SDK picks per-device defaults — same as before any of these existed.
| Field | Type | Default | What it does |
|---|---|---|---|
maxRetries | Int | 3 | Maximum number of face-capture attempts before the SDK surfaces a terminal failure. Counts the initial capture plus each "Scan again" retry, so 3 allows up to three face captures total. |
minFaceRatio | Double? | null (device class) | Minimum fraction of the camera frame (0.0–1.0) that the detected face bounding box must span on both width and height. Defaults: landscape 0.55 / tablet 0.42 / phone 0.32. Lower → customer can stand further away. Set ~0.30 for a kiosk where the customer stands ~50 cm from a large display. |
livenessEnabled | Boolean? | null (SDK default) | Forces the server-side liveness check on or off. null keeps each flow's default — ON for payment/check-in (unless an on-device liveness check already ran), OFF for lite enrollment. Set false on supervised kiosks where local checks are sufficient. |
previewRotation | Int? | null (camera auto) | View-level rotation applied to the camera preview only (visual feed). 0/90/180/270 degrees. Use this when the device's camera sensor mounting doesn't match the display's natural orientation (some POS handhelds ship the front camera at a 90°/270° offset from the display, so the preview comes out sideways without an override). When set, the uploaded image is rotated to match — the server sees the same orientation the customer saw. |
activityOrientation | Int? | null | Force the customer-facing Activity into a specific screen orientation via setRequestedOrientation(...). Accepts any Android ActivityInfo.SCREEN_ORIENTATION_* constant. Useful on handhelds where the camera sensor only produces a server-acceptable image in one orientation. |
Examples:
// Handheld with sensor mounted at 90° pose, display rotated 270° from sensor
EmbeddedPaymentRequest(
...,
rotationAngle = 90,
previewRotation = 270,
activityOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
)
// Kiosk: customer ~50 cm from screen — relax the strict default
EmbeddedCheckinRequest(..., minFaceRatio = 0.30)
// Supervised registration: skip server liveness, rely on local checks
EmbeddedLiteRegistrationRequest(..., livenessEnabled = false)4.10 Per-modality (face vs palm) capture overrides
When face and palm capture need different rotation/camera configs on the same device — common on hardware with a dedicated palm-side sensor, or when MFA's "Use palm" fallback expects a different grip than face capture — set these palm-specific fields. All three default to "fall back to face values," so existing integrations keep working untouched.
| Field | Type | Default | Fallback |
|---|---|---|---|
palmRotationAngle | Int | 0 | pass -1 → rotationAngle |
palmCameraIndex | Int | -1 | cameraIndex |
palmPreviewRotation | Int? | null | previewRotation |
Always send both sets when they differ so the MFA palm fallback (when face misses and the customer taps Use palm on the MFA screen) picks up the right config without the host having to re-issue a fresh request.
EmbeddedPaymentRequest(
...,
rotationAngle = 90, cameraIndex = 0, previewRotation = 270, // face
palmRotationAngle = 0, palmCameraIndex = 1, palmPreviewRotation = 0, // palm
)4.11 Lite registration
Face-enrollment + minimal profile creation (first name + phone — lastName and email are auto-generated placeholders the user updates later). Used for low-friction onboarding flows where the customer doesn't yet have a Wink account.
data class EmbeddedLiteRegistrationRequest(
val requestId: String,
val rotationAngle: Int = 0,
val cameraIndex: Int = 0,
val minFaceRatio: Double? = null, // see §4.9
val livenessEnabled: Boolean? = null, // null = OFF for enrollment by default
val previewRotation: Int? = null,
val activityOrientation: Int? = null,
val maxRetries: Int = 3, // see §4.9
)
data class EmbeddedLiteRegistrationResult(
val requestId: String,
val accessToken: String?, // identity-provider access token (or platform fallback)
val winkSeed: String?,
val oAuthRequestId: String?,
val firstName: String?,
val lastName: String?, // auto-generated placeholder
val email: String?, // auto-generated placeholder
val contactNo: String?,
val alreadyEnrolled: Boolean, // true when the face was recognized; no form shown
val refreshToken: String? = null,
val idToken: String? = null,
val expiresIn: Int? = null,
val tokenType: String? = null,
val isKeycloakToken: Boolean = false, // false when the SDK fell back to the platform token
)
winkPay.startLiteRegistration(
EmbeddedLiteRegistrationRequest(requestId = UUID.randomUUID().toString()),
object : EmbeddedPaymentCallback {
override fun onLiteRegistrationSuccess(result: EmbeddedLiteRegistrationResult) {
// result.accessToken is the identity-provider token (when isKeycloakToken == true)
// — use it as a Bearer token for downstream API calls.
}
override fun onSuccess(r: EmbeddedPaymentResult) {}
override fun onCancelled(requestId: String) {}
override fun onFailure(error: EmbeddedPaymentError) {}
})Flow:
- Fresh face enrollment scan.
- If the face is already known → returns
alreadyEnrolled = truewith the existing user's tokens. No form shown. - Otherwise → the SDK shows a compact form (first name + phone with a country picker). On submit it validates the phone number, creates the profile, then exchanges the result for an identity-provider access token. If that exchange fails it falls back to the platform token (
isKeycloakToken = false).
4.12 Embedding the capture UI in your own Activity (WinkPayCaptureFragment)
WinkPayCaptureFragment)createNativeIntent(...) launches the SDK's full-screen customer-display Activity. If you'd rather have the capture screen draw inside your own layout — next to your branded chrome, in a sidebar, in a tablet master/detail pane — call createCaptureFragment() instead and mount the returned Fragment yourself.
// 1. Register the result callback as usual.
winkPay.startCheckin(
EmbeddedCheckinRequest(
requestId = UUID.randomUUID().toString(),
cameraIndex = 0,
rotationAngle = 0,
),
callback,
)
// 2. Mount the fragment in your container instead of launching the Activity.
supportFragmentManager.beginTransaction()
.replace(R.id.host_capture_container, winkPay.createCaptureFragment())
.commit()The fragment reads its capture parameters from the same process-scoped state that startCheckin / startPayment populates, so always call one of those before mounting the fragment.
Supported flows: the check-in flow (face capture → API auth → onCheckinSuccess) works end-to-end inside the fragment today. Full payment flows (startPayment) are not yet exposed through this entry point because they involve multiple downstream screens (PaymentOptions, PaymentConfirmation, MFA/Consent dialogs) that aren't suited to an embedded view — keep using createNativeIntent(...) for those.
Cancellation: removing the fragment from its container at any time tears down the capture session. If you want the registered callback to receive onCancelled, call winkPay.cancelPayment() as well — by default a host-driven removal silently disposes of state.
4.13 Age-restricted override (setAgeRestrictionOverride)
setAgeRestrictionOverride)When an order is age-restricted (EmbeddedPaymentRequest.ageRestrictedPurchase = true) and the matched customer's profile is not verified, the customer display blocks payment and shows an ID prompt. If the merchant visually checks the customer's ID and approves the sale, call:
winkPay.setAgeRestrictionOverride()This clears the customer-side ID gate for the current order so payment can proceed without profile verification. Typically wired to an "Override" button on the merchant display. Safe to call from any thread.
5. Credentials & device registration
5.1 Merchant credentials
Pass the merchant credentials directly on WinkPaySdk.Config:
WinkPaySdk.Config(
clientId = "YOUR_CLIENT_ID",
merchantClientSecret = "YOUR_MERCHANT_CLIENT_SECRET",
deviceSerialNumber = Build.SERIAL,
sdkEnvironment = "stage",
)Both fields are required for any biometric flow. Treat the secret as exactly that — keep it out of source control, and out of strings.xml. Stage vs prod URLs are baked into the SDK and selected via Config.sdkEnvironment — there's nothing else to configure for a standard deployment.
5.2 Device registration (deviceSerialNumber)
deviceSerialNumber)The SDK requires the device to be registered with the WinkPay platform before it can run face or palm. Pass the POS serial number on every init — the SDK takes care of the rest:
- First launch: a background thread registers the serial with the WinkPay platform. The platform returns a server-issued device identifier, which the SDK persists locally.
- Subsequent launches: the cached device identifier is reused. The serial you pass is ignored unless the cache was cleared (e.g. app data wiped).
- At biometric capture time: the SDK attaches the cached device identifier to every biometric login call.
If registration hasn't completed yet (or failed), the capture screens display a small red ! "Device not registered" badge in the top-right corner. Biometric attempts in that state surface a "Device not registered. Please contact support." failure to your callback.
What to pass as
deviceSerialNumber. On POS hardware with API 24–25,Build.SERIALtypically works. On newer devices, fall back to a stable per-device synthetic ID (e.g."$MANUFACTURER-$MODEL-$ID"— or your own provisioned ID). Whatever you pass on the first run is what gets registered with WinkPay; it is not mutated locally afterwards.
5.3 Legacy envFileContent path
envFileContent pathIf you have an existing integration that reads merchant credentials from a .env-style file, you can keep using Config.envFileContent — the SDK still parses KEY=VALUE lines for CLIENT_ID, MERCHANT_CLIENT_SECRET, and the optional URL-override keys below. New integrations should use the direct fields in §5.1. When both are present, the direct fields win.
| Key | Required | Used by | Notes |
|---|---|---|---|
CLIENT_ID | ✓ | Auth API | OAuth client id issued by WinkPay |
MERCHANT_CLIENT_SECRET | ✓ | Auth API | OAuth client secret |
BASE_LOGIN_URL | optional | Auth API | Override the baked-in stage/prod URL — only set for non-standard deployments |
BASE_PAYMENT_URL | optional | Payment API | Override of baked-in URL |
BASE_API_URL_PALM | optional | Palm API | Override of baked-in URL |
PMS_PROJECT_ID | optional | Palm API | Override of baked-in project id |
6. Lifecycle & threading
What happens when you call startPayment and launch the Activity
startPayment and launch the ActivitystartPaymentstores the order data in process-scoped state and registers your callback.createNativeIntentreturns an Intent for the SDK's customer-display Activity.- The Activity reads the order data in
onCreate. IfrequestTypewasPAY_FACEorPAY_PALM, it routes straight to the corresponding capture screen; otherwise it shows the face/palm chooser. - Capture → API auth → consent / MFA dialogs as required → card selection → either pay or return external tokens.
- The Activity finishes. Your callback fires (on the main thread):
onSuccess— payment processed or external tokens returnedonCancelled— user tapped cancel, or you calledcancelPayment()onFailure— auth/payment failed andreturnOnFailure=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 a dedicated 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 twicestartPayment 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:
- The Intent must include
FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_MULTIPLE_TASK—createNativeIntent(ctx, displayId)adds these for you whendisplayId >= 0. - You must pass an
ActivityOptionsbundle. 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.
The capture screen guides the user into the correct pose automatically — surfacing on-screen prompts to move lower / further back or to centre the hand in the bracket when it sits just outside the usable zone, and only committing to capture once the hand has held a good pose steadily. If the underlying engine accepts a capture but stalls before liveness, the SDK silently re-acquires rather than freezing. The prompted gesture must be held for at least ~1 second before it is accepted — a gesture flashed and immediately dropped is not counted, and removing the hand right as the prompt appears fails liveness. No host configuration is involved.
The capture screen also manages lighting automatically. Camera exposure is metered on the palm (not the background), the area of the customer display outside the guide frame is dimmed when a bright background would otherwise overpower the palm, and if no palm is detected for a couple of seconds the guide-frame interior brightens (and screen brightness is raised) to act as a fill light for the hovering hand. Backlit scenes — a light source behind the hand — are detected and compensated for automatically. Expect visible dimming/brightening of the customer display during palm capture; it is intentional and requires no host configuration.
If liveness is rejected the errorMessage starts with "Liveness check…" (e.g. "Liveness check failed", "Liveness check incomplete"). 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, enable SDK logging — WinkPaySdk.Config(debugLogging = true) or adb shell setprop log.tag.PalmCaptureSession DEBUG — and capture logcat with tag PalmCaptureSession; every reject logs a one-line reason. Reach out to the Wink team if you believe these rejects require SDK tuning.(Debug/info logs are off by default.)
8.1 Lite palm path (server-assisted — not in current releases)
Earlier previews of this guide described an optional server-assisted palm capture path, selected via a PALM_MODELING_SERVER=http://host:port key in Config.envFileContent, that offloaded palm-model construction to an external modeling server. That path does not ship in the 1.6.x AARs — setting PALM_MODELING_SERVER has no effect, and all palm capture (model build and liveness) runs on-device as described above. If the server-assisted path returns in a future release, it will be re-documented here.
8.2 Palm ABI variants (64-bit vs 32-bit AAR)
The palm engine (libPalmAPI.so, ~18 MB per ABI) ships in exactly one ABI variant per AAR. Both variants have identical Maven coordinates (com.wink:winkpay-sdk:<version>) — the distribution zip name is the distinguisher.
| Variant | Distribution zip | Native libs in the AAR | Pick it for |
|---|---|---|---|
| 64-bit (default) | winkpay-sdk-<version>.zip | arm64-v8a libPalmAPI.so + JNI bridge for both arm64-v8a and armeabi-v7a | Standard 64-bit POS hardware (Clover, etc.) |
| 32-bit | winkpay-sdk-<version>-arm32.zip | armeabi-v7a libs only (engine + bridge) | 32-bit POS hardware (e.g. PAX A920, Ingenico DX8000) |
How each behaves at runtime:
- 64-bit variant — palm works on arm64-v8a devices. The 32-bit JNI bridge stub keeps the AAR installable on 32-bit devices, but palm gates off there: face flows work normally, and palm requests fail with
PALM_UNSUPPORTED(§9.1). - 32-bit variant — because the AAR contains armeabi-v7a native libs only, Android runs the host process in 32-bit mode even on 64-bit devices, so palm works everywhere.
Caveat: if your host app ships its own arm64-v8a native libs (or sets
abiFilters += "arm64-v8a"), Android picks a 64-bit process — the 32-bit palm engine can't load, and palm gates off to face-only. With the 32-bit variant, either ship no arm64 libs or restrict your own to armeabi-v7a.
The SDK detects the process-bitness / bundled-engine match at runtime; on a mismatch it never crashes — it simply reports palm as unsupported and errors explicitly on any palm request.
The 64-bit variant is what you receive by default. If your hardware needs the 32-bit variant, request the -arm32 distribution zip from the Wink team.
9. Error handling
9.1 errorCode values delivered via EmbeddedPaymentError
errorCode values delivered via EmbeddedPaymentErrorThe native flow produces the following error codes:
errorCode | Cause |
|---|---|
LIVENESS_FAILED | The biometric session failed because liveness was rejected (face or palm). The user-visible message starts with "Liveness check…". |
USER_NOT_ENROLLED | Face/palm capture succeeded but no enrolled user matched. Only delivered when the host opted out of in-SDK enrollment (e.g. check-in flows on merchants that don't offer lite registration). The accompanying message is a fixed "not enrolled" string. |
PROFILE_INCOMPLETE | The matched user exists but their profile is missing fields the backend requires before continuing. Message is a fixed string from the SDK — the BE response itself is generic, so the SDK substitutes a clearer one. |
PALM_UNSUPPORTED | A palm flow (PAY_PALM, PAY, or check-in with biometricType = "palm") was requested on a device whose hardware does not support palm capture, or whose process bitness doesn't match the palm-engine ABI bundled in the SDK variant (see §8.2). Delivered immediately, before any UI is shown. Message: "Palm biometrics aren't supported on this device. Please use face capture instead." Hosts that want a face-only fallback should re-issue the request as PAY_FACE. |
AUTH_FAILED | Any other terminal authentication / payment failure: face not recognized, server error, network error, payment declined, etc. The errorMessage carries the human-readable reason (server-supplied where available, otherwise face-friendly — see §9.2). |
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
errorMessageNot 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 prefix | Meaning |
|---|---|
Liveness check failed. | Palm or face liveness rejected — wrong/absent gesture, gesture window expired, or a spoof guard triggered |
Liveness check incomplete | Palm left the frame mid-gesture |
Face not recognized / User not found | The captured face did not match any enrolled user for this merchant |
Payment declined | Card-level decline from the processor |
Network error | Network transport error reaching the auth or payment API |
Humanized face-scan errors. When the auth server returns an HTTP error with no useful body — common for HTTP 400 on an unusable face image (occluded, blank, oversized, malformed) — the SDK substitutes a face-specific message instead of surfacing the raw HTTP reason phrase ("Bad Request", "Unauthorized", "Internal Server Error", or a bare HTTP NNN fallback). The replacements are keyed off the HTTP class:
| HTTP class | User-visible message |
|---|---|
| 400 (Bad Request) | "We couldn't read your face from that image. Make sure your face is fully visible, well-lit, and not blocked by hands, hats, or sunglasses, then try again." |
| 401 (Unauthorized) | "Your session expired. Please start over." |
| 404 (Not Found) | "We couldn't find a matching account. Please try again or sign up." |
| 5xx (Server) | "We're having trouble on our end. Please try again in a moment." |
| Network / IO | "Couldn't reach the server. Please check your connection and try again." |
| Other | "Face scan failed. Please try again." |
If the server does return a real, descriptive error body, the SDK surfaces that verbatim — the substitution only kicks in when the response body would otherwise be useless (empty, or a bare reason phrase). The substituted text appears in EmbeddedPaymentError.errorMessage when returnOnFailure = true, and on the native retry screen otherwise.
9.3 Initialization-time exceptions
These come out as standard Kotlin exceptions, not via the callback:
| Exception | Cause |
|---|---|
IllegalStateException("WinkPaySdk not initialized") | You accessed an API that requires init before calling WinkPayEmbedded.init |
IllegalStateException("$key is not configured in .env") | A required credential (CLIENT_ID, MERCHANT_CLIENT_SECRET) was missing or blank — pass it via Config.clientId / Config.merchantClientSecret (or the legacy envFileContent keys) |
IllegalArgumentException("environment must be one of [qa, stage, prod]…") | Config.sdkEnvironment was something other than "qa", "stage", or "prod" |
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 SDK's own serialization needs.
If you minify your app and see R8 warnings about other transitive dependencies that your own app pulls in (your POS vendor SDK, analytics libraries, 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) to your APK. Each AAR variant bundles the palm engine (libPalmAPI.so, ~18 MB uncompressed) for a single ABI — see §8.2 — so the footprint is per-variant, not per-ABI-times-N. The face-detection model is delivered on-device at runtime by the platform — zero APK cost.
To keep the build lean:
- 64-bit (default) variant — set
ndk.abiFilters += "arm64-v8a"; including other ABIs just inflates your APK with native libs that won't run. - 32-bit variant — do not filter to (or ship) arm64-v8a; leave
abiFiltersunset or set it toarmeabi-v7a. Shipping your own arm64 libs alongside this variant forces a 64-bit process and gates palm off (§8.2).
10.3 Versioning
Maven coordinates are com.wink:winkpay-sdk:<version>. The current recommended version is 1.6.1. The SDK versioning convention is:
1.x.0— feature releases1.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
| Symptom | Likely cause | Fix |
|---|---|---|
IllegalStateException: WinkPaySdk not initialized | API used before WinkPayEmbedded.init() returned | Move init earlier (Application.onCreate or top of Activity.onCreate). |
IllegalStateException: CLIENT_ID is not configured in .env | Config.clientId (and merchantClientSecret) were not passed, and envFileContent was either absent or missing the key | Pass clientId + merchantClientSecret directly on Config (see §5.1). For the legacy .env path, verify the file actually contains the keys and that Config(envFileContent = …) was set. |
Capture screen shows a red ! "Device not registered" badge | First-run device registration hasn't completed yet — the SDK couldn't reach the WinkPay platform, or Config.deviceSerialNumber was null / blank | Confirm Config.deviceSerialNumber is non-blank; check network reachability. Once the device identifier is cached, the badge is gone for the lifetime of the install. See §5.2. |
| SDK init crashes with a native-library load error | Bundled native libs are being packaged compressed inside base.apk | Set 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_FAILED | Host 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 result | Mode confusion: returnExternalTokens=true populates winkCardToken and leaves transactionId null; returnExternalTokens=false does the opposite | Read §4.7 — pick the mode that matches who is processing the charge. |
| Palm liveness consistently fails for a real user on a real device | One of the SDK's local liveness guards is rejecting | Enable SDK logging (Config(debugLogging = true) or adb shell setprop log.tag.PalmCaptureSession DEBUG), then capture 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 install | Same root cause as the native-library load error above | useLegacyPackaging = true. |
Palm requests fail with PALM_UNSUPPORTED on a 64-bit device while using the 32-bit (arm32) SDK variant | The host app ships its own arm64-v8a native libs (or sets abiFilters += "arm64-v8a"), so Android runs a 64-bit process that can't load the armeabi-v7a palm engine | Remove/restrict your own native libs to armeabi-v7a so the process runs 32-bit, or switch to the default 64-bit SDK variant. See §8.2. |
Updated 16 days ago
