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 then enrollDevice() — 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 developerModeEnabled if it’s left on)
  • Observe KoardMerchantSdk.getInstance().readinessState and block the UI unless isReadyForTransactions is true
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 – a KoardReaderStatus enum value: preparing, readyForTap, cardDetected, pinEntryRequested, pinEntryCompleted, readCompleted, readNotCompleted, readCancelled, readRetry, processing, complete, developerModeEnabled, or unknown
  • displayMessage – Reader-provided instructions ("Present card", "Processing", etc.)
  • statusCode / statusCodeDescription – numeric status (and its description) for advanced troubleshooting
  • actionStatusOnProgress, OnFailure, or OnComplete (these are the only three action statuses)
  • finalStatusApprove, Decline, Abort, Failure, AltService, or Unknown after reader completion
  • transaction – 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 (CalledAuthenticationInProgressAttestationInProgressGettingConfigurationsParsingConfigurationsDone); 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 MainScreenViewModel in action
  • See SDK Response Codes for the full reference of final statuses, display messages, and error scenarios
  • Consult KoardPaymentModels.kt for PaymentBreakdown, 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 eventId into sale, 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 readinessState to 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 KoardTransactionResponse carries statusCode, readerStatus, and finalStatus. 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. Call sdk.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 into onResume() / 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 another TECH_DISCOVERED app cannot jump in front of your tap (§3.4.14).