Terminal integration flows

Use the Intent API when your POS app runs on the same Android device as the Peach Payments Payment App. Amounts are in minor units (cents). For till-point QR (POS on a separate device), see Till-point QR integration.

Prerequisites

  • Add com.peach:intent_api to your Android POS project - see App-to-app integration.
  • Install the Peach Payments Payment App on the terminal (UAT or production build - APKs on Test your integration).
  • For dedicated POS deployments, ask Peach Payments to enable integrated mode (disables stand-alone numpad payments on the terminal).

Sale flow

The POS app initiates payment; the Payment App handles card processing and returns a result to your app.

  1. Build a Sale with amount (Long, cents) and optional externalPosData.
  2. Call startTransaction - the Payment App opens in the foreground.
  3. The customer completes payment on the terminal.
  4. The Payment App returns a ResultObject JSON payload (Activity result or AIDL callback).
  5. Evaluate success: transactionType is SALE or SALE_WITH_CASHBACK and isApproved == true (typically transactionResult == "approved_confirmed").
  6. Persist transactionId - required for refunds and voids.
  7. If webhooks are configured, your backend receives a separate JSON notification - see Point of sale webhooks (no decryption required).

Integrated POS: Set disableReceiptingOnOutcome(true) so the Payment App auto-returns to your POS after ~10 seconds without a manual dismiss step.

Sale.Builder(context)
    .amount(amountInCents)
    .disableReceiptingOnOutcome(true)
    .setExternalPosData(mapOf("merchantTransactionId" to orderId))
    .setListener(object : SaleTransactionListener {
        override fun alertSaleTransactionResponse(response: PosTransactionSummary) {
            // Check transactionType + isApproved - see success table below
        }
        override fun alertError(error: IntentError) { /* handle */ }
    })
    .startTransaction()

Success checks by operation

CallbackSuccess
SaletransactionType is SALE or SALE_WITH_CASHBACK and isApproved == true
RefundtransactionType == REFUND and isApproved == true
VoidVoidResponse.transactionResult indicates success (no isApproved field)

Refund flow

  1. Call refundTransaction(amount, transactionId) where transactionId is the UUID from the original sale.
  2. The Payment App prompts for supervisor approval (using a 6-digit PIN).
  3. On callback, apply the refund success rule from the table above.
  4. Check isRefundable via Transaction lookup before offering refund in your UI.

There is no separate integrator-facing "reversal" operation - choose void or refund based on isVoidable / isRefundable.

Refund.Builder(context)
    .refundTransaction(amountInCents, originalTransactionId)
    .setListener(refundListener)
    .startTransaction()

Void flow

  1. Call performVoid(transactionId) for the original sale UUID.
  2. The Payment App processes the void.
  3. Evaluate VoidResponse - void uses a separate model without isApproved.

Check isVoidable on the original transaction before exposing void in your POS.

Transaction lookup and recovery flow

Use when your app crashes, the WebView is killed, or you receive PAYMENT_APP_CLOSED_BEFORE_RESPONSE.

  1. On resume, call lastTransaction() or searchTransaction.
  2. Apply the same success rules for the returned transactionType.
  3. Match your order via merchantTransactionId in posData - set this in externalPosData before starting the sale.
🚧

Card authorisation may still complete on the terminal even if your app is killed after handing off to the Payment App.

lastTransaction() behaviour

lastTransaction() returns only the most recent transaction where the customer tapped a card on the terminal. It does not include attempts that failed before card presentment (for example, validation errors, user cancel before tap, or connectivity errors during initiation).

If lookup returns an error (alertError) or you cannot match a successful card-present result to your open order, always treat the payment as unsuccessful in your application. Do not mark the order paid based on lookup alone unless you receive a result that passes the success checks in the table above.

For recovery when lastTransaction() does not cover your scenario, use searchTransaction with filters such as amount, time window, or merchantTransactionId (where supported).

TransactionLookup.Builder(context)
    .lastTransaction()
    .setListener(lookupListener)
    .startTransaction()

Order reference (Dashboard and reconciliation)

Pass your order ID in externalPosData using the merchantTransactionId key.

This appears in the Intent response posData, Peach Payments Dashboard, reconciliation exports, and point of sale webhooks.

You can include additional externalPosData keys (String, Number, Boolean), but Peach Payments does not include them in standard reconciliation files.

Reconciliation flow

Your POS does not trigger settlement or batch close - Peach Payments handles daily settlement.

ChannelFormatHow to access
DashboardCSVDashboard reporting
Reconciliation APIJSON (/transactions-recon)Reconciliation API
Point of sale webhooksJSON (pushed)Request documentation from support

Match payments to orders using merchantTransactionId in Intent posData, Dashboard exports, reconciliation API responses, and webhook transaction.posData.

Duplicate submissions

The Intent API does not reject a second sale when you reuse the same merchantTransactionId. Implement idempotency in your POS: do not call startTransaction twice for the same open order; use lookup before retrying.

Sample response (successful sale)

{
  "finalResultStatus": "CODE_SUCCESS",
  "receiveAction": "SaleIntent",
  "transaction": {
    "amount": 1000,
    "currencyCode": "ZAR",
    "transactionId": "358f45f6-3818-4e3f-9af8-9cc3eca8f140",
    "isApproved": true,
    "transactionResult": "approved_confirmed",
    "transactionType": "SALE",
    "rrn": "340368496951",
    "posData": {
      "merchantTransactionId": "ORDER-12345"
    }
  }
}

Test and go-live

StepAction
1(Optional) Install Mock Payments App on any Android device - no UAT account or Maven token required
2Install UAT Payment App on a Sunmi terminal; log in with UAT account credentials from Peach Payments
3Run test matrix (sale, decline, refund, void, recovery) - see Test your integration
4Publish your POS APK via Sunmi (public upload); Peach Payments enables it on merchant terminals
📘

UAT rule: Only round amounts (for example, R 100.00) succeed as live approvals on UAT terminals. Non-round amounts decline - use this to test decline handling. For Intent errors, see Intent error codes.

Error handling

Flow-level errors return via alertError(IntentError) on your listener. For the full code list and descriptions, see Intent error codes.

Common recovery-related codes:

CodeMeaningAction
PAYMENT_APP_CLOSED_BEFORE_RESPONSEPayment App closed before callbackRun transaction lookup; treat as unsuccessful unless lookup returns a passing success result
PAYMENT_APP_NOT_INSTALLEDPayment App missing on deviceInstall UAT or production Payment App
INVALID_AMOUNTAmount ≤ 0 or over limitFix amount before retry

Lookup errors: If alertError fires on a lookup call, treat the payment as unsuccessful in your POS.