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
  • Why only sale and preauth emit reader events
  • How to validate SDK readiness before launching the thin client
  • Handling Flow<KoardTransactionResponse> to power custom UIs
  • Managing surcharge confirmation, tip/tax/surcharge breakdowns, and telemetry
  • Post-reader operations such as capture, 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
  • 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

Only sdk.sale and sdk.preauth interact with the thin client, so they return cold Flow<KoardTransactionResponse> streams. Collect the flow on a worker thread, and feed each event back to the UI.

fun startSale(activity: Activity, amountInCents: Int) {
    val sdk = KoardMerchantSdk.getInstance()
    val readiness = sdk.readinessState.value
    if (!readiness.isReadyForTransactions) {
        Log.w("Checkout", "SDK not ready: ${readiness.getStatusMessage()}")
        return
    }

    val eventId = UUID.randomUUID().toString()

    lifecycleScope.launch(Dispatchers.IO) {
        sdk.sale(
            activity = activity,
            amount = amountInCents,
            breakdown = PaymentBreakdown(subtotal = amountInCents),
            currency = "USD",
            eventId = eventId
        ).collect { response ->
            handleTransactionEvent(response)
        }
    }
}

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 – enumerated statuses like preparing, readyForTap, processing, developerModeEnabled
  • displayMessage – Reader-provided instructions ("Tap card", "Processing", etc.)
  • statusCode – numeric status for advanced troubleshooting
  • actionStatusOnProgress, OnFailure, OnComplete, and surcharge confirmation events
  • finalStatusApprove, Decline, Failure, etc. after reader completion
  • transaction – populated on completion; includes IDs, surcharge state, and breakdown so you can print receipts or prompt for confirmation (e.g., SURCHARGE_PENDING → call sdk.confirm(...))

The demo’s TransactionFlowViewModel (see koard-android-demo/src/main/java/com/koard/android/ui/TransactionFlowViewModel.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. The signature matches sale, the Flow emits the same events, and response.transaction?.transactionId is the ID you’ll pass into capture/refund APIs later.

Post-Reader Operations

All backend-only operations are suspend functions that return Result<KoardTransaction> (or Result<Unit>), so they never emit reader events and can run from any worker thread.

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

suspend fun refundTransaction(transactionId: String, amount: Int? = null) {
    withContext(Dispatchers.IO) {
        val sdk = KoardMerchantSdk.getInstance()
        sdk.refundTransaction(
            transactionId = transactionId,
            amount = amount,
            eventId = UUID.randomUUID().toString()
        ).onSuccess {
            println("Refund successful: ${it.transactionId}")
        }.onFailure {
            println("Refund failed: ${it.message}")
        }
    }
}

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}")
        }
    }
}

Canceling the reader session

If the customer backs out while the reader is active, call sdk.cancelTransaction() from a worker thread to stop the thin client. The method returns Result<Unit> so you can confirm cancellation or surface any error.

Next steps

  • Review the Installing the SDK guide for initialization and enrollment
  • Explore the Demo App to see TransactionFlowViewModel in action
  • Consult KoardPaymentModels.kt for PaymentBreakdown, Surcharge, and other metadata helpers

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() if the user backs out mid-flow to keep the thin client in sync.