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:
- Store the Payment Intent
namewith the sale. You need it for reconciliation. - Return
client_secretto the POS App over HTTPS. - 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
processPaymentfromonCreate(). - 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.
| Event | Use |
|---|---|
payment_intent.succeeded | Confirm the sale. |
charge.failed | Record the failed attempt and read the Payment Intent before offering a retry. |
refund.updated | Track 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 state | Meaning |
|---|---|
SUCCEEDED | The payment succeeded. Reconcile against latest_charge. |
PROCESSING | An attempt is unresolved. Do not create another sale. |
REQUIRES_PAYMENT_METHOD | No attempt yet, or the last attempt failed. Read last_payment_error and the latest charge. |
CANCELED | The 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:
- The creation request timed out. Repeat it with the same idempotency key and body. Do not create a second Payment Intent.
- The payment result is unknown. Call
getPaymentStatuswith the original client secret.PROCESSING, a lost callback, elapsed time, and a read error are not permission to start another payment. - The attempt failed. Read the Payment Intent. If its state is
REQUIRES_PAYMENT_METHODand the last attempt failed, the merchant can callprocessPaymentagain 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:
- Ask Synapto to enable Tap to Pay and supply the production companion app.
- Use
https://api.synaptopay.comand production API keys on your POS Backend. - Initialize the app with the account's
pk_live_key. - Pair again. Sandbox pairings do not work in production.
Updated 22 days ago