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.
Click the following link to download all the code for this codelab:
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.
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.

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.
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 |
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.


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.
@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)
}
}
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
explicitNulls = false
}
requestPaymentInformation method to suspend and return the result of the request, based on this we will implement the retry logic. 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 ...
}
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))
}
}
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
}
}
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.


