Tap to Pay Payments

Tap to Pay Payments

Take a card-present payment on the merchant's phone. Your POS Backend creates a Payment Intent. Your POS App passes its client secret to the Android POS SDK, which opens the companion app to accept the card. The terms POS App, POS Backend, and companion app are defined in Tap to Pay Setup, which you must complete first.

1. Create the Payment Intent (server)

CreatePaymentIntent API reference

Call this endpoint with a secret API key that has payments.write access to the account. The account must have an active Tap to Pay payment rail that supports the currency. Set payment_method_types to TAP_TO_PAY so the intent cannot be confirmed through another payment method.

curl -X POST https://api.synaptopay-sandbox.com/v1/accounts/acct_YOUR_ACCOUNT/payment-intents \
  -H "Authorization: Api-Key $API_KEY" \
  -H "Idempotency-Key: YOUR_SALE_CREATION_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "1000",
    "currency": "GBP",
    "payment_method_types": ["TAP_TO_PAY"]
  }'

The amount is in minor units: 1000 is GBP 10.00. Relevant response fields:

{
  "name": "accounts/acct_YOUR_ACCOUNT/payment-intents/pi_PAYMENT_INTENT_ID",
  "amount": "1000",
  "currency": "GBP",
  "payment_method_types": ["TAP_TO_PAY"],
  "state": "REQUIRES_PAYMENT_METHOD",
  "client_secret": "pi_PAYMENT_INTENT_ID_secret_SECRET"
}

Do not set confirm, device, payment_method, or setup_future_usage. The SDK confirms the intent, Synapto selects the paired terminal, and Tap to Pay does not save a reusable payment method.

On your POS Backend:

  1. Store the Payment Intent name with the sale. You need it for reconciliation.
  2. Return client_secret to the POS App over HTTPS.
  3. Let the app retrieve the sale and its client secret again after a restart.

Treat the client secret as opaque. Do not parse it, put it in URLs, logs, analytics, or saved instance state, or give it to another account. The SDK does not store it.

2. Process the payment (Android)

Let any readiness check or pairing wait finish before starting a payment. Disable the Pay action while a payment is active or a sale is unresolved. As in the setup guide, keep these fields and functions in an object that outlives the Activity and call them on the main thread.

private var paymentOperation: OperationHandle? = null
private var paymentStatusOperation: OperationHandle? = null
private var latestPaymentResult: PaymentResult? = null

fun takePayment(clientSecret: String) {
    if (paymentOperation != null || paymentStatusOperation != null) return
    try {
        latestPaymentResult = null
        paymentOperation = SynaptoPos.processPayment(clientSecret) { result ->
            paymentOperation = null
            handlePaymentResult(result)
        }
    } catch (error: IllegalArgumentException) {
        showIntegrationError()
    } catch (error: IllegalStateException) {
        showIntegrationError()
    }
}

fun handlePaymentResult(result: PaymentResult) {
    latestPaymentResult = result
    if (!result.hasStatus()) {
        showPaymentError(checkNotNull(result.error))
        return
    }
    when (checkNotNull(result.status)) {
        PaymentStatus.SUCCEEDED -> showApproved()
        PaymentStatus.DECLINED -> showDeclined()
        PaymentStatus.CANCELED -> showCanceled()
        PaymentStatus.FAILED -> showFailed()
        PaymentStatus.PROCESSING -> showPaymentPending()
    }
}
private OperationHandle paymentOperation;
private OperationHandle paymentStatusOperation;
private PaymentResult latestPaymentResult;

void takePayment(String clientSecret) {
    if (paymentOperation != null || paymentStatusOperation != null) return;
    try {
        latestPaymentResult = null;
        paymentOperation = SynaptoPos.processPayment(clientSecret, result -> {
            paymentOperation = null;
            handlePaymentResult(result);
        });
    } catch (IllegalArgumentException | IllegalStateException error) {
        showIntegrationError();
    }
}

void handlePaymentResult(PaymentResult result) {
    latestPaymentResult = result;
    if (!result.hasStatus()) {
        showPaymentError(result.getError());
        return;
    }
    switch (result.getStatus()) {
        case SUCCEEDED: showApproved(); break;
        case DECLINED: showDeclined(); break;
        case CANCELED: showCanceled(); break;
        case FAILED: showFailed(); break;
        case PROCESSING: showPaymentPending(); break;
    }
}

The handle guards only prevent overlapping payment starts and status reads in this process. Your sale state must also prevent a new payment after a PROCESSING result or an app restart.

showPaymentPending() keeps the sale open and offers or schedules recovery. showPaymentError() follows the PaymentError table. Neither re-enables Pay as if nothing had been attempted.

A completion screen in the companion app is not a result. Use only the SDK result and your server's reconciliation.

3. Recover an unresolved payment (Android)

Call getPaymentStatus with the same client secret after a PROCESSING result, after closing a payment handle, or after process death. After a restart, restore the signed-in account and fetch the sale's client secret from your POS Backend first.

The examples allow only one payment start or status read at a time. This prevents their callbacks from competing to update the result.

fun recoverPayment(clientSecret: String) {
    if (paymentOperation != null || paymentStatusOperation != null) return
    try {
        paymentStatusOperation = SynaptoPos.getPaymentStatus(clientSecret) { result ->
            paymentStatusOperation = null
            handlePaymentResult(result)
        }
    } catch (error: IllegalArgumentException) {
        showIntegrationError()
    } catch (error: IllegalStateException) {
        showIntegrationError()
    }
}
void recoverPayment(String clientSecret) {
    if (paymentOperation != null || paymentStatusOperation != null) return;
    try {
        paymentStatusOperation = SynaptoPos.getPaymentStatus(clientSecret, result -> {
            paymentStatusOperation = null;
            handlePaymentResult(result);
        });
    } catch (IllegalArgumentException | IllegalStateException error) {
        showIntegrationError();
    }
}

For PROCESSING, read again after a delay, or offer a Check status action. Increase the delay between reads. Stop when a final status arrives or the merchant leaves the sale. An error leaves the sale unresolved.

If the status stays PROCESSING, keep the sale open and contact Synapto with the Payment Intent name. Do not clear app data and do not create a replacement Payment Intent.

Lifecycle

  • When the companion app opens, your Activity pauses. Keep the payment handle.
  • When the Activity is recreated, attach the new screen to the stored result. Do not call processPayment from onCreate().
  • When the merchant leaves the checkout, close the handles and cancel your scheduled reads:
fun stopObservingPayment() {
    paymentOperation?.close()
    paymentOperation = null
    paymentStatusOperation?.close()
    paymentStatusOperation = null
}
void stopObservingPayment() {
    if (paymentOperation != null) {
        paymentOperation.close();
        paymentOperation = null;
    }
    if (paymentStatusOperation != null) {
        paymentStatusOperation.close();
        paymentStatusOperation = null;
    }
}

Closing does not cancel the payment. Keep the sale open on your POS Backend and recover it later. If the merchant signs in to another account, follow Account switching.

4. Confirm the sale on your server

Do not fulfil a sale on the POS App's word alone. Confirm it through webhooks or by reading the Payment Intent.

Subscribe to these events. See Collect Webhooks for signature verification and delivery.

EventUse
payment_intent.succeededConfirm the sale.
charge.failedRecord the failed attempt and read the Payment Intent before offering a retry.
refund.updatedTrack reversals; see Refunds and receipts.

GetPaymentIntent API reference

With payments.read access:

curl https://api.synaptopay-sandbox.com/v1/accounts/acct_YOUR_ACCOUNT/payment-intents/pi_PAYMENT_INTENT_ID \
  -H "Authorization: Api-Key $API_KEY"

GetPaymentIntent returns what Synapto has recorded. It does not query Softpay. Only the SDK's status reads and Softpay's events update the result, so server-side polling does not replace the Android recovery step.

Payment Intent stateMeaning
SUCCEEDEDThe payment succeeded. Reconcile against latest_charge.
PROCESSINGAn attempt is unresolved. Do not create another sale.
REQUIRES_PAYMENT_METHODNo attempt yet, or the last attempt failed. Read last_payment_error and the latest charge.
CANCELEDThe intent is canceled and cannot be retried.

To see every attempt, read the latest charge or call ListCharges filtered by payment_intent. Make fulfilment idempotent: a webhook and a read can both report success.

Safe retries

Keep these three cases apart:

  1. The creation request timed out. Repeat it with the same idempotency key and body. Do not create a second Payment Intent.
  2. The payment result is unknown. Call getPaymentStatus with the original client secret. PROCESSING, a lost callback, elapsed time, and a read error are not permission to start another payment.
  3. The attempt failed. Read the Payment Intent. If its state is REQUIRES_PAYMENT_METHOD and the last attempt failed, the merchant can call processPayment again with the same client secret. Synapto creates a new charge. Do this only on the merchant's action, never from an error callback.

The SDK status CANCELED can mean a canceled attempt or a canceled Payment Intent. Read the Payment Intent before retrying.

Refunds and receipts

CreateRefund does not support Tap to Pay charges. Do not apply the Refunds guide to them. Contact Synapto before production to agree how to handle refund requests. A reversal by the provider after a successful payment appears as a Refund and updates the charge's refunded amount, so keep those records in reconciliation.

The SDK returns payment status only, not receipts or card details. Decide how your integration gives the customer a receipt and verify it on a supported device.

Sandbox and production

Test on a supported physical device with the sandbox companion app. Ask Synapto for the Tap to Pay test procedure. The card numbers and decline rules in the Testing guide do not apply.

Before rollout, verify on a device:

  • A clean install of the SDK from the bundle Synapto supplied, including a release build.
  • Each readiness status and error, pairing success and failure, and a lost pairing-creation response.
  • Activity recreation and process death before and after the companion app opens.
  • Each payment status, and server reconciliation for each.
  • Callback loss, network loss, and process death during a payment, recovered from the original sale. None may start a second payment.
  • Receipts, webhooks, and the agreed handling of reversals and refund requests.
  • No token or client secret in logs, analytics, URLs, or saved instance state.

For production:

  1. Ask Synapto to enable Tap to Pay and supply the production companion app.
  2. Use https://api.synaptopay.com and production API keys on your POS Backend.
  3. Initialize the app with the account's pk_live_ key.
  4. Pair again. Sandbox pairings do not work in production.

Did this page help you?