Skip to content

Continue flow

Display an order review page before capturing payment.

Overview

The continue flow is a two-step checkout experience that allows customers to review their order details after PayPal approval but before final payment submission. This flow is ideal for e-commerce checkouts requiring shipping confirmation, complex orders needing customer review, or compliance requirements mandating order confirmation.

To enable the continue flow, you must set userAction = UserAction.Continue in the component configuration. Without this setting, the button defaults to the pay now flow.

Payment flow

The PayPal continue flow consists of six key steps with approval and capture separation.

Step 1: Submission

The customer taps the PayPal button, which triggers the payment flow. The SDK validates the PayPal configuration and transaction data before proceeding to order creation. If validation fails, onError is triggered.

Step 2: Order creation

The SDK creates a PayPal order using the provided transaction details. This involves sending the payment amount, currency, merchant information, shipping options, and any additional order details to PayPal's API. If order creation fails, onError is triggered.

Step 3: PayPal approval

The customer is redirected to PayPal checkout where they can log in, select their payment method, and optionally choose a shipping address.

When the customer taps the PayPal button (with userAction set to Continue), the PayPal authentication flow opens and the customer enters their credentials.

After login, the customer is requested to review the order. The text shown in the PayPal user interface may vary; sometimes it's displayed as "Continue to Review Order".

This step has five associated callbacks:

  • onApprove: Retrieves order details and navigates to the review page if the customer successfully approves the order.
  • onCancel: Cancels the transaction if the customer cancels in PayPal.
  • onError: Receives error data if any error occurs during the approval process.
  • onShippingAddressChange: Validates shipping addresses as the customer changes them in PayPal.
  • onShippingOptionsChange: Handles shipping option changes made by the customer.

PayPal handles all authentication and payment method selection within their secure environment.

Step 4: Return to merchant

After PayPal approval, the customer is returned to your app with the approved order. The customer can review their order details including contact information, shipping address, and order summary before final submission.

Step 5: Order review

On the review screen, the customer can see:

  • Contact information: Name, email, and phone number from PayPal
  • Shipping address: Full delivery address selected in PayPal
  • Order summary: Itemised breakdown of subtotal, shipping, tax, and total

The customer can either submit the order to complete their payment or cancel and return to the checkout screen.

Step 6: Payment capture

Once the customer confirms their order on the review screen, you capture the payment. This is when the funds are actually transferred from the customer's account.

Your capture API call will return a success or failure response, which you handle in your backend code.

Implementation

Before you start

To use PayPal continue flow in your Android application:

  1. Ensure you have a valid PayPal Business account with API credentials.
  2. Configure your PayPal merchant account to accept the currencies you need.
  3. Set up your merchant configuration in the Unity Portal (API credentials, payment methods, risk settings).
  4. Implement a review screen to display order details.
  5. Create a backend endpoint to capture payments after customer confirmation.

Step 1: Configure your SDK

Set up your PxpCheckout instance with transaction information for PayPal payments.

val transactionData = TransactionData(
    amount = 2500.0,
    currency = "USD",
    merchant = "your-merchant-id",
    entryType = EntryType.Ecom,
    intent = TransactionIntentData(
        paypal = IntentType.Authorisation
    ),
    merchantTransactionId = "order-${System.currentTimeMillis()}",
    merchantTransactionDate = { Clock.System.now().toString() },
    cardAcceptorName = "Your Store Name"
)

val sdkConfig = PxpSdkConfig(
    environment = Environment.TEST, // or Environment.LIVE for production
    session = SessionConfig(
        sessionId = "your-session-id",
        hmacKey = "your-hmac-key",
        data = "your-session-data",
        encryptionKey = "your-encryption-key",
        locale = "en-US"
    ),
    transactionData = transactionData,
    clientId = "your-client-id",
    ownerId = "your-owner-id",
    ownerType = "MerchantGroup",
    onGetShopper = {
        // Return current shopper data dynamically
        Shopper(
            id = "shopper-123"
        )
    },
    onGetShippingAddress = {
        // Return current shipping address when needed
        ShippingAddress(
            addressLine1 = "123 Main Street",
            addressLine2 = "Apt 1",
            city = "New York",
            county = "NY",
            postalCode = "10001",
            countryCode = "US"
        )
    }
)

val pxpCheckout = PxpCheckout.builder()
    .withConfig(sdkConfig)
    .withContext(context)
    .build()

Step 2: Create the continue button

Create the PayPal button with the continue flow configuration. The userAction = UserAction.Continue setting is required to enable the continue flow.

Android PayPal callbacks receive JSON strings, not typed objects. You'll need to parse the JSON data in your callbacks. The onSuccess callback receives a JSON string containing the order approval data.

import com.pxp.checkout.components.paypal.types.UserAction
import com.pxp.checkout.components.paypal.types.ShippingPreference
import org.json.JSONObject

val paypalConfig = PayPalComponentConfig().apply {
    // Required PayPal configuration
    renderType = "setOfButtons"
    payeeEmailAddress = "merchant@example.com"
    paymentDescription = "Order Payment"
    
    // REQUIRED: Enable continue flow (two-step checkout)
    userAction = UserAction.Continue.toString()
    
    // Shipping configuration
    shippingPreference = ShippingPreference.GetFromFile.toString()
    shippingOptions = listOf(
        ShippingOption(
            id = "1",
            label = "Standard Shipping",
            selected = true,
            amounts = ShippingAmounts(
                currencyCode = "USD",
                shipping = 10.00
            ),
            type = "Shipping"
        ),
        ShippingOption(
            id = "2",
            label = "Express Shipping",
            selected = false,
            amounts = ShippingAmounts(
                currencyCode = "USD",
                shipping = 15.00
            ),
            type = "Shipping"
        )
    )
    
    // Display options
    locale = "en-US"
    fundingSources = listOf("paypal")
    
    // Styling
    style = PayPalButtonStyle(
        layout = "vertical",
        color = "gold",
        shape = "rect",
        label = "paypal",
        tagline = false,
        height = 55
    )

    // REQUIRED: Handle order approval
    onSuccess = { dataJson ->
        Log.d("PayPal", "PayPal order approved: $dataJson")
        
        try {
            // Parse the JSON response and navigate to review screen
            val orderData = parsePayPalResponse(dataJson)
            navigateToReviewScreen(orderData)
        } catch (e: Exception) {
            Log.e("PayPal", "Error parsing order details", e)
            showError("Unable to retrieve order details. Please try again.")
        }
    }

    // REQUIRED: Handle payment errors
    onError = { error ->
        Log.e("PayPal", "PayPal payment error", error)
        showError("Payment failed. Please try again.")
    }

    // OPTIONAL: Handle payment cancellation
    onCancel = {
        Log.d("PayPal", "PayPal payment cancelled")
        showMessage("Payment was cancelled. You can try again anytime.")
    }
    
    // OPTIONAL: Validate shipping address changes
    onShippingAddressChange = { dataJson ->
        Log.d("PayPal", "Shipping address changed: $dataJson")
        
        try {
            // Parse the JSON data to check country code
            val jsonObject = JSONObject(dataJson)
            val shippingAddress = jsonObject.optJSONObject("shippingAddress")
            
            if (shippingAddress != null) {
                val countryCode = shippingAddress.optString("countryCode", "")
                
                // Validate country
                if (countryCode != "US") {
                    // Return reject action with error code
                    return@onShippingAddressChange "reject:COUNTRY_ERROR"
                }
            }
        } catch (e: Exception) {
            Log.e("PayPal", "Error parsing shipping address", e)
        }
        
        // Return null to allow the change
        null
    }
    
    // OPTIONAL: Handle shipping option changes
    onShippingOptionsChange = { dataJson ->
        Log.d("PayPal", "Shipping option changed: $dataJson")
        null
    }
}

// Create the component
val paypalComponent = pxpCheckout.createComponent<PayPalComponent, PayPalComponentConfig>(
    type = ComponentType.PAYPAL,
    config = paypalConfig
)

Step 3: Implement the review screen

Create a review screen composable to display order details after PayPal approval.

@Composable
fun ReviewOrderScreen(
    orderData: OrderData,
    onSubmit: (OrderData) -> Unit,
    onCancel: () -> Unit
) {
    val payer = orderData.payer
    val purchaseUnit = orderData.purchaseUnits.first()
    val shipping = purchaseUnit.shipping
    val amount = purchaseUnit.amount

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
            .verticalScroll(rememberScrollState())
    ) {
        Text(
            text = "Review your order",
            style = MaterialTheme.typography.headlineMedium,
            modifier = Modifier.padding(bottom = 8.dp)
        )
        
        Text(
            text = "Please review your order details before completing the purchase",
            style = MaterialTheme.typography.bodyMedium,
            modifier = Modifier.padding(bottom = 24.dp)
        )

        // Contact information
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp)
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Text(
                    text = "Contact information",
                    style = MaterialTheme.typography.titleMedium,
                    modifier = Modifier.padding(bottom = 8.dp)
                )
                Text("Name: ${payer.name.givenName} ${payer.name.surname}")
                Text("Email: ${payer.emailAddress}")
                payer.phone?.phoneNumber?.nationalNumber?.let {
                    Text("Phone: $it")
                }
            }
        }

        // Shipping address
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp)
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Text(
                    text = "Delivering to",
                    style = MaterialTheme.typography.titleMedium,
                    modifier = Modifier.padding(bottom = 8.dp)
                )
                Text(
                    text = shipping.name.fullName,
                    fontWeight = FontWeight.Bold
                )
                Text(shipping.address.addressLine1)
                shipping.address.addressLine2?.let { Text(it) }
                Text("${shipping.address.adminArea2}, ${shipping.address.adminArea1} ${shipping.address.postalCode}")
                Text(shipping.address.countryCode)
            }
        }

        // Order summary
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp)
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Text(
                    text = "Order summary",
                    style = MaterialTheme.typography.titleMedium,
                    modifier = Modifier.padding(bottom = 8.dp)
                )
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    Text("Subtotal:")
                    Text("${amount.currencyCode} ${amount.breakdown.itemTotal.value}")
                }
                amount.breakdown.shipping?.value?.let {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween
                    ) {
                        Text("Delivery:")
                        Text("${amount.currencyCode} $it")
                    }
                }
                amount.breakdown.taxTotal?.value?.let {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        horizontalArrangement = Arrangement.SpaceBetween
                    ) {
                        Text("Tax:")
                        Text("${amount.currencyCode} $it")
                    }
                }
                HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    Text(
                        text = "Total:",
                        fontWeight = FontWeight.Bold
                    )
                    Text(
                        text = "${amount.currencyCode} ${amount.value}",
                        fontWeight = FontWeight.Bold
                    )
                }
            }
        }

        // Actions
        Text(
            text = "Order ID: ${orderData.orderId}",
            style = MaterialTheme.typography.bodySmall,
            modifier = Modifier.padding(bottom = 16.dp)
        )
        
        Button(
            onClick = { onSubmit(orderData) },
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 8.dp)
        ) {
            Text("Submit Order & Pay Now")
        }
        
        OutlinedButton(
            onClick = onCancel,
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Cancel and go back")
        }
    }
}

Step 4: Handle order submission

Implement the backend capture logic when the customer submits the order from the review screen.

// In your ViewModel or Repository
suspend fun handleSubmitOrder(orderData: OrderData): Result<CaptureResponse> {
    return withContext(Dispatchers.IO) {
        try {
            // Call backend to capture the PayPal order
            val response = apiService.completeOrder(
                orderId = orderData.orderId,
                sessionId = sessionData.sessionId,
                merchantTransactionId = transactionData.merchantTransactionId
            )
            
            if (response.isSuccessful) {
                Log.d("PayPal", "Payment captured successfully")
                Result.success(response.body()!!)
            } else {
                Result.failure(Exception(response.errorBody()?.string() ?: "Payment capture failed"))
            }
        } catch (e: Exception) {
            Log.e("PayPal", "Order submission error", e)
            Result.failure(e)
        }
    }
}

// Backend endpoint (example using Ktor)
suspend fun completePaypalOrder(call: ApplicationCall) {
    val request = call.receive<CompleteOrderRequest>()
    
    try {
        // Call the PXP API to capture the PayPal order
        val response = pxpApiClient.post("api/v1/paypal/orders/${request.orderId}/capture") {
            contentType(ContentType.Application.Json)
            setBody(
                mapOf(
                    "sessionId" to request.sessionId,
                    "merchantTransactionId" to request.merchantTransactionId
                )
            )
        }
        
        call.respond(
            HttpStatusCode.OK,
            mapOf(
                "success" to true,
                "data" to response.body<Map<String, Any>>(),
                "systemTransactionId" to response.body<Map<String, Any>>()["systemTransactionId"],
                "providerTransactionId" to response.body<Map<String, Any>>()["providerTransactionId"]
            )
        )
    } catch (e: Exception) {
        call.respond(
            HttpStatusCode.InternalServerError,
            mapOf(
                "success" to false,
                "error" to "Failed to complete order",
                "message" to e.message
            )
        )
    }
}

Step 5: Handle common scenarios

Shipping address validation

Validate shipping addresses as customers change them in PayPal.

val paypalConfig = PayPalComponentConfig().apply {
    userAction = UserAction.Continue.toString()
    
    onShippingAddressChange = { dataJson ->
        Log.d("PayPal", "Shipping address changed: $dataJson")
        
        try {
            // Parse the JSON data to check shipping address
            val jsonObject = JSONObject(dataJson)
            val shippingAddress = jsonObject.optJSONObject("shippingAddress")
            
            if (shippingAddress != null) {
                val countryCode = shippingAddress.optString("countryCode", "")
                val state = shippingAddress.optString("state", "")
                val postalCode = shippingAddress.optString("postalCode", "")
                
                // Validate country
                val allowedCountries = listOf("US", "CA")
                if (!allowedCountries.contains(countryCode)) {
                    return@onShippingAddressChange "reject:COUNTRY_ERROR"
                }
                
                // Validate state for US addresses
                if (countryCode == "US") {
                    val restrictedStates = listOf("AK", "HI") // Alaska, Hawaii
                    if (restrictedStates.contains(state)) {
                        return@onShippingAddressChange "reject:STATE_ERROR"
                    }
                }
                
                // Validate postal code format
                if (countryCode == "US") {
                    val postalCodeRegex = Regex("""^\d{5}(-\d{4})?$""")
                    if (!postalCodeRegex.matches(postalCode)) {
                        return@onShippingAddressChange "reject:POSTAL_CODE_ERROR"
                    }
                }
            }
        } catch (e: Exception) {
            Log.e("PayPal", "Error parsing shipping address", e)
        }
        
        // Return null to allow the change
        null
    }
}

Review screen cancellation

Handle when customers cancel from the review screen.

fun handleCancelReview(navController: NavController) {
    Log.d("PayPal", "Customer cancelled from review screen")
    
    // Log analytics event
    analytics.track("review_page_cancelled", mapOf(
        "order_id" to orderData.orderId,
        "stage" to "review"
    ))
    
    // Clear order data
    viewModel.clearOrderData()
    
    // Return to checkout screen
    navController.popBackStack()
    
    // Show message
    Toast.makeText(context, "Order cancelled. You can start a new checkout anytime.", Toast.LENGTH_SHORT).show()
}

Step 6: Handle errors

Implement comprehensive error handling for PayPal continue flow.

val paypalConfig = PayPalComponentConfig().apply {
    userAction = UserAction.Continue.toString()
    
    onError = { errorJson ->
        Log.e("PayPal", "PayPal error: $errorJson")
        
        // Parse error from JSON string
        val error = parsePayPalError(errorJson)
        
        // Handle specific PayPal error types
        val errorMessage = when (error.name) {
            "VALIDATION_ERROR" -> "Payment details validation failed. Please try again."
            "INSTRUMENT_DECLINED" -> "Your PayPal payment method was declined. Please try a different method."
            "PAYER_ACTION_REQUIRED" -> "Additional action required in PayPal. Please complete the payment process."
            "UNPROCESSABLE_ENTITY" -> "Payment cannot be processed. Please contact support."
            else -> "Payment failed. Please try again or contact support."
        }
        
        showError(errorMessage)
    }

    onSuccess = { dataJson ->
        try {
            val orderData = parsePayPalResponse(dataJson)
            navigateToReviewScreen(orderData)
        } catch (e: Exception) {
            Log.e("PayPal", "Failed to retrieve order details", e)
            
            val errorMessage = when {
                e is HttpException && e.code() == 404 -> "Order not found. Please start a new checkout."
                e is HttpException && e.code() == 422 -> "Order details could not be retrieved. Please try again."
                e is HttpException && e.code() >= 500 -> "PayPal service temporarily unavailable. Please try again later."
                else -> "Unable to retrieve order details. Please try again."
            }
            
            showError(errorMessage)
        }
    }
}

// Handle order submission errors
suspend fun handleSubmitOrder(orderData: OrderData) {
    try {
        val result = capturePayment(orderData)
        // Handle success
    } catch (e: Exception) {
        Log.e("PayPal", "Payment capture error", e)
        
        val errorMessage = when {
            e is HttpException && e.code() == 422 -> "Payment details could not be verified. Please try again."
            e is HttpException && e.code() == 409 -> "This payment has already been processed."
            e is HttpException && e.code() >= 500 -> "Payment system temporarily unavailable. Please try again later."
            else -> "Payment processing failed. Please try again."
        }
        
        showError(errorMessage)
    }
}

Webhook integration

Unity sends PayPal transaction approved webhooks when PayPal orders are approved, providing complete payer details and shipping information.

{
  "eventCategory": "transaction",
  "eventDate": "2026-03-17T09:50:05.3525590Z",
  "eventData": {
    "state": "Approved",
    "amounts": {
      "transaction": 10,
      "currencyCode": "USD"
    },
    "merchant": "merchant-1",
    "site": "site-1",
    "transactionMethod": {
      "intent": "Create",
      "fundingType": "Paypal",
      "entryType": "Ecom"
    },
    "shippingAddress": {
      "addressLine1": "123 Main St",
      "postalCode": "95131",
      "countryCode": "US",
      "city": "San Francisco",
      "county": "CA"
    },
    "merchantTransactionDate": "2026-03-06T02:53:12",
    "merchantTransactionId": "99ECC73F-A9A9-44E2-9821-4F4797B3145D",
    "systemTransactionId": "c3898060-431d-4829-bcde-049edcee70ae",
    "providerTransactionId": "0DC51351HB1980105",
    "fundingData": {
      "paypal": {
        "payeeEmailAddress": "merchant@example.com",
        "paymentSource": "Paypal"
      }
    },
    "payerDetails": {
      "emailAddress": "john.doe@example.com",
      "givenName": "John",
      "surName": "Doe",
      "phoneNumber": "4087098069"
    },
    "eventCode": "TXNPP_APP"
  },
  "eventOwner": {
    "merchantGroup": "MerchantGroup",
    "merchant": "merchant-1",
    "site": "site-1"
  }
}
ParameterDescription
eventCategory
string
The category of the webhook event (e.g., "transaction").
eventDate
string
The date and time when the event occurred, in ISO 8601 format.
eventCode
string
The event code. TXNPP_APP indicates PayPal order approved.
payerDetails.emailAddress
string
The customer's email address from PayPal.
payerDetails.givenName
string
The customer's first name from PayPal.
payerDetails.surName
string
The customer's last name from PayPal.
payerDetails.phoneNumber
string
The customer's phone number from PayPal (if provided).
shippingAddress
object
Complete shipping address if customer selected one in PayPal.
systemTransactionId
string
Unity's internal transaction ID.
providerTransactionId
string
PayPal's transaction ID.

The payer details (email, name, phone) are also displayed in the Unity Portal's reporting system for PayPal transaction details.