Before you build real handling for payments, reversals, or any other operation, it's worth understanding the one pattern that governs all communication with the Market Pay payment application. Every operation in the SDK follows it. Once it clicks, the whole API becomes predictable.
This guide explains three things: why responses don't come back from the method you call, how to tell responses apart when they all arrive in the same place, and why the response super-types — not their success subtypes — are what you handle.
Requests are fire-and-forget; responses arrive separately
When you call something like sendPaymentRequest, you might expect it to return the payment result. It doesn't. The call sends the request to the payment application and returns almost immediately — it does not wait for the terminal to process the card.
The actual result arrives later, asynchronously, in the interceptor you registered when you initialized the SDK. This is the single most important thing to internalize: the method you call and the response you handle are in two different places in your code.
// You send here…
viewModelScope.launch {
clientSDK.sendPaymentRequest(/* … */)
}
// …and the result arrives here, later, in the interceptor:
.bindInterceptor(
interceptor = { message ->
// the payment response shows up as `message`
Result.success(message)
}
)
Why is it built this way? A card payment isn't instant — the terminal prompts the cardholder, talks to the acquirer, and may take many seconds. Blocking your call for the whole duration would freeze your app. Decoupling the request from the response lets the terminal take as long as it needs while your app stays responsive, and it lets the payment application send you intermediate updates (see "display requests" below) before the final result.
Every response is a DomainMessage
The interceptor receives a single parameter typed as DomainMessage. Every message in App2App communication — login responses, payment responses, errors, progress updates — is a subtype of DomainMessage. That's why one interceptor can receive all of them.
Because they all arrive as the same broad type, your job in the interceptor is to type-check the incoming message to decide what it is and how to handle it:
.bindInterceptor(
interceptor = { message ->
when (message) {
is SuccessRetailerLoginResponse -> { /* session is now open */ }
is SuccessRetailerPaymentResponse -> { /* payment approved */ }
is ErrorRetailerPaymentResponse -> { /* payment failed */ }
// …handle the message types your app cares about
}
Result.success(message)
}
)
You only need to match the message types your application actually acts on. Messages you don't match simply fall through — but always return Result.success(message) at the end so the SDK knows the message was accepted.
Each operation has its own response super-type
The responses aren't a flat list of unrelated classes. Each operation has a sealed super-type, and under it sit the concrete outcomes — typically a success and an error variant. Matching the super-type catches every outcome of that operation; matching a single subtype catches only one.
| Operation | Response super-type | Subtypes you may receive |
|---|---|---|
| Login | RetailerLoginResponse | SuccessRetailerLoginResponse, ErrorRetailerLoginResponse |
| Payment | RetailerPaymentResponse | SuccessRetailerPaymentResponse, ErrorRetailerPaymentResponse, PartialRetailerPaymentResponse |
| Reversal | RetailerReversalResponse | SuccessRetailerReversalResponse, ErrorRetailerReversalResponse |
| Reconciliation | RetailerReconciliationResponse | Success…, Error… |
| Transaction status | RetailerTransactionStatusResponse | Success…, Error… |
| Card acquisition | RetailerAcquisitionResponse | SuccessRetailerAcquisitionResponse, ErrorRetailerAcquisitionResponse |
| Diagnosis (test connection) | RetailerDiagnosisResponse | Success…, Error… |
| Logout | RetailerLogoutResponse | Success…, Error… |
Note. Payment has a third subtype,
PartialRetailerPaymentResponse, that the other operations don't. This is exactly why you reason in terms of the super-type: a handler written only for "success or error" would miss the partial case. The full, authoritative subtype list for every operation is in the API reference (sdk-doc.zip) — when in doubt, match the super-type.
Payment also sends progress updates
During a payment, before the final result, the payment application may send you RetailerDisplayRequest messages. These are not the payment outcome — they tell you what the terminal is doing right now (waiting for the card, contacting the host, and so on), which is useful if you want to show your own progress UI. The same request that produces a RetailerPaymentResponse can produce several RetailerDisplayRequest messages along the way. Handle them if you want live progress; ignore them otherwise. The final outcome is always one of the RetailerPaymentResponse subtypes.
Why you await the super-type, not the success subtype
This is the mistake that causes the most confusing bugs, so it gets its own section. It matters most when you switch from fire-and-forget to blocking mode — waiting for a specific response before continuing.
The SDK provides BlockingMessageGateway for this. You feed every interceptor message into it, and then you can send a request and suspend your coroutine until the matching response arrives:
val gateway = BlockingMessageGateway()
// In the interceptor, hand every message to the gateway:
.bindInterceptor(
interceptor = { message ->
gateway.onNewMessage(message)
Result.success(message)
}
)
// Then send-and-wait, specifying the response type to await:
val response: RetailerLoginResponse = gateway.sendBlocking(
timeoutMillis = 30000
) {
clientSDK.sendLoginRequest()
}
when (response) {
is SuccessRetailerLoginResponse -> { /* logged in */ }
is ErrorRetailerLoginResponse -> { /* login rejected — handle it */ }
}
The type you put in the angle brackets / assign to is the type sendBlocking waits for. Here is the trap:
- Await
RetailerLoginResponse(the super-type) → the coroutine resumes on either success or error. You handle both. Correct. - Await
SuccessRetailerLoginResponse(the success subtype) → the coroutine resumes only if login succeeds. If login fails, noSuccessRetailerLoginResponseever arrives, the gateway keeps waiting, and your coroutine hangs until it finally throwsTimeoutCancellationException. The failure response was delivered — you just weren't listening for it.
In other words: awaiting the success subtype silently converts every failure into a timeout. Always await the super-type so both outcomes resume your coroutine, then branch with a when as shown above.
sendBlocking defaults to a 60-second timeout (timeoutMillis = 60000). Pass a shorter value for operations that should fail fast. Whatever the timeout, wrap the call so you can handle TimeoutCancellationException — a timeout means no response arrived in time, which is itself a condition your app must react to.
The pattern in one paragraph
You send a request and it returns immediately. The response — and any progress updates before it — arrive later in your interceptor, all typed as DomainMessage. You type-check to find the ones you care about. Each operation has a sealed response super-type covering all of its outcomes; you reason and (in blocking mode) wait in terms of that super-type, never a single subtype, so that failures reach your code instead of vanishing into a timeout. Every operation in the SDK works exactly this way — so once you've handled one, you've handled them all.
Related
- Quickstart — the end-to-end first payment that this model underpins.
- Session & login lifecycle — why login must succeed before any response is meaningful, and when to re-login.
- State & foreground handling — the other asynchronous channel (the event observer), separate from the interceptor described here.
- Troubleshooting: sendBlocking hangs until timeout — the failure mode this guide's last section exists to prevent.
- API reference (
sdk-doc.zip) — the authoritative, complete subtype list for every operation.