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), then sdk.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 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 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 – 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
  • actionStatus – OnProgress, OnFailure, or OnComplete (these are the only three action statuses)
  • finalStatus – Approve, 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 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 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: Persist a stable eventId for 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.IO and use flowOn(Dispatchers.IO) for their work; collect on Main for UI updates.
  • 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).