Running Payments on Android
Learn how to drive live Tap to Pay on Android sessions with the Koard SDK and how to handle every follow-up transaction step.
What you learn
- Which APIs emit reader events, and why
refund()is not one of them - How to validate SDK readiness before launching the thin client
- Handling
Flow<KoardTransactionResponse>to power custom UIs - Running Practice Mode taps to validate the online-PIN flow
- Managing surcharge confirmation, tip/tax/surcharge breakdowns, and telemetry
- Post-reader operations such as capture, refund, EMV refund, reverse, incremental auth, and tip adjustments
Step 1: Install the Visa Kernel App
Before writing any Tap to Pay code, install the Visa Kernel app on every NFC-enabled development device. Download it directly from the Google Play Store, launch it once to complete setup, and keep it installed for all future builds.
Prerequisites
Before starting any NFC session:
- Initialize the SDK in
Application.onCreate()and authenticate the merchant - Call
setActiveLocation(locationId)and thenenrollDevice()— both are mandatory, and neither happens automatically. See Installing the SDK - Register the hosting activity for NFC in
onResume()/onPause() - Disable developer mode on the device (reader emits
developerModeEnabledif it’s left on) - Observe
KoardMerchantSdk.getInstance().readinessStateand block the UI unlessisReadyForTransactionsistrue
val sdk = KoardMerchantSdk.getInstance()
val readiness = sdk.readinessState.value
if (!readiness.isReadyForTransactions) {
Log.w("Checkout", "SDK not ready: ${readiness.getStatusMessage()}")
return
}
Launching a Sale Session
sdk.sale, sdk.preauth, sdk.completePartialAuth, and sdk.refundEmv are the APIs that drive a reader session, so they return cold Flow streams. sdk.prepare also returns a Flow, but it is a warm-up that emits progress toward Done — it never runs a transaction. Collect the flow on a worker thread, and feed each event back to the UI. (Other calls reach the thin client too — isKernelAppInstalled, checkKiCEligibility, getTransactionConfig, cancelTransaction, resetKernelService, unenrollDevice, and the NFC registration pair — but they are one-shot and do not emit reader events.)
suspend fun sale(
activity: Activity,
amount: Int, // amount in cents
breakdown: PaymentBreakdown? = null,
buttonProperties: List<KoardButtonProperties>? = null,
currency: String = "USD",
eventId: String? = null,
tapTimeoutMs: Long? = null, // auto-cancel the tap after this many ms
transactionType: String = "Payment" // "TestWithPin" for Practice Mode
): Flow<KoardTransactionResponse>
Pass buttonProperties to customize the on-screen reader buttons, and tapTimeoutMs to automatically cancel the reader session if no card is presented in time.
currency must be one of the currency codes the kernel reports for this device. Per KiC §3.4.8 the transaction's currencyCode "can be one of the currencies that comes out of the getTransactionConfig api", so if you support anything beyond a single fixed currency, read the live list from sdk.getTransactionConfig() (a suspend call returning KoardTransactionConfig?, wrapping KiC §3.4.16) rather than hard-coding it. The same object carries the supported transactionTypes, supportedButtons, supportedCountries, and supportedLanguages. Visa marks getTransactionConfig Implementation: Conditional — it is there to be called on demand when you need the latest config.
preauth no longer matches sale's signature. The trailing transactionType parameter is new in 1.0.6 and exists on sale() only. preauth() takes the same first seven parameters (activity through tapTimeoutMs) and nothing more.
Readiness is enforced by throwing, not by emitting. sale(), preauth(), completePartialAuth(), and refundEmv() refresh their readiness checks and throw KoardException before returning a Flow when the SDK is not ready. Inspect e.error.errorType: it is a KoardErrorType.NotReady.* value such as KernelAppNotInstalled, DeveloperModeEnabled, NotAuthenticated, NotEnrolled, NoActiveLocation, ReaderNotStarted, or Preparing. Retry on Preparing; prompt the operator on the rest. Wrap the call in try/catch, not just the collection.
Practice Mode (TestWithPin)
transactionType is the KiC transaction type sent to the kernel:
| Value | Behavior |
|---|---|
"Payment" (default) |
A real sale. The kernel picks the CVM from the card and terminal profile — usually signature or no-CVM, never a guaranteed PIN |
"TestWithPin" |
Practice Mode (KiC Annex A-2). Forces the PIN-entry UI so you can validate the online-PIN flow without routing to the acquirer |
sdk.sale(
activity = activity,
amount = 1000,
transactionType = "TestWithPin" // Practice Mode — exercises the PIN keypad
).collect { response -> handleTransactionEvent(response) }
Practice Mode is only valid when the device enrollment's supported transaction types include TestWithPin. Read the enrollment's supported types from sdk.getEnrollmentDetails()?.transactionTypes before offering it in your UI.
fun startSale(activity: Activity, amountInCents: Int) {
val sdk = KoardMerchantSdk.getInstance()
// No pre-flight readiness guard here: sale() refreshes the readiness checks
// itself and throws with the typed reason, so a guard that returns early
// would swallow the retryable Preparing case. Let the catch below decide.
val eventId = UUID.randomUUID().toString()
lifecycleScope.launch(Dispatchers.IO) {
try {
sdk.sale(
activity = activity,
amount = amountInCents,
breakdown = PaymentBreakdown(subtotal = amountInCents),
currency = "USD",
eventId = eventId
).collect { response ->
// The Flow is collected on Dispatchers.IO; hop to Main for UI work.
withContext(Dispatchers.Main) {
handleTransactionEvent(response)
}
}
} catch (e: KoardException) {
// sale() runs the readiness checks itself and throws before it
// returns the Flow, so this is where a not-ready SDK surfaces.
withContext(Dispatchers.Main) {
when (e.error.errorType) {
// Transient: the reader is still coming up — retry shortly.
is KoardErrorType.NotReady.Preparing ->
scheduleRetry { startSale(activity, amountInCents) }
// Every other NotReady state needs operator action
// (install the kernel app, disable developer mode, log in,
// enroll the device, select a location).
else ->
showError(message = e.error.shortMessage, errorType = e.error.errorType)
}
}
}
}
}
private fun handleTransactionEvent(response: KoardTransactionResponse) {
when (response.actionStatus) {
KoardTransactionActionStatus.OnProgress -> {
showStatus(response.readerStatus.toString(), response.displayMessage)
}
KoardTransactionActionStatus.OnFailure -> {
showError(
code = response.statusCode,
message = response.displayMessage ?: "Transaction failed",
finalStatus = response.finalStatus
)
}
KoardTransactionActionStatus.OnComplete -> {
val transaction = response.transaction ?: return
showReceipt(transaction)
}
else -> Unit
}
}
Event payloads
Every emitted KoardTransactionResponse contains the data you need to build a bespoke reader UI:
readerStatus– aKoardReaderStatusenum value:preparing,readyForTap,cardDetected,pinEntryRequested,pinEntryCompleted,readCompleted,readNotCompleted,readCancelled,readRetry,processing,complete,developerModeEnabled, orunknowndisplayMessage– Reader-provided instructions ("Present card", "Processing", etc.)statusCode/statusCodeDescription– numeric status (and its description) for advanced troubleshootingactionStatus–OnProgress,OnFailure, orOnComplete(these are the only three action statuses)finalStatus–Approve,Decline,Abort,Failure,AltService, orUnknownafter reader completiontransaction– populated on completion; includes IDs, surcharge state, and breakdown so you can print receipts or prompt for confirmation
Surcharge confirmation is signaled on the transaction, not the action status. There is no OnConfirmSurcharge action status. When the completion event carries a transaction whose transaction.status == KoardTransactionStatus.SURCHARGE_PENDING, prompt the customer and call sdk.confirm(transactionId, confirm = true/false) with their decision.
For a complete reference of every finalStatus, statusCode, display message ID, and error scenario the SDK can return, see the SDK Response Codes guide.
The demo’s MainScreenViewModel (see src/main/java/com/koard/android/ui/MainScreenViewModel.kt) shows a complete Compose-based implementation that you can adapt for your UI stack.
Preauthorization
Call sdk.preauth(...) when you need a hold + later capture. It takes the same parameters as sale except the trailing transactionType (Practice Mode is sale-only), the Flow emits the same events, and response.transaction?.transactionId is the ID you’ll pass into capture/refund APIs later.
Completing a partial approval
When an authorization is only partially approved (the issuer approved less than the requested amount), call sdk.completePartialAuth(...) to authorize the remaining amount using the original card data — no new tap is required for the card itself, but because it drives the reader session it returns a Flow<KoardTransactionResponse> just like sale/preauth:
suspend fun completePartialAuth(
activity: Activity,
transactionId: String,
amount: Int,
breakdown: PaymentBreakdown? = null,
buttonProperties: List<KoardButtonProperties>? = null,
currency: String = "USD",
eventId: String? = null,
tapTimeoutMs: Long? = null
): Flow<KoardTransactionResponse>
Warming up the reader
sdk.prepare() is a latency optimization, not a precondition for tapping. Visa marks the pre-transaction warm-up API Implementation: Optional (KiC integration guide §3.4.7, "Preparing the SDK for Point-of-Sale Transactions"), and startTransaction (§3.4.11) lists no warm-up among its preconditions. What warming up buys you is timing: §3.4.7 describes the session-establishment and device-attestation work as happening "before startTransaction" either way, and frames the API as a fix for "the delay seen if the startTransaction is called for the first time after app restart". Calling it moves that cost off the critical user path.
The one precondition §3.4.7 does state is that the device must already be enrolled before you warm up, so call prepare() after enrollDevice() has succeeded, not before.
Call it early (for example in Application.onCreate, once enrollment is confirmed). It returns a Flow<KoardPrepareResponse> that emits progress (Called → AuthenticationInProgress → AttestationInProgress → GettingConfigurations → ParsingConfigurations → Done); key off the Done emission and treat any error variant as "retry on next user action". Nothing else in your flow needs to block on it — a fire-and-forget collect on a worker scope is a valid usage pattern, since the only effect is the warm-up.
// collect() is a suspend function — call it from a coroutine on a worker scope.
applicationScope.launch(Dispatchers.IO) {
sdk.prepare().collect { response ->
if (response.status is KoardPrepareStatus.Done) {
Log.i("Checkout", "Reader is warmed up")
}
}
}
Post-Reader Operations
All backend-only operations are suspend functions that return a Result, so they never emit reader events and can run from any worker thread. The payload varies by call: capture, reverse, refund, adjust, and getTransaction return Result<KoardTransaction>; getTransactions returns Result<KoardTransactionDetails> (which carries the paging fields); and sendReceipt returns Result<PaymentOperationResponse>.
cancelTransaction() and resetKernelService() also return a Result (Result<Unit>), but they are not backend calls — they talk to the Tap to Pay Ready app over IPC to abort a tap or release the service connection. See Canceling the reader session.
Capture
suspend fun captureTransaction(transactionId: String, amountOverride: Int? = null) {
withContext(Dispatchers.IO) {
val sdk = KoardMerchantSdk.getInstance()
sdk.capture(transactionId = transactionId, amount = amountOverride)
.onSuccess { println("Capture successful: ${it.transactionId}") }
.onFailure { println("Capture failed: ${it.message}") }
}
}
Incremental authorization
suspend fun incrementalAuth(transactionId: String, additionalAmountCents: Int) {
withContext(Dispatchers.IO) {
val sdk = KoardMerchantSdk.getInstance()
sdk.incrementalAuth(transactionId, additionalAmountCents)
.onSuccess { println("Incremental auth approved") }
.onFailure { println("Incremental auth failed: ${it.message}") }
}
}
Reverse / void
suspend fun reverseTransaction(transactionId: String, amountCents: Int? = null) {
withContext(Dispatchers.IO) {
val sdk = KoardMerchantSdk.getInstance()
sdk.reverse(transactionId, amountCents)
.onSuccess { println("Reverse successful: ${it.transactionId}") }
.onFailure { println("Reverse failed: ${it.message}") }
}
}
Refund (backend-only)
sdk.refund(...) is a plain backend call — no card is presented and no reader events are emitted. Use it to refund against a transaction you already hold an ID for:
suspend fun refundTransaction(transactionId: String, amount: Int? = null) {
withContext(Dispatchers.IO) {
val sdk = KoardMerchantSdk.getInstance()
sdk.refund(
transactionId = transactionId,
amount = amount, // null refunds the full amount
eventId = UUID.randomUUID().toString()
).onSuccess {
println("Refund successful: ${it.transactionId}")
}.onFailure {
println("Refund failed: ${it.message}")
}
}
}
sdk.refundTransaction(transactionId, amount, eventId) is an older alias with the same signature and behavior; prefer refund(...) in new code.
EMV refund (tap required)
When the customer must present their card to receive the refund, use sdk.refundEmv(...). This is the only refund entry point that drives the thin client, so it returns a Flow<KoardTransactionResponse> and emits the same reader events as sale:
suspend fun refundEmv(
activity: Activity,
transactionId: String,
amount: Int, // amount in cents, required
breakdown: PaymentBreakdown? = null,
buttonProperties: List<KoardButtonProperties>? = null,
currency: String = "USD",
eventId: String? = null
): Flow<KoardTransactionResponse>
lifecycleScope.launch(Dispatchers.IO) {
sdk.refundEmv(
activity = activity,
transactionId = transactionId,
amount = amountInCents,
eventId = UUID.randomUUID().toString()
).collect { response ->
handleTransactionEvent(response) // same handler as sale
}
}
Like sale(), refundEmv() throws a KoardErrorType.NotReady.* KoardException before returning a Flow when the SDK is not ready, and it has no tapTimeoutMs parameter.
Tip adjustments
suspend fun adjustTip(transactionId: String, newTipCents: Int) {
withContext(Dispatchers.IO) {
val sdk = KoardMerchantSdk.getInstance()
sdk.adjust(
transactionId = transactionId,
type = AmountType.FIXED,
amount = newTipCents
).onSuccess {
println("Tip adjusted. New total: ${it.totalAmount}")
}.onFailure {
println("Tip adjustment failed: ${it.message}")
}
}
}
Confirming a surcharge
When a completion event carries a transaction with status == KoardTransactionStatus.SURCHARGE_PENDING, prompt the customer and submit their decision with sdk.confirm(...):
suspend fun confirmSurcharge(transactionId: String, accepted: Boolean) {
withContext(Dispatchers.IO) {
val sdk = KoardMerchantSdk.getInstance()
sdk.confirm(transactionId = transactionId, confirm = accepted)
.onSuccess { println("Surcharge ${if (accepted) "accepted" else "declined"}: ${it.totalAmount}") }
.onFailure { println("Confirm failed: ${it.message}") }
}
}
Canceling the reader session
There are two teardown paths. They do different things — one aborts a tap in flight, the other releases the IPC connection to the Tap to Pay Ready app.
| Call | Use for | Effect |
|---|---|---|
sdk.cancelTransaction() |
Programmatic cancel, tap timeouts | Calls KiC cancelTransaction(), which aborts the current tap and emits a cancel terminal through the active flow, then clears the SDK's transaction guard. It does not call unbindKernelService(), so the SDK-connector's binding to the Tap to Pay Ready app is left in place |
sdk.resetKernelService() |
Navigating away from the transaction screen (including after a completed transaction, before the receipt screen), and recovery: the kernel app died, an unrecoverable error, or "another tap is already active" | Unbinds the IPC connection to the Tap to Pay Ready app and clears the SDK's transaction state. Enrollment and the kernel app itself are untouched. The next tap re-initializes lazily; call prepare() afterward only to avoid paying that latency on the critical path |
suspend fun cancelTap() {
withContext(Dispatchers.IO) {
val sdk = KoardMerchantSdk.getInstance()
sdk.cancelTransaction() // abort the tap; leaves the IPC binding in place
.onSuccess { println("Tap cancelled") }
.onFailure { println("Cancel failed: ${it.message}") }
}
}
suspend fun releaseReaderSession() {
withContext(Dispatchers.IO) {
val sdk = KoardMerchantSdk.getInstance()
sdk.resetKernelService() // unbind IPC; safe after a completed transaction
.onSuccess { println("Reader session reset") }
.onFailure { println("Reset failed: ${it.message}") }
}
}
The underlying KiC cancelTransaction() API is deprecated. Visa's 25.06.10 release notes list "Deprecated cancelTransaction() Api" alongside the change that makes the Tap to Pay Ready app call cancel itself when the user presses its cancel button (it now emits CancelTransactionInitiated instead of asking your app to cancel). The KiC integration guide has no §3.4 section specifying cancelTransaction()'s behavior; it appears only in §3.4.10 as one of the calls that returns a TransactionResponseMessage.
Koard's cancelTransaction() still calls it, and it is the path the SDK's own tap-timeout timer uses. Treat customer-pressed cancel as handled by the Tap to Pay Ready app, and reach for this wrapper when your code needs to abort a tap. If a future kernel release removes the API, this wrapper is the surface that breaks — pin your integration tests to it.
resetKernelService() wraps KiC unbindKernelService() (KiC integration guide §3.4.12, "Resetting the Service Connection State", implementation optional). Visa's own sample calls it once a transaction completes, before navigating to the receipt screen — so it is routine lifecycle hygiene, not just an emergency lever. It resets the connection between the SDK-connector and the Tap to Pay Ready app; it does not unenroll the device, uninstall the kernel app, or discard credentials.
It always clears the SDK's internal state, even when the underlying unbind throws (for example because the kernel app is already dead) — the returned Result reports the unbind failure, but the next tap flow can still start. Use sdk.hasActiveTapTransaction() to check whether a tap is in flight before tearing anything down.
Account, location, and terminal details
Use these suspend lookups to read merchant, location, and terminal configuration (including surcharge and tax details) when building your checkout UI:
sdk.getMerchantAccount() // Result<KoardAccount>
sdk.getLocation(locationId) // Result<KoardLocation>
sdk.getTerminal(terminalId) // Result<KoardTerminal>
Transaction history and receipts
// Offset paging over the active location's transactions. KoardTransactionDetails
// carries limit/offset/page/total, so advance offset and compare offset + loaded
// against total to implement "load more".
sdk.getTransactions(limit = 50, offset = 0) // Result<KoardTransactionDetails>
sdk.getTransaction(transactionId) // Result<KoardTransaction>
// Email, SMS, or both — pass null for the channel you don't want.
sdk.sendReceipt(
transactionId = transactionId,
email = "customer@example.com",
phoneNumber = null
) // Result<PaymentOperationResponse>
Next steps
- Review the Installing the SDK guide for initialization, location selection, and enrollment
- Explore the Demo App to see
MainScreenViewModelin action - See SDK Response Codes for the full reference of final statuses, display messages, and error scenarios
- Consult
KoardPaymentModels.ktforPaymentBreakdown,Surcharge, and other metadata helpers - Check Supported Devices & NFC Tap Location for device compatibility and where customers should tap
Best Practices
- Idempotency everywhere: Pass a stable
eventIdintosale,preauth, refunds, reversals, and adjustments so Koard can safely dedupe retried requests. - UI threading: Collect the payment Flow on
Dispatchers.IO, but dispatch UI updates (Compose state, views, dialogs) back to the main thread to avoid lifecycle crashes. - Surface readiness early: Bind
readinessStateto visible UI (like the demo Settings screen) so operators see why the reader is blocked (developer mode, missing tap-to-pay dependency, no active location). - Telemetry hooks: Each
KoardTransactionResponsecarriesstatusCode,readerStatus, andfinalStatus. Log or export these fields for device fleet monitoring and support. - Lifecycle hygiene: Cancel the Flow when the hosting Activity/Fragment stops, and call
sdk.cancelTransaction()for programmatic cancels and timeouts — it aborts the tap without unbinding. Callsdk.resetKernelService()when navigating away from the transaction screen — including after a completed transaction, per KiC §3.4.12 — to release the IPC connection. - NFC registration: Keep
registerActivityForNfc()/unregisterActivityForNfc()wired intoonResume()/onPause()on every activity that can host a tap. Visa marks these optional (KiC §3.3.15.1–2), but they claim NFC foreground dispatch so anotherTECH_DISCOVEREDapp cannot jump in front of your tap (§3.4.14).

