Tapaya Terminal App
Accept contactless card payments by handing the card interaction off to the Tapaya Terminal plugin app.
Take contactless card payments from your Android app by handing the card interaction off to the Tapaya Terminal
plugin app. Your app stays in control of the sale; the plugin handles the NFC tap, PIN entry, and card
processing, then the result flows back through the Accept.payments.pay() event stream.
Use this integration when you want Tap to Pay without embedding a payment processor in your own app. If your platform is enabled for embedded Stripe Tap to Pay instead, see the Embedded Tap to Pay guide.
Integration options
| Option | Best for | Result delivery |
|---|---|---|
| Accept SDK (recommended) | Native Android apps | Accept.payments.pay() emits a Flow<PaymentEvent>: creation, launch, and the terminal result, handled for you |
| Deeplink | WebViews, cross-platform frameworks, QR codes | You create the payment via the Tapaya API and poll its status; the plugin opens via a deeplink |
The SDK path is covered first; see Integrate without the SDK for the deeplink contract.
How it works
- Your app starts a payment with
Accept.payments.pay(amount, currency). - The SDK registers the payment with the Tapaya backend and launches the Tapaya Terminal plugin on the same device.
- The customer taps their card or phone on the Tapaya Terminal screen.
- Tapaya Terminal settles the payment with the Tapaya backend.
- The SDK observes the payment until it reaches a final state and emits
PaymentEvent.Resultcarrying aPayResult. If the payment can't be created at all, it emitsPaymentEvent.CreationFailedinstead, usually within a second.
Prerequisites
- The Tapaya Terminal plugin installed on the device and activated for your merchant (see Install and activate the terminal).
- A device with NFC, running Android 11 (API 30) or later.
- The Accept SDK installed and initialized, with the merchant authenticated.
- Location permission (
ACCESS_FINE_LOCATION) granted at runtime by your app.
Install and activate the terminal
Activate once per device. Activation links the Tapaya Terminal plugin to the same merchant account your app is authenticated with.
if (!Accept.plugin.isInstalled()) {
// Opens the store listing so the user can install Tapaya Terminal
Accept.plugin.install()
return
}
// suspend; launches the plugin's activation flow and waits for the outcome
when (val result = Accept.plugin.activateTerminal()) {
ActivateTerminalResult.Success -> { /* device linked, ready to take payments */ }
ActivateTerminalResult.Canceled -> { /* user backed out */ }
is ActivateTerminalResult.Failed -> result.reason // ActivateTerminalFailureReason
}ActivateTerminalFailureReason is one of INVALID_INTENT, HANDOVER_FAILED, CONFIRM_FAILED,
MERCHANT_NOT_ONBOARDED, or UNKNOWN.
Note
Accept.plugin.isInstalled() returns false when the Tapaya Terminal plugin is missing. Read the plugin's
current state at any time with Accept.plugin.status(), and guard your payment UI accordingly.
Create a payment
Amounts are always in the smallest unit of the currency: hellers for CZK, cents for EUR. 1500 with
currency "CZK" charges 15.00 Kč. Currencies are ISO 4217 codes.
pay() returns a Flow<PaymentEvent>; collect it to observe progress and the terminal outcome. It never throws —
failures surface as PaymentEvent.CreationFailed.
Accept.payments.pay(amount = 1500, currency = "CZK") // 15.00 Kč
.collect { event ->
when (event) {
PaymentEvent.Creating -> { /* registering the payment */ }
is PaymentEvent.Created -> { /* backend payment created: event.paymentToken */ }
PaymentEvent.Launched -> { /* Tapaya Terminal is in the foreground */ }
is PaymentEvent.Result -> when (val r = event.payResult) {
is PayResult.Success -> showReceipt(r.paymentToken, r.authCode)
is PayResult.Declined -> showDeclined(r.metadata)
is PayResult.Canceled -> showCancelled(r.metadata)
is PayResult.Failed -> showUnexpected(r.reason) // PayFailureReason
}
is PaymentEvent.CreationFailed -> handleError(event.cause) // AcceptException
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | Long | ✓ | Amount to charge, in the minor unit of currency (e.g. cents for USD, hellers for CZK). |
currency | String | ✓ | ISO 4217 currency code. |
paymentToken | String? | Resumes an existing payment session instead of starting a new one. Defaults to null. | |
receiptConfig | PaymentReceiptConfig? | Overrides the receipt screen shown after an approved payment, for this call only. Defaults to null, which falls back to the default set with Accept.payments.setOptions(). | |
nfcPosition | NfcPositionConfig? | Overrides where the tap-to-pay animation points, for this call only. Defaults to null, which falls back to the default set with Accept.payments.setOptions(). | |
metadata | Map<String, String> | Key-value data attached to the payment. Sent to the Tapaya Terminal plugin on the PAY intent and echoed back on the resulting PayResult, if the plugin returns it. Defaults to emptyMap(). | |
tip | Long? | Fixed tip, in the minor unit of currency. When set, the plugin skips its own interactive tip screen and charges amount + tip. Defaults to null (plugin prompts the payer for a tip). |
While the payment is in progress the Tapaya Terminal plugin is in the foreground. Your app resumes automatically
when the payment finishes. To resume an interrupted payment, pass its paymentToken back into
pay(amount, currency, paymentToken).
metadata is best-effort
metadata is echoed back on PayResult only if the plugin returns it, so treat it as best-effort context
(e.g. an order ID for your own logging), not as data your app depends on receiving back.
Configuring the receipt screen
receiptConfig controls what the Tapaya Terminal plugin shows the payer after an approved payment, and how
long it stays on screen and whether it prints. It has no effect on declined, reversed, or pending-reversal
outcomes; those always show their own outcome screen regardless of this setting.
PaymentReceiptConfig | Description |
|---|---|
DismissImmediately | Nothing is shown to the payer. |
Show(showQrCodeReceipt, dismissBehavior, printBehavior) | Shows the receipt screen. showQrCodeReceipt: Boolean = true toggles a QR code linking to the receipt. dismissBehavior: DismissBehavior = DismissBehavior.Manual controls how it closes: Manual (stays until the payer dismisses it) or Delayed(delay: Duration) (auto-dismisses after delay if the payer hasn't dismissed it first). printBehavior: ReceiptPrintBehavior = ReceiptPrintBehavior.OnDemand controls the physical receipt: Automatic (prints as soon as the screen appears), OnDemand (only when the cashier triggers it), or Disabled (never prints). |
Set a device-wide default once with Accept.payments.setOptions(), and override it for a single payment via
pay(..., receiptConfig = ...). Until setOptions() is called, the default is Show with a QR receipt, manual
dismiss, and on-demand printing, matching the plugin's own fallback.
// Set a default, e.g. once at app startup
Accept.payments.setOptions(
receiptConfig = PaymentReceiptConfig.Show(
showQrCodeReceipt = true,
dismissBehavior = DismissBehavior.Delayed(30.seconds),
printBehavior = ReceiptPrintBehavior.Automatic,
),
)
// Override it for a single payment
Accept.payments.pay(
amount = 1500,
currency = "CZK",
receiptConfig = PaymentReceiptConfig.DismissImmediately,
)Positioning the NFC tap animation
nfcPosition is a hint telling the Tapaya Terminal plugin where this device's NFC antenna is, so it can place the
"tap here" animation correctly. The plugin resolves antenna position on its own first (live platform detection,
then a bundled device database, then anything the merchant configured directly in the plugin); only provide this
to override that resolution, for example on a device the plugin can't detect or has wrong in its database.
NfcPositionConfig | Description |
|---|---|
Unspecified | No override (default). The plugin resolves the position itself and keeps any position the merchant already configured there. |
OnDevice(surface, xFraction, yFraction) | The antenna is on the device itself; the animation points at (xFraction, yFraction) on surface, either OnDeviceSurface.BACK or OnDeviceSurface.FRONT_SCREEN. |
ExternalAbove | A separate reader module sits above the phone (smart-POS scaffold); there's no on-device point to place. |
Set a device-wide default with Accept.payments.setOptions(nfcPosition = ...), or override it per call via
pay(..., nfcPosition = ...). setOptions() only touches the option(s) you pass, so setOptions(nfcPosition = ...)
alone won't disturb a receiptConfig default set earlier, and vice versa.
Payment outcome
The terminal PaymentEvent.Result carries a PayResult. Every variant carries metadata, echoing back what you
passed to pay() if the plugin returns it (may be null):
PayResult | Meaning |
|---|---|
Success(paymentToken, metadata, authCode) | Authorized and captured. Funds will settle to the merchant. authCode is the processor authorization code reported by the terminal; informational, and may be absent depending on the processor. |
Declined(metadata) | Rejected by the card processor or issuing bank. |
Canceled(metadata) | Cancelled by the customer or merchant before completion. |
Failed(reason, metadata) | Did not complete; reason is a PayFailureReason. |
PayFailureReason is one of INVALID_INTENT, NOT_ACTIVATED, TERMINAL_INIT_FAILED, PAYMENT_SETUP_FAILED,
PLUGIN_UNAVAILABLE, or UNKNOWN.
Reading payment details
PayResult.Success gives you the paymentToken. Fetch the full record — including receiptUrl, requested and
settlement amounts, and timestamps — with Accept.payments.status():
val status: PaymentStatus = Accept.payments.status(paymentToken)
status.state // PENDING, SUCCESS, CANCELLED, REFUNDED, FAILED, ACTION_NEEDED
status.receiptUrl // for your receipt UIStop an in-flight payment with Accept.payments.cancel(paymentToken), which returns the resulting PaymentState.
Error handling
A payment that reaches the terminal but does not succeed is not an exception — it arrives as
PaymentEvent.Result carrying PayResult.Declined, Canceled, or Failed(reason). Failures that stop the
payment from being created arrive as PaymentEvent.CreationFailed(cause), where cause is a typed
AcceptException. The ones specific to this integration:
| Exception | When it happens | What to do |
|---|---|---|
PluginUnavailable | The Tapaya Terminal plugin is not installed or its service could not be bound. | Prompt installation with Accept.plugin.install(). |
CurrencyNotAvailableForMerchant(code) | The merchant is not configured for the requested currency. | Charge in an enabled currency, or update the merchant's configuration. |
AmountBelowMinimum(minimum, currency) | The amount is below the configured minimum. | Charge at or above minimum. |
LocationPermissionRequired | Your app has not been granted location permission. | Request ACCESS_FINE_LOCATION, then retry. |
LocationUnavailable / LocationTimeout | A device location fix could not be resolved. | Ask the user to enable location and retry. |
SessionExpired | The authenticated session expired. | Re-authenticate with Accept.auth.authenticate(). |
NoInternetConnection | No network connectivity. | Ask the user to reconnect and retry. |
PaymentInProgress | A previous pay() call is still awaiting a result. | Wait for the current payment to finish. |
Accept.payments.status() and cancel() are suspend functions and throw these exceptions (rather than
emitting events); wrap them in try-catch. status()/cancel() also throw PaymentNotFound for an unknown
token. See the full list in Error Handling.
Timeouts
A card payment must be completed on the terminal shortly after it starts. If the customer never taps, or the
Tapaya Terminal plugin is closed mid-payment, the payment ends as PayResult.Canceled or
PayResult.Failed, and the underlying payment is cancelled server-side and never settles. Always confirm the
final state with Accept.payments.status() before treating a payment as complete.
Integrate without the SDK
If you can't embed the Accept SDK (a cross-platform framework, a thin POS shell, a web-based till), you can drive Tapaya Terminal directly with a deeplink. The division of work changes: you create the payment through the Tapaya API and you poll its status for the result; Tapaya Terminal only handles the card interaction.
The flow:
- Create a payment from your backend via the Payments API and keep
its
paymentId. - Open Tapaya Terminal with that
paymentIdusing the pay deeplink. - Poll the payment status via the Payments API until it leaves
PENDING. Deeplinks have no return channel — polling is the only source of truth.
Note
Tapaya Terminal must be installed, signed in to the merchant account, and configured for the payment's currency. There is no activation step in this mode; the signed-in account is the merchant of record. Sign a merchant in with the login deeplink if needed.
Deeplink schemes
The scheme selects which build of Tapaya Terminal handles the link:
| Environment | Scheme |
|---|---|
| Production | tapaya-accept |
| Sandbox | tapaya-accept-sandbox |
If a link doesn't resolve, that build of Tapaya Terminal is not installed.
Pay deeplink
tapaya-accept://pay?paymentId=<paymentId>&amount=<amount>¤cy=<ISO4217>| Query parameter | Required | Notes |
|---|---|---|
paymentId | Yes | The payment created in step 1. |
amount | Yes | Amount in major units as a whole number — amount=15 charges 15.00. Note this differs from the SDK, which takes minor units. |
currency | Yes | ISO 4217 currency code, e.g. CZK. |
val uri = Uri.parse("tapaya-accept://pay?paymentId=$paymentId&amount=15¤cy=CZK")
try {
startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
} catch (e: ActivityNotFoundException) {
// Tapaya Terminal is not installed, send the user to Google Play
}Login deeplink
Sign a merchant in to Tapaya Terminal with a merchant token from your backend:
tapaya-accept://login?token=<merchantToken>| Query parameter | Required | Notes |
|---|---|---|
token | Yes | Merchant token identifying the account to sign in. |
activationCode | No | Pre-fills the terminal activation code. |
Test the integration
- Initialize with
isProduction = false(the default); sandbox payments never move real money. - Install the Tapaya Terminal plugin on your test device, sign in with a sandbox merchant, and activate.
- Run a payment for a small amount and verify the
Successflow end to end, including your receipt UI. - Exercise the failure paths: decline a payment on the terminal, cancel one, and start one with an unsupported
currency; your
PayResult/CreationFailedhandling should cover each.
When you're ready to go live, set isProduction = true in initialize and authenticate with production
credentials. The payment code does not change.