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.5.0
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" } // 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.5.0")
}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 only | The SDK ships native libraries for arm64-v8a only; x86_64 emulators are not supported |
useLegacyPackaging = trueis mandatory. Without it, the SDK's bundled native libraries stay insidebase.apkinstead of being extracted tolib/arm64-v8a/, 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. No host configuration is involved.
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, 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 (preview — off by default)
The SDK also ships an optional server-assisted palm capture path that offloads palm-model construction to an external modeling server, lowering on-device memory use on resource-constrained POS hardware. From the host's perspective there is no behavioural difference — palm identification still resolves to the same onSuccess / onFailure callback.
The path is selected via an optional configuration key:
| Configuration | Effect |
|---|---|
| (unset — default) | On-device path runs. The palm model is built locally. Production-recommended. |
PALM_MODELING_SERVER=http://host:port | Server-assisted path runs. Captured frames are sent to the modeling server, which returns the model. |
Pass it through Config.envFileContent (legacy env-file path) alongside your other keys:
WinkPaySdk.Config(
clientId = "…",
merchantClientSecret = "…",
deviceSerialNumber = Build.SERIAL,
sdkEnvironment = "stage",
envFileContent = "PALM_MODELING_SERVER=http://10.0.0.42:9090\n",
)The server-assisted path is currently preview / staged — the on-device path remains the default and the recommended option for production deployments. The host-facing API (startPayment with PAY_PALM, startCheckin with biometricType = "palm") is identical for both paths.
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. 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 the gesture/blink |
Liveness check timed out | Gesture window expired before a PASS verdict |
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, arm64-v8a only) to your APK. The face-detection model is delivered on-device at runtime by the platform — 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.5.0. 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. |
