Tapaya
Integration GuideTapaya Terminal App

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

OptionBest forResult delivery
Accept SDK (recommended)Native Android appsAccept.payments.pay() emits a Flow<PaymentEvent>: creation, launch, and the terminal result, handled for you
DeeplinkWebViews, cross-platform frameworks, QR codesYou 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

  1. Your app starts a payment with Accept.payments.pay(amount, currency).
  2. The SDK registers the payment with the Tapaya backend and launches the Tapaya Terminal plugin on the same device.
  3. The customer taps their card or phone on the Tapaya Terminal screen.
  4. Tapaya Terminal settles the payment with the Tapaya backend.
  5. The SDK observes the payment until it reaches a final state and emits PaymentEvent.Result carrying a PayResult. If the payment can't be created at all, it emits PaymentEvent.CreationFailed instead, usually within a second.

Prerequisites

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:

ParameterTypeRequiredDescription
amountLongAmount to charge, in the minor unit of currency (e.g. cents for USD, hellers for CZK).
currencyStringISO 4217 currency code.
paymentTokenString?Resumes an existing payment session instead of starting a new one. Defaults to null.
receiptConfigPaymentReceiptConfig?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().
nfcPositionNfcPositionConfig?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().
metadataMap<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().
tipLong?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.

PaymentReceiptConfigDescription
DismissImmediatelyNothing 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.

NfcPositionConfigDescription
UnspecifiedNo 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.
ExternalAboveA 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):

PayResultMeaning
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 UI

Stop 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:

ExceptionWhen it happensWhat to do
PluginUnavailableThe 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.
LocationPermissionRequiredYour app has not been granted location permission.Request ACCESS_FINE_LOCATION, then retry.
LocationUnavailable / LocationTimeoutA device location fix could not be resolved.Ask the user to enable location and retry.
SessionExpiredThe authenticated session expired.Re-authenticate with Accept.auth.authenticate().
NoInternetConnectionNo network connectivity.Ask the user to reconnect and retry.
PaymentInProgressA 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:

  1. Create a payment from your backend via the Payments API and keep its paymentId.
  2. Open Tapaya Terminal with that paymentId using the pay deeplink.
  3. 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.

The scheme selects which build of Tapaya Terminal handles the link:

EnvironmentScheme
Productiontapaya-accept
Sandboxtapaya-accept-sandbox

If a link doesn't resolve, that build of Tapaya Terminal is not installed.

tapaya-accept://pay?paymentId=<paymentId>&amount=<amount>&currency=<ISO4217>
Query parameterRequiredNotes
paymentIdYesThe payment created in step 1.
amountYesAmount in major units as a whole number — amount=15 charges 15.00. Note this differs from the SDK, which takes minor units.
currencyYesISO 4217 currency code, e.g. CZK.
val uri = Uri.parse("tapaya-accept://pay?paymentId=$paymentId&amount=15&currency=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
}

Sign a merchant in to Tapaya Terminal with a merchant token from your backend:

tapaya-accept://login?token=<merchantToken>
Query parameterRequiredNotes
tokenYesMerchant token identifying the account to sign in.
activationCodeNoPre-fills the terminal activation code.

Test the integration

  1. Initialize with isProduction = false (the default); sandbox payments never move real money.
  2. Install the Tapaya Terminal plugin on your test device, sign in with a sandbox merchant, and activate.
  3. Run a payment for a small amount and verify the Success flow end to end, including your receipt UI.
  4. Exercise the failure paths: decline a payment on the terminal, cancel one, and start one with an unsupported currency; your PayResult/CreationFailed handling 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.

On this page