The Worldline Payment Interface (WPI) is an Android intent-based interface that enables easy and fast integration with payment apps. It is designed to allow additional business applications to execute payments on any Worldline offered SmartPOS device. A SmartPOS device is a payment device that runs Android And also smartphones using Tap On Mobile.

All Worldline Payment Solutions support WPI, which means that business applications that use this interface can run everywhere without any change of code. This makes it easy for developers to build payment apps that are compatible with a wide range of payment devices and payment protocols.

The WPI interface is built on top of the Android Intent mechanism, which allows different parts of an app to communicate with each other in a structured way. By using intents to trigger payment-related actions, the WPI interface simplifies the payment integration process and makes it easier for developers to build business applications on top of Payment Solutions.

What you'll learn

Get the code

Click the following link to download all the code for this codelab:

Download Zip

Check out the sample app

For this codelab we will use Android studio Narwhal Feature Drop, SDK 36 and Gradle 8.14.3 This codelab repository contains multiple branches to help you follow along and understand the evolution of the code:

Tip: We recommend that you start with the code in the end branch and follow the codelab step-by-step at your own pace. You can always refer to the retry-wpi-info branch if you get stuck or want to see the final implementation.

Getting familiar with the code and running the sample app

Take a moment to explore the project structure and run the app.

When you run the app from the end branch , you'll see that this is a very simple application with only a button to request the payment information. This project uses Jetpack Compose for the UI and Kotlin Serialization for JSON serialization and deserialization.

Project structure

From the previous codelab we saw how to implement the information request. As a reminder, WPI provides a set of intents that developers can use to build apps that work with the interface. The following table lists each intent, along with its purpose:

Type

Action Type

Intent

Purpose

Financial

Display Action

com.worldline.payment.action.PROCESS_TRANSACTION

Process a financial transaction

Management

Display Action

com.worldline.payment.action.PROCESS_MANAGEMENT

Trigger payment solution managed actions

Information

Background Action

com.worldline.payment.action.PROCESS_INFORMATION

Background management of payment solution specific information

As you can see the information request is a background action, this means that the payment solution will not show any UI when this intent is called. This is useful for requests that do not require user interaction, such as fetching configuration data. For this type of request we use Android Messenger service to get the response from the payment solution.

Extras

An extra in Android intents is a way to pass additional data or parameters to another component in order to provide more context or information for the requested action. In WPI we have extras for request and response. The request extras needs to be provided by the Business Application that is using the interface and the response extra is provided by the Payment Solution.

Request:

Extra

Description

Type

Condition

WPI_SERVICE_TYPE

Specify the subtype of action to be executed

String

Mandatory

WPI_REQUEST

The request contains JSON structured data mandatory for given service type, in some specific cases this field is not given or empty

String

Mandatory for Financial, Optional for Management, Not needed for Information

WPI_VERSION

Used WPI version

String

Mandatory

WPI_SESSION_ID

Used as an identifier of a WPI exchange (a request/response) between a client application and a payment application. Must be unique, part of the response, and not reused for subsequent requests (except for recovery). Any business application should store this before sending the intent.

String

Mandatory

Response:

Extra

Description

Type

Condition

WPI_SERVICE_TYPE

Specify the subtype of action that was executed

String

Mandatory for all service types except WPI_SVC_LAST_TRANSACTION (see 3.3.1 Section in WPI interfacer)

WPI_RESPONSE

The response contains JSON structured data processed for the requested service type

String

Mandatory for all service types except WPI_SVC_LAST_TRANSACTION (see 3.3.1 Section in WPI interface)

WPI_VERSION

Used WPI version

String

Mandatory

WPI_SESSION_ID

Identifier of a WPI exchange provided by the client.

String

Mandatory

Service type

Purpose

WPI_SVC_LAST_TRANSACTION

Recovery feature, know the status of latest transaction based of Session ID

WPI_SVC_PAYMENT_INFORMATION

Fetches payment solution configuration and capabilities, including supported service types, languages, currencies, and terminal specifications

Data Dictionary

Response Data: Payment Solution Information

Field Name

Description

Type

Condition

result

Result of the transaction (success or failure)

String (pre-defined 4.2.2 Result)

Mandatory

errorCondition

Specific error reason (see table below)

String (pre-defined 4.2.3 Error Condition)

Mandatory

softwareVersion

Software version of the payment solution

String

Conditional - WPI_RESULT_SUCCESS

terminalIdentifier

Terminal identifier configured for the payment solution

String

Conditional - WPI_RESULT_SUCCESS

terminalModel

Terminal model of the device

String

Conditional - WPI_RESULT_SUCCESS

macAddress

MAC Address of used adapter.

String

Conditional - WPI_RESULT_SUCCESS

supportedLanguages

Languages supported by the payment solution

Array

Conditional - WPI_RESULT_SUCCESS

cardholderDefaultLanguage

Cardholder language

String

Conditional - WPI_RESULT_SUCCESS

merchantLanguage

Merchant language

String

Conditional - WPI_RESULT_SUCCESS

countryCode

Country code of the payment solution

String

Conditional - WPI_RESULT_SUCCESS

defaultCurrency

Default currency

String

Conditional - WPI_RESULT_SUCCESS

supportedCurrencies

Currencies the payment solution supports

Array

Conditional - WPI_RESULT_SUCCESS

shopInfo

Shop information for the used terminal

Object (4.2.1 ShopInfo)

Conditional - WPI_RESULT_SUCCESS

acquirerProtocol

The payment solution protocol used towards the acquirer

String

Conditional - WPI_RESULT_SUCCESS

supportedTicketFormats

Receipt formats which the payment solution supports

Array

Conditional - WPI_RESULT_SUCCESS

supportReceiptDelegation

Whether receipt delegation is supported or not.

Boolean

Conditional - WPI_RESULT_SUCCESS

paymentInformation

Payment information per brand

Array

Conditional - WPI_RESULT_SUCCESS

paymentSolutionSupportedServiceTypes

Supported services types by the payment solution

Array

Conditional - WPI_RESULT_SUCCESS

supportsManualPanKeyEntry

Whether manual card data entry through the payment application is activated

Boolean

Conditional - WPI_RESULT_SUCCESS

supportsTip

Whether tip is supported or not.

Boolean

Conditional - WPI_RESULT_SUCCESS

To simulate a real world scenario, you can set the sample app as the default application that will start at terminal startup. To do this, you need to set the application as the default app in the SmartPOS settings application.

  1. Go to Launcher and open settings application

Settings Icon

  1. Select wpi-codelab as Default Application

Default App Selection

  1. To verify that everything is working correctly, restart the terminal. After the restart, the sample app should start automatically.

The PaymentInfoScreen needs to change to react to different UI states such as loading, success, and error. To achieve this, we will define a sealed class PaymentInfoUiState that represents these states.

sealed class PaymentInfoUiState {
    object Idle : PaymentInfoUiState()
    data class Loading(val attempt: Int) : PaymentInfoUiState()
    data class Success(val json: String, val attempt: Int) : PaymentInfoUiState()
    data class Error(val message: String, val attempt: Int) : PaymentInfoUiState()
}

We will change the compose code to react to these states accordingly. The signature of the PaymentInfoScreen composable function will be updated to accept the uiState and a retry callback. The purpose of the application changes as we will automatically request the payment information when the app starts and retry if it fails.

@Composable
fun PaymentInfoScreen(
    uiState: PaymentInfoUiState,
    onRetry: () -> Unit
) {
  //... existing code ...
}

Whe change the Colum to show different content based on the uiState.

Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        when (uiState) {
            is PaymentInfoUiState.Loading -> {
                CircularProgressIndicator()
                Spacer(modifier = Modifier.height(16.dp))
                Text("Loading... (Attempt ${uiState.attempt})")
            }
            is PaymentInfoUiState.Error -> {
                Text(
                    text = "Error: ${uiState.message}",
                    color = Color.Red
                )
                Spacer(modifier = Modifier.height(8.dp))
                Button(onClick = onRetry) {
                    Text("Retry")
                }
                Spacer(modifier = Modifier.height(8.dp))
                Text("Attempt: ${uiState.attempt}")
            }
            is PaymentInfoUiState.Success -> {
                Box(
                    modifier = Modifier
                        .weight(1f)
                        .fillMaxWidth()
                        .verticalScroll(rememberScrollState())
                ) {
                    Text(
                        text = prettyPrintJson(uiState.json),
                        style = MaterialTheme.typography.bodySmall,
                        fontFamily = FontFamily.Monospace
                    )
                }
            }
            PaymentInfoUiState.Idle -> {}
        }
    }

The PaymentInfoViewModel is responsible for managing the state of the payment information request process. It uses Jetpack Compose's mutableStateOf to hold the current UI state, which can be one of the states defined in the PaymentInfoUiState. It also tracks the current attempt number for retries and provides a method to retry fetching the payment information.

class PaymentInfoViewModel : ViewModel() {
  private val _uiState = MutableStateFlow<PaymentInfoUiState>(PaymentInfoUiState.Idle)
  val uiState: StateFlow<PaymentInfoUiState> = _uiState
  private var attempt = 1

  /**
   * Initializes the request process, setting the attempt counter to 1 and updating the UI state to Loading.
   */
  fun startPaymentInfoRequest() {
    attempt = 1
    _uiState.value = PaymentInfoUiState.Loading(attempt)
  }

  /**
   * Handles the result of the payment information request.
   * If successful, updates the UI state to Success.
   * If it fails and the maximum number of attempts (5) has not been reached, updates the UI state to Loading for another attempt.
   * If all attempts fail, sets the UI state to Error.
   */
  fun onPaymentInfoResult(result: Result<String>) {
    if (result.isSuccess) {
      _uiState.value = PaymentInfoUiState.Success(result.getOrNull() ?: "", attempt)
    } else {
      if (attempt >= 5) {
        _uiState.value = PaymentInfoUiState.Error("All attempts failed", attempt)
        attempt = 0
      } else {
        _uiState.value = PaymentInfoUiState.Loading(attempt)
      }
    }
    attempt++
  }

  /**
   * Allows the user to manually retry fetching the payment information by updating the UI state to Loading.
   */
  fun retry() {
    _uiState.value = PaymentInfoUiState.Loading(attempt)
  }

  /**
   * Handles the scenario where the service disconnects during the retry process, updating the UI state to Error and resetting the attempt counter.
   */
  fun onServiceDisconnected() {
    _uiState.value = PaymentInfoUiState.Error("Service disconnected", attempt)
    attempt = 0
  }
}

The MainActivity will be updated to implement the retry logic for fetching the payment information.

  1. In a separate file, We need to create PaymentInformationResponse data class to parse the response and check if the result is SUCCESS or not. The PaymentInformationResponse will only contain the fields we need for this demo. We use a custom serializer for the Result enum to handle the prefix in the enum values.
@Serializable
data class PaymentInformationResponse(
   val errorCondition: String,
    val result: Result
)

@Serializable(with = ResultSerializer::class)
enum class Result( val id: String) {
  SUCCESS("WPI_RESULT_SUCCESS"),
  PARTIAL("WPI_RESULT_PARTIAL"),
  FAILURE("WPI_RESULT_FAILURE")
}


@Serializer(forClass = Result::class)
class ResultSerializer : KSerializer<Result> {

  private val prefix = "WPI_RESULT_"

  override val descriptor: SerialDescriptor
    get() = PrimitiveSerialDescriptor("Result", PrimitiveKind.STRING)

  override fun deserialize(decoder: Decoder): Result {
    val name = decoder.decodeString().removePrefix(prefix)
    return Result.valueOf(name)
  }

  override fun serialize(encoder: Encoder, value: Result) {
    encoder.encodeString(value.id)
  }
}

  1. Declare a Json instance with ignoreUnknownKeys set to true to handle any extra fields in the response that we are not interested in.
private val json = Json {
        encodeDefaults = true
        ignoreUnknownKeys = true
        explicitNulls = false
    }

  1. Change the signature of the requestPaymentInformation method to suspend and return the result of the request, based on this we will implement the retry logic.
  2. We need to update the messenger Handler to check if the response from the request is successful or not, because this will also have an impact on the retry logic.
  3. For Demo purposes we added a lot of logging to see what is happening during the retry process.
    private suspend fun executePaymentInformation(): Result<String> = suspendCancellableCoroutine { cont ->

  val message = Message.obtain(
    null,
    895349,
    paymentInformationBundle
  )
  val handler = object : Handler(Looper.getMainLooper()) {
    override fun handleMessage(msg: Message) {
      when (msg.what) {
        895349 -> {
          val data = msg.obj as? Bundle
          val response = data?.getString("WPI_RESPONSE")
          if (response != null && response.isNotBlank()) {
            val decodeResult = runCatching {
              json.decodeFromString<PaymentInformationResponse>(response)
            }
            decodeResult.onSuccess { paymentSettingsResponse ->
              if (paymentSettingsResponse.result == WPIResult.SUCCESS) {
                Log.i("PaymentInfo", "Result: ${paymentSettingsResponse.result}")
                cont.resume(Result.success(response))
              } else {
                Log.i("PaymentInfo", "Response not successfull")
                cont.resume(Result.failure(Exception("Result not SUCCESS: ${paymentSettingsResponse.result}. Full response: $response")))
              }
            }.onFailure { e ->
              Log.e("PaymentInfo", "Failed to parse response", e)
              cont.resume(Result.failure(Exception("Failed to parse response", e)))
            }
          } else {
            cont.resume(Result.failure(Exception("Invalid or empty response")))
          }
        }
        else -> cont.resumeWithException(IllegalArgumentException("${msg.what} is an incorrect message identifier."))
      }
    }
  }
  // .. more existing code ...
}

  1. We need to modify the messenger send, to see if the service is disconnected, so that we can update the result accordingly in the ViewModel.
  2. We highlight the DeadObjectException which illustrates that the service is disconnected.
 private suspend fun executePaymentInformation(): Result<String> = suspendCancellableCoroutine { cont ->

   // ... existing code ...

  val messenger = Messenger(handler)
        message.replyTo = messenger
        if (!bound || mService == null) {
            Log.e("PaymentInfo", "Service not bound or Messenger is null. Cannot send message.")
            cont.resume(Result.failure(Exception("Service not bound or Messenger is null.")))
            return@suspendCancellableCoroutine
        }

        runCatching {
            mService?.send(message)
        }.onFailure { e ->
            when (e) {
                is android.os.DeadObjectException -> Log.e("PaymentInfo", "DeadObjectException: Service is dead.", e)
                else -> Log.e("PaymentInfo", "Exception sending message to service", e)
            }
            cont.resume(Result.failure(e))
        }

}

  1. Finally we implement the retryLogic in the MainActivity using a coroutine that will keep trying to fetch the payment information until it succeeds or reaches the maximum number of attempts (5).
private suspend fun <T> retryCall(
        times: Int = 5,
        initialDelay: Long = 2000L,
        factor: Long = 2L,
        block: suspend () -> Result<T>
    ) {
        var currentDelay = initialDelay
        repeat(times) {
            if (!bound) {
                Log.e("PaymentInfo", "Service is not bound during retry. Exiting retry loop.")
                paymentInfoViewModel.onServiceDisconnected()
                return
            }
            Log.i("PaymentInfo", "Retry attempt:  $it")
            block().onSuccess { return }
            Log.i("PaymentInfo", "Current delay:  $currentDelay")
            delay(currentDelay)
            currentDelay *= factor
        }
    }

Use Retry Logic and ViewModel in Activity

private var retryAttempt = 1
private val paymentInfoViewModel: PaymentInfoViewModel by viewModels()
    private fun executePaymentInformationWithRetry() {
        retryAttempt = 1
        lifecycleScope.launch {
            retryCall {
                val res = executePaymentInformation()
                paymentInfoViewModel.onPaymentInfoResult(res)
                retryAttempt++
                Log.i("PaymentInfo", "Res from infoRequest:  $res")
                res
            }
        }
    }
 override fun onServiceConnected(className: ComponentName, service: IBinder) {
  mService = Messenger(service)
  bound = true
  paymentInfoViewModel.startPaymentInfoRequest()
  executePaymentInformationWithRetry()
}
  override fun onServiceDisconnected(className: ComponentName) {
  Log.i("PaymentInfo", "Service disconnected")
  bound = false
  paymentInfoViewModel.onServiceDisconnected()
}
  setContent {
            val uiState by paymentInfoViewModel.uiState.collectAsState()
            PaymentInfoScreen(
                uiState = uiState,
                onRetry = {
                    paymentInfoViewModel.retry()
                    bindInformationService()                }
            )
        }
   private fun bindInformationService() {
        val intent = Intent("com.worldline.payment.action.PROCESS_INFORMATION").apply {
            component = getWpiSupportedServices("com.worldline.payment.action.PROCESS_INFORMATION").first()
            putExtras(paymentInformationBundle)
        }
        bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
    }
   override fun onStart() {
        super.onStart()
        bindInformationService()
    }

After completing all the steps, your MainActivity.kt should look like this:

class MainActivity : ComponentActivity() {
    /** Messenger for communicating with the service.  */
    private var mService: Messenger? = null

    /** Flag indicating whether we have called bind on the service.  */
    private var bound: Boolean = false
    private var retryAttempt = 1
    private val paymentInfoViewModel: PaymentInfoViewModel by viewModels()

    private val json = Json {
        encodeDefaults = true
        ignoreUnknownKeys = true
        explicitNulls = false
    }

    /**
     * Class for interacting with the main interface of the service.
     */
    private val mConnection = object : ServiceConnection {
        // Called when the connection with the service is established.
        override fun onServiceConnected(className: ComponentName, service: IBinder) {
            // Because we have bound to an explicit
            // service that is running in our own process, we can
            // cast its IBinder to a concrete class and directly access it.
            mService = Messenger(service)
            bound = true
            paymentInfoViewModel.startPaymentInfoRequest()
            executePaymentInformationWithRetry()
        }

        // Called when the connection with the service disconnects unexpectedly.
        override fun onServiceDisconnected(className: ComponentName) {
            Log.i("PaymentInfo", "Service disconnected")
            bound = false
            paymentInfoViewModel.onServiceDisconnected()
        }
    }

    private val paymentInformationBundle = bundleOf(
        "WPI_SERVICE_TYPE" to "WPI_SVC_PAYMENT_INFORMATION",
        "WPI_VERSION" to "2.1",
        "WPI_SESSION_ID" to UUID.randomUUID().toString()
    )



    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val uiState by paymentInfoViewModel.uiState.collectAsState()
            PaymentInfoScreen(
                uiState = uiState,
                onRetry = {
                    paymentInfoViewModel.retry()
                    bindInformationService()                }
            )
        }
    }

    override fun onStart() {
        super.onStart()
        bindInformationService()
    }

    override fun onStop() {
        super.onStop()
        // Unbind from the service.
        if (bound) {
            unbindService(mConnection)
            bound = false
        }
    }

    private fun bindInformationService() {
        val intent = Intent("com.worldline.payment.action.PROCESS_INFORMATION").apply {
            component = getWpiSupportedServices("com.worldline.payment.action.PROCESS_INFORMATION").first()
            putExtras(paymentInformationBundle)
        }
        bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
    }

    @SuppressLint("HandlerLeak", "UnsafeIntentLaunch")
    private suspend fun executePaymentInformation(): Result<String> = suspendCancellableCoroutine { cont ->
        val message = Message.obtain(
            null,
            895349,
            paymentInformationBundle
        )
        val handler = object : Handler(Looper.getMainLooper()) {
            override fun handleMessage(msg: Message) {
                when (msg.what) {
                    895349 -> {
                        val data = msg.obj as? Bundle
                        val response = data?.getString("WPI_RESPONSE")
                        if (response != null && response.isNotBlank()) {
                            val decodeResult = runCatching {
                                json.decodeFromString<PaymentInformationResponse>(response)
                            }
                            decodeResult.onSuccess { paymentSettingsResponse ->
                                if (paymentSettingsResponse.result == WPIResult.SUCCESS) {
                                    Log.i("PaymentInfo", "Result: ${paymentSettingsResponse.result}")
                                    cont.resume(Result.success(response))
                                } else {
                                    Log.i("PaymentInfo", "Response not successfull")
                                    cont.resume(Result.failure(Exception("Result not SUCCESS: ${paymentSettingsResponse.result}. Full response: $response")))
                                }
                            }.onFailure { e ->
                                Log.e("PaymentInfo", "Failed to parse response", e)
                                cont.resume(Result.failure(Exception("Failed to parse response", e)))
                            }
                        } else {
                            cont.resume(Result.failure(Exception("Invalid or empty response")))
                        }
                    }
                    else -> cont.resumeWithException(IllegalArgumentException("${msg.what} is an incorrect message identifier."))
                }
            }
        }
        val messenger = Messenger(handler)
        message.replyTo = messenger
        if (!bound || mService == null) {
            Log.e("PaymentInfo", "Service not bound or Messenger is null. Cannot send message.")
            cont.resume(Result.failure(Exception("Service not bound or Messenger is null.")))
            return@suspendCancellableCoroutine
        }

        runCatching {
            mService?.send(message)
        }.onFailure { e ->
            when (e) {
                is android.os.DeadObjectException -> Log.e("PaymentInfo", "DeadObjectException: Service is dead.", e)
                else -> Log.e("PaymentInfo", "Exception sending message to service", e)
            }
            cont.resume(Result.failure(e))
        }
    }

    private suspend fun <T> retryCall(
        times: Int = 5,
        initialDelay: Long = 2000L,
        factor: Long = 2L,
        block: suspend () -> Result<T>
    ) {
        var currentDelay = initialDelay
        repeat(times) {
            if (!bound) {
                Log.e("PaymentInfo", "Service is not bound during retry. Exiting retry loop.")
                paymentInfoViewModel.onServiceDisconnected()
                return
            }
            Log.i("PaymentInfo", "Retry attempt:  $it")
            block().onSuccess { return }
            Log.i("PaymentInfo", "Current delay:  $currentDelay")
            delay(currentDelay)
            currentDelay *= factor
        }
    }

    private fun executePaymentInformationWithRetry() {
        retryAttempt = 1
        lifecycleScope.launch {
            retryCall {
                val res = executePaymentInformation()
                paymentInfoViewModel.onPaymentInfoResult(res)
                retryAttempt++
                Log.i("PaymentInfo", "Res from infoRequest:  $res")
                res
            }
        }
    }

    private fun Context.getWpiSupportedServices(action: String): List<ComponentName> {
        val intent = Intent(action)
        return packageManager.queryIntentServices(intent, 0)
            .mapNotNull { ComponentName(it.serviceInfo.packageName, it.serviceInfo.name) }
    }
}

And your PaymentInfoViewModel should look like this:

class PaymentInfoViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<PaymentInfoUiState>(PaymentInfoUiState.Idle)
    val uiState: StateFlow<PaymentInfoUiState> = _uiState
    private var attempt = 1

    fun startPaymentInfoRequest() {
        attempt = 1
        _uiState.value = PaymentInfoUiState.Loading(attempt)
    }

    fun onPaymentInfoResult(result: Result<String>) {
        if (result.isSuccess) {
            _uiState.value = PaymentInfoUiState.Success(result.getOrNull() ?: "", attempt)
        } else {
            if (attempt >= 5) {
                _uiState.value = PaymentInfoUiState.Error("All attempts failed", attempt)
                attempt = 0
            } else {
                _uiState.value = PaymentInfoUiState.Loading(attempt)

            }
        }
        attempt++
    }

    fun retry() {
        _uiState.value = PaymentInfoUiState.Loading(attempt)
    }

    fun onServiceDisconnected() {
        _uiState.value = PaymentInfoUiState.Error("Service disconnected", attempt)
        attempt = 0
    }
}


To test the application, you start the application on the SmartPOS device and observe the behavior when requesting payment information.

Loading State

Loading State

Error State

Error State

Success State

Success State