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, including the card-present
refund()overload - 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
For custom order/refund references, see Transaction Metadata. For deinit(), initialization status, and signing in after a reset, see SDK Lifecycle and Deinitialization.
These examples target the published Android SDK 1.0.7. Amount arguments are Int minor currency units. Install this version before using them.
Active Location and Terminal Routing
Your app must provide a way to select the active location in settings. Authenticate the merchant, load that merchant's available locations, and let the operator select the location where the device is taking payments. The selected location_id resolves the linked terminal_id, whose processor configuration, MID, and TID determine payment routing for that merchant.
Show the active location in settings and on the payment screen. Select it before reader preparation or any payment. If the operator changes merchants, clear the previous selection and reload the new merchant's locations. If you remember a choice between sessions, validate that it still belongs to the authenticated merchant and still has a usable linked terminal. Do not silently reuse another merchant's location or select an arbitrary terminal.
- iOS: call
KoardMerchantSDK.shared.setActiveLocationID(location.id), then follow the account-linking and reader preparation flow. - Android: call the suspending
sdk.setActiveLocation(location.id), thensdk.enrollDevice()and the reader preparation flow.
Block payment initiation until location selection and required readiness checks have completed. If a location's terminal changes, reload the configuration and complete the SDK's readiness flow before taking another payment.
Step 1: Check Visa Tap to Pay Ready Installation
Before enrollment or payments, call sdk.refreshReadinessChecks() on a worker thread and inspect sdk.readinessState.value.kernelAppInstalled. If it is false, the merchant can install directly from Google Play, or your app can optionally offer an Install action using sdk.installKernelApp(activity). Refresh again in onResume() when the merchant returns. See Install Visa Tap to Pay Ready for both options and the activity example.
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 the Activity-based sdk.refund(..., withTap = true) (also available as sdk.refundEmv) 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. Create the flow on a worker thread and apply flowOn(Dispatchers.IO) so its reader work also runs there. Collect from your UI coroutine to update the screen on Main. (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
metadata: JSONObject? = null // app-defined JSON context
): Flow<KoardTransactionResponse>
metadata is an optional org.json.JSONObject; see Transaction Metadata for examples and how to retrieve it.
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. transactionType exists on sale() only. In 1.0.7, both methods accept the first seven parameters (activity through tapTimeoutMs) and optional trailing metadata; sale() places transactionType before metadata. Amounts remain Int values in minor currency units.
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.
import android.app.Activity
import com.koardlabs.merchant.sdk.KoardMerchantSdk
import com.koardlabs.merchant.sdk.domain.*
import com.koardlabs.merchant.sdk.domain.exception.KoardException
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
fun startSale(activity: Activity, amountInCents: Int, eventId: String) {
// lifecycleScope runs on Main. eventId was saved with the order before this call.
lifecycleScope.launch {
var receivedTerminalEvent = false
try {
val flow = withContext(Dispatchers.IO) {
KoardMerchantSdk.getInstance().sale(
activity = activity,
amount = amountInCents,
breakdown = PaymentBreakdown(subtotal = amountInCents),
currency = "USD",
eventId = eventId
)
}.flowOn(Dispatchers.IO)
flow.collect { response ->
if (handleTransactionEvent(response)) receivedTerminalEvent = true
}
if (!receivedTerminalEvent) {
showUnresolved("Reader stopped without a final result; reconcile the saved operation")
}
} catch (e: CancellationException) {
throw e // Preserve coroutine cancellation during navigation.
} catch (e: KoardException) {
// Preparing is a readiness rejection before the SDK returns a Flow.
// Only retry that pre-flight case; never replay a submitted charge on a timeout.
when (e.error.errorType) {
is KoardErrorType.NotReady.Preparing ->
scheduleRetry { startSale(activity, amountInCents, eventId) }
else -> showError(message = e.error.shortMessage, errorType = e.error.errorType)
}
} catch (e: Exception) {
showUnresolved("Payment outcome unknown; reconcile the saved operation")
}
}
}
// true means this reader session delivered a terminal event, not that payment succeeded.
private fun handleTransactionEvent(response: KoardTransactionResponse): Boolean {
when (response.actionStatus) {
KoardTransactionActionStatus.OnProgress -> {
showStatus(response.readerStatus.toString(), response.displayMessage)
return false
}
KoardTransactionActionStatus.OnFailure -> {
val canceled = response.finalStatus == KoardTransactionFinalStatus.Abort ||
response.readerStatus == KoardReaderStatus.readCancelled
val retryableRead = response.statusCode == null || response.statusCode in setOf(12, 42, 53)
if (!canceled && retryableRead && response.finalStatus == null) {
// Keep collecting the SAME flow; this asks for another tap, not a new sale.
showStatus(response.readerStatus.toString(), response.displayMessage ?: "Please tap again")
return false
}
if (canceled) showAborted("Transaction canceled")
else showError(code = response.statusCode,
message = response.displayMessage ?: "Transaction failed", finalStatus = response.finalStatus)
response.transaction?.let { reconcileTransaction(it) }
}
KoardTransactionActionStatus.OnComplete -> {
val transaction = response.transaction
when {
response.finalStatus == KoardTransactionFinalStatus.Abort ||
transaction?.status == KoardTransactionStatus.CANCELED ->
showAborted(response.displayMessage ?: "Transaction canceled")
response.finalStatus == KoardTransactionFinalStatus.Decline ||
transaction?.status == KoardTransactionStatus.DECLINED ->
showDeclined(response.displayMessage ?: "Transaction declined")
response.finalStatus == KoardTransactionFinalStatus.Failure ||
transaction?.status == KoardTransactionStatus.ERROR ->
showUnresolved(response.displayMessage ?: "Transaction failed; check its outcome")
transaction == null -> showUnresolved("Missing transaction result")
transaction.status == KoardTransactionStatus.SURCHARGE_PENDING ->
requestSurchargeConfirmation(transaction)
transaction.statusReason == "partial_approval" ->
requestPartialApproval(transaction)
transaction.status == KoardTransactionStatus.PENDING ->
reconcileTransaction(transaction)
transaction.status in setOf(KoardTransactionStatus.AUTHORIZED,
KoardTransactionStatus.CAPTURED, KoardTransactionStatus.SETTLED,
KoardTransactionStatus.REFUNDED, KoardTransactionStatus.REVERSED) ->
showReceipt(transaction)
else -> reconcileTransaction(transaction)
}
}
null -> showUnresolved("Missing action status; reconcile the outcome")
}
return true
}
The UI helpers above are application callbacks. Classify the returned transaction even when finalStatus is null; the standard demo also uses transaction status to handle backend-operation results. An authorized preauth is a hold, so label its receipt accordingly. requestSurchargeConfirmation must disclose the returned surcharge and final total, submit the cardholder decision, and inspect the returned transaction. requestPartialApproval offers a decision on the partial amount or a second-card payment for the remainder. Pending and unresolved outcomes retain the saved IDs for outcome recovery, without automatically resubmitting. Persist a separate ID for each operation that exposes eventId.
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 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 on another card. The customer must tap that card in a new reader session. It returns a Flow<KoardTransactionResponse> just like sale/preauth; pass the original transaction ID and the remaining amount, with a separately persisted operation ID:
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): KoardTransaction =
withContext(Dispatchers.IO) {
KoardMerchantSdk.getInstance().capture(transactionId = transactionId, amount = amountOverride)
.getOrThrow()
}
Incremental authorization
suspend fun incrementalAuth(transactionId: String, additionalAmountCents: Int, eventId: String): KoardTransaction =
withContext(Dispatchers.IO) {
KoardMerchantSdk.getInstance().incrementalAuth(transactionId, additionalAmountCents, eventId)
.getOrThrow()
}
Reverse / void
suspend fun reverseTransaction(transactionId: String, eventId: String, amountCents: Int? = null): KoardTransaction =
withContext(Dispatchers.IO) {
KoardMerchantSdk.getInstance().reverse(transactionId, amountCents, eventId).getOrThrow()
}
Refund (reference-based)
The overload without an Activity returns Result<KoardTransaction> and uses the original transaction reference, with no new tap:
suspend fun refundTransaction(transactionId: String, eventId: String, amount: Int? = null): KoardTransaction =
withContext(Dispatchers.IO) {
KoardMerchantSdk.getInstance().refund(
transactionId = transactionId,
amount = amount, // null requests a full refund
eventId = eventId,
metadata = org.json.JSONObject().put("reason", "customer_return")
).getOrThrow()
}
sdk.refundTransaction(transactionId, amount, eventId) is an older reference-based alias. It has no metadata argument; prefer refund(...) in new code.
EMV refund (tap required)
Use the Activity-based refund(..., withTap = true) overload, as in the standard checkout demo. This returns a Flow<KoardTransactionResponse> and requires a refund amount. refundEmv(...) is also supported in 1.0.7 as a tap-refund alias.
suspend fun refundWithTap(activity: Activity, transactionId: String, amountInCents: Int, eventId: String) {
// Invoke from a Main/UI coroutine. Handle thrown readiness errors as in startSale.
val flow = withContext(Dispatchers.IO) {
KoardMerchantSdk.getInstance().refund(
activity = activity,
transactionId = transactionId,
amount = amountInCents,
withTap = true,
currency = "USD",
eventId = eventId,
metadata = org.json.JSONObject().put("reason", "customer_return")
)
}.flowOn(Dispatchers.IO)
var receivedTerminalEvent = false
flow.collect { response ->
if (handleTransactionEvent(response)) receivedTerminalEvent = true
}
if (!receivedTerminalEvent) showUnresolved("Refund outcome unknown; reconcile the saved operation")
}
The tap overload and refundEmv() throw typed readiness errors before returning a Flow and have no tapTimeoutMs argument. Both accept optional metadata.
Tip adjustments
suspend fun adjustTip(transactionId: String, newTipCents: Int): KoardTransaction =
withContext(Dispatchers.IO) {
KoardMerchantSdk.getInstance().adjust(
transactionId = transactionId,
type = AmountType.FIXED,
amount = newTipCents
).getOrThrow()
}
Confirming a surcharge
For a transaction whose status is SURCHARGE_PENDING, pass the cardholder's decision to this backend operation. Use accepted = false to reject the surcharge. cancelTransaction() and resetKernelService() only operate on the reader connection and do not replace this call. See pending amounts and expiry for the 120-second confirmation window and cancellation outcomes.
suspend fun confirmSurcharge(transactionId: String, accepted: Boolean, eventId: String): KoardTransaction =
withContext(Dispatchers.IO) {
KoardMerchantSdk.getInstance().confirm(
transactionId = transactionId, confirm = accepted, eventId = eventId
).getOrThrow()
}
These helpers unwrap Result with getOrThrow() and also propagate SDK exceptions. Catch both at the UI operation boundary, preserving CancellationException. Inspect the returned transaction status and amounts before showing success. Pending and partial outcomes need their own handling. SDK 1.0.7 does not expose eventId on capture or adjust; reconcile the transaction before any further attempt rather than treating those operations as safely replayable.
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: Persist a stable
eventIdfor each operation that exposes it. Android 1.0.7 capture and tip adjustment have no such argument; reconcile uncertain outcomes before any further attempt. - UI threading: Create SDK flows on
Dispatchers.IOand useflowOn(Dispatchers.IO)for their work; collect on Main for UI updates. - 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).

