Android integration (Kotlin)
Who this is for
Native Android apps (Kotlin) that need to show the amount due and collection details in their own UI and refresh the order state once the buyer has paid. The example uses Retrofit + OkHttp.
Before you start
- You have an API KEY (see Get started).
- You have configured a collection address and set it active.
- You have your own server — a mobile app cannot hold the API KEY.
The KEY cannot live inside an APK
APKs can be decompiled, so any string baked into the package is public. Order requests from the app go to your own server, which calls the platform's Open API with the KEY.
Install
// app/build.gradle.kts
dependencies {
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
}Minimal working example
Two pieces: your server calls the platform with the KEY, and the app talks only to your server.
1. Your server (Kotlin / Ktor or OkHttp)
// Server side: call the platform's Open API with OkHttp
val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.SECONDS)
.build()
fun createOrder(orderNo: String, amount: String): String {
val json = """
{
"merchantOrderNo": "$orderNo",
"chainId": 2,
"tokenId": 1,
"amount": "$amount",
"subject": "Order $orderNo",
"notifyUrl": "https://your-shop.com/pay/notify"
}
""".trimIndent()
val req = Request.Builder()
.url("${System.getenv("API_BASE")}/api/pay/open/createOrder")
.addHeader("X-Pay-Api-Key", System.getenv("PAY_API_KEY")) // server side only
.addHeader("Content-Type", "application/json")
.post(json.toRequestBody("application/json".toMediaType()))
.build()
return client.newCall(req).execute().use { it.body!!.string() }
}2. App side: Retrofit talks only to your backend
data class CheckoutReq(val orderNo: String, val amount: String)
data class CheckoutResp(
val code: Int,
val orderNo: String?,
val payAddress: String?,
val payAmountDisplay: String?,
val payAmountMinimal: String?,
val chainKey: String?,
val msg: String?,
)
data class StatusResp(
val code: Int,
val status: Int, // 1..6; only 3/4 mean paid
val statusText: String?,
val txHash: String?,
)
interface ShopApi {
@POST("/api/checkout")
suspend fun checkout(@Body body: CheckoutReq): CheckoutResp
@GET("/api/order-status")
suspend fun status(@Query("orderNo") orderNo: String): StatusResp
}class CheckoutViewModel : ViewModel() {
private val api = Retrofit.Builder()
.baseUrl("https://your-shop.com")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ShopApi::class.java)
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state
fun start(orderNo: String, amount: String) = viewModelScope.launch {
try {
val o = api.checkout(CheckoutReq(orderNo, amount))
if (o.code != 0) {
_state.value = UiState.Error(o.msg ?: "create failed")
return@launch
}
_state.value = UiState.Ready(o)
// Poll until confirmed / finished
while (true) {
delay(3_000)
val s = api.status(o.orderNo!!)
when (s.status) {
1 -> _state.value = UiState.Ready(o) // waiting for payment
2 -> _state.value = UiState.Pending(o) // submitted, awaiting confirmations
3, 4 -> { _state.value = UiState.Paid(o); break }
5 -> { _state.value = UiState.Error("Order expired"); break }
6 -> { _state.value = UiState.Error("Manual review required — contact support"); break }
}
}
} catch (e: Exception) {
_state.value = UiState.Error(e.message ?: "network error")
}
}
}You can also skip building the checkout UI in the app and open the platform's payment counter:
{cashierBaseUrl}/pay/cashier/m/{merchantNo}, with both values from the merchant-sideGET /api/pay/getMyProfile.
Callback handling
Callbacks are received by your server (the app is not involved). Verify with OkHttp on the server:
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
fun verify(rawBody: String, ts: String, sig: String, apiKey: String): Boolean {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(apiKey.toByteArray(), "HmacSHA256"))
mac.update(ts.toByteArray()) // timestamp and body concatenated as strings
mac.update(rawBody.toByteArray()) // raw body, never re-serialised
val want = mac.doFinal().joinToString("") { "%02x".format(it) }
return java.security.MessageDigest.isEqual(want.toByteArray(), sig.toByteArray()) // constant time
}Handling notes:
- Idempotent: callbacks retry; deduplicate on
merchantOrderNoso you never ship twice. - Return fast: about a 10-second timeout; move slow work to a background task.
- Query is authoritative: verify with Query order first.
Wallet hand-off
Wallet hand-off on Android relies on wallet apps' URL schemes: handing a link such as https://your-shop.com/pay/... to the system chooser opens an installed wallet. Show the collection address and amount as text alongside the QR code so users without a wallet can still pay.
FAQ
| Question | Answer |
|---|---|
Can the KEY go in BuildConfig? | No — the APK can be decompiled |
| Should I use a WebView? | Optional. You can also open the platform checkout in the system browser and maintain one fewer UI |
| Polling interval? | 3–5 seconds; pause when the app goes to background and resume on return |
| How do I know it is paid? | Only states 3 / 4 — never compare numeric values |
| Will retries after a network drop create duplicate orders? | No — the same merchantOrderNo is idempotently reused |