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.
activateTerminal() also throws NoTidsAvailableForMerchant when the merchant has no unassigned terminal
(TID) left to activate, separately from the ActivateTerminalResult.Failed outcomes above. Wrap the call in a
try-catch if you want to surface this case distinctly, for example by prompting the merchant to free up or
request another TID.
Checking for plugin updates
Accept.plugin.updateInfo() reports whether a newer build of the Tapaya Terminal plugin is available on Google
Play, so you can prompt the merchant to update before a payment hits a version gap.
when (val info = Accept.plugin.updateInfo()) {
PluginUpdateInfo.UpToDate -> { /* nothing to do */ }
is PluginUpdateInfo.UpdateAvailable -> promptUpdate(info.priority) // Int, higher is more urgent
PluginUpdateInfo.Unknown -> { /* not installed, or an older plugin build that predates this check */ }
}Note
The SDK never queries Play directly; the plugin app checks itself via Play Core and reports the result over
its existing status channel. A plugin build older than the one that shipped this feature always reports
Unknown.
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). | |
transactionTimeout | Duration? | Overrides how long the terminal waits for a card tap, for this call only. Defaults to null, which falls back to the default set with Accept.payments.setOptions(). See Setting a transaction timeout. | |
display | PaymentDisplay? | Overrides which screen runs the plugin's payment UI, for this call only. Defaults to null, which falls back to the default set with Accept.payments.setOptions(). See Targeting a display. |
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.
Setting a transaction timeout
transactionTimeout controls how long the Tapaya Terminal plugin waits for the customer to tap their card before
giving up. It accepts any Duration from zero up to 120 seconds; anything outside that range surfaces as
TransactionTimeoutOutOfRange(transactionTimeout) via PaymentEvent.CreationFailed, since pay() never throws
synchronously.
Accept.payments.pay(
amount = 1500,
currency = "CZK",
transactionTimeout = 90.seconds,
)Leave it at the default null to use the companion's own fallback of around 60 seconds, or set a device-wide
default with Accept.payments.setOptions(transactionTimeout = ...).
Targeting a display
On a dual-sided ("double-sided") device, one panel faces the merchant and a second, customer-facing panel faces
the payer. Accept.displays reports what the device has right now, and the display option tells the plugin
which panel to run its payment UI on.
if (Accept.displays.isDualScreen) {
val customerPanel = Accept.displays.customerFacing() // AcceptDisplay?, null on single-screen devices
}
Accept.payments.pay(
amount = 1500,
currency = "CZK",
display = PaymentDisplay.CustomerFacing,
)PaymentDisplay | Description |
|---|---|
Default | The host app's current display (default behavior, unchanged on single-screen devices). |
CustomerFacing | The secondary panel of a dual-sided device. |
Specific(displayId) | An explicit display, by the id reported in Accept.displays.all(). |
A target that can't be honored, for example CustomerFacing on a single-screen device, silently falls back to
Default. Set a device-wide default with Accept.payments.setOptions(display = ...), or override it per call via
pay(..., display = ...).
Set the target before authenticating
The terminal warm-up fired by Accept.auth.authenticate() follows the same target, so call
setOptions(display = ...) before authenticate() if you want the warm-up itself to stay off the merchant's
screen.
The :dualscreen sample app demonstrates a full two-panel checkout: the merchant works the primary panel while
PaymentDisplay.CustomerFacing puts the plugin's payment UI on the customer's screen.
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, receiptDetails) | 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, receiptDetails) | 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 receipt details
Success and Declined carry an optional receiptDetails: ReceiptDetails? with structured card-receipt data
reported by the terminal, for callers that print their own official card receipt instead of relying solely on
PaymentStatus.receiptUrl's hosted page.
| Field | Type | Description |
|---|---|---|
merchantId | String? | Merchant ID reported by the processor. |
terminalId | String? | Terminal ID reported by the processor. |
cardBrand | String? | Card brand, e.g. Visa or Mastercard. |
appLabel | String? | EMV application label. |
aid | String? | EMV application identifier. |
maskedPan | String? | Masked card number. |
responseCode | String? | Processor response code. |
transactionTimestamp | String? | ISO-8601 transaction timestamp. |
Every field is optional and best-effort; a caller must be prepared for any of them, or receiptDetails itself,
to be absent.
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.
Issuing a refund
Refund a settled payment with Accept.payments.refund(paymentToken, reason), which also returns the resulting
PaymentState.
val state: PaymentState = Accept.payments.refund(paymentToken, RefundReason.CUSTOMER_REQUEST)RefundReason is one of CUSTOMER_REQUEST, DUPLICATE, FRAUDULENT, or EXPIRED_CHARGE.
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. |
TransactionTimeoutOutOfRange(transactionTimeout) | transactionTimeout was outside the 0 to 120 second range. | Pass a value within range, or omit it to use the default. |
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(), cancel(), and refund() are suspend functions and throw these exceptions
(rather than emitting events); wrap them in try-catch. status()/cancel()/refund() 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.