Panduan · Terbit
Integrasi payment gateway di Android dengan Kotlin: kunci API yang tidak boleh masuk BuildConfig, Custom Tabs dan bukan WebView, dan return_url https yang membuka aplikasi lewat App Links
Di aplikasi native Android, Kasera Pay dipasang dengan tiga bagian: backend penjual yang membuat tagihan dengan kunci API, aplikasi Kotlin yang meminta tagihan itu lalu membuka halaman pembayarannya di Custom Tabs, dan alamat https milik penjual yang dipakai sebagai return_url dan diakui sebagai milik aplikasi lewat App Links. Aplikasinya sendiri tidak pernah memegang kunci dan tidak pernah memanggil API Kasera Pay.
Susunan ini sama dengan integrasi payment gateway di Flutter. Yang dibahas di sini adalah bagian yang khas proyek Android Studio: BuildConfig, Custom Tabs, App Links, siklus hidup Activity, dan satu perilaku org.json bawaan Android. Kode pemanggilan backend dan pemantauan status di bawah dikompilasi dan dijalankan pada JVM dengan Kotlin 2.4, OkHttp 5.3.2, dan kotlinx-coroutines 1.10.2 melawan backend tiruan, termasuk backend yang menjawab 503 bergantian dan tagihan yang kedaluwarsa. Potongan Activity dan manifest memakai API Android standar dan tidak dijalankan di perangkat.
1. Kunci API tinggal di backend, bukan di BuildConfig
Cara yang paling sering dicontohkan untuk menyimpan rahasia di proyek Android adalah menaruhnya di local.properties, lalu membacanya ke buildConfigField, sendiri atau lewat plugin secrets. Cara itu menjaga kunci keluar dari repositori git, dan hanya itu. Nilainya ditulis sebagai konstanta di kelas BuildConfig, ikut terkompilasi ke dalam APK, dan terbaca oleh dekompiler dalam beberapa menit. R8 mengecilkan dan mengacak nama kelas, bukan isi string konstan. Kunci live yang bocor dari aplikasi juga lebih mahal dicabut daripada kunci server: versi lama tetap terpasang di ponsel yang belum memperbarui.
Yang membuat tagihan karena itu adalah backend, dengan Idempotency-Key yang disimpan bersama pesanan sehingga tombol bayar yang tertekan dua kali tetap menghasilkan satu tagihan. Permintaannya:
curl https://pay.kasera.id/v1/transactions \
-H "Authorization: Bearer kp_live_..." \
-H "Idempotency-Key: pesanan-ORD-1042" \
-H "Content-Type: application/json" \
-d '{
"amount": 125000,
"description": "Pesanan ORD-1042",
"external_id": "ORD-1042",
"return_url": "https://toko.example/kembali?pesanan=ORD-1042"
}'Backend lalu mengembalikan checkout_url dan expires_at ke aplikasi. Kalau backend-nya Spring Boot, bagian ini lengkap di integrasi payment gateway di Spring Boot, termasuk verifikasi webhook yang menandai pesanan lunas. Rotasi kunci dan langkah saat kunci live bocor ada di artikel menyimpan dan merotasi kunci API.
2. Memanggil backend sendiri dari aplikasi
// app/build.gradle.kts
dependencies {
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("androidx.browser:browser:1.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
}Izin android.permission.INTERNET tetap dibutuhkan di manifest. OkHttp 5.3.2 adalah versi yang dipakai saat menguji; dua lainnya adalah rilis stabil di repositori Google saat panduan ini ditulis.
// data/PembayaranApi.kt
// Aplikasi hanya berbicara dengan backend milik penjual. Tidak ada kunci
// Kasera Pay di sini, di BuildConfig, di local.properties, maupun di res/.
import java.io.IOException
import java.time.Instant
import java.time.OffsetDateTime
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
data class Tagihan(val checkoutUrl: String, val kedaluwarsa: Instant)
data class StatusPesanan(val status: String, val kedaluwarsa: Instant?)
// expires_at datang dengan offset (+07:00). OffsetDateTime membacanya utuh.
private fun waktu(s: String): Instant = OffsetDateTime.parse(s).toInstant()
class PembayaranApi(
private val baseUrl: String, // https://api.toko.example
private val tokenSesi: () -> String,
) {
private val http = OkHttpClient.Builder()
.callTimeout(20, TimeUnit.SECONDS)
.build()
suspend fun buatTagihan(pesananId: String): Tagihan = withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/pesanan/$pesananId/bayar")
.header("Authorization", "Bearer ${tokenSesi()}")
.post(ByteArray(0).toRequestBody(null))
.build()
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) throw IOException("gagal membuat tagihan: ${res.code}")
val body = JSONObject(res.body.string())
Tagihan(body.getString("checkout_url"), waktu(body.getString("expires_at")))
}
}
suspend fun bacaStatus(pesananId: String): StatusPesanan = withContext(Dispatchers.IO) {
val req = Request.Builder()
.url("$baseUrl/pesanan/$pesananId")
.header("Authorization", "Bearer ${tokenSesi()}")
.build()
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) throw IOException("gagal membaca status: ${res.code}")
val body = JSONObject(res.body.string())
StatusPesanan(
status = body.getString("status"),
// Bukan optString: org.json bawaan Android mengembalikan teks
// "null" untuk nilai null, dan teks itu bukan tanggal.
kedaluwarsa = if (body.isNull("expires_at")) null else waktu(body.getString("expires_at")),
)
}
}
}Dua hal di kode itu mudah terlewat. res.body tidak lagi nullable di OkHttp 5; proyek yang masih memakai OkHttp 4 menulis res.body!!.string(). Dan optString pada org.json bawaan Android mengembalikan teks “null” untuk nilai JSON null, bukan string kosong, sehingga expires_at yang sudah null karena pesanan lunas berubah menjadi galat parse. Pustaka org.json dari Maven yang dipakai saat menguji di JVM tidak berperilaku begitu, dan karena itulah kode di atas memeriksa isNull lebih dulu.
3. Halaman pembayaran di Custom Tabs, bukan WebView
Pembeli Virtual Account menyalin nomor lalu pindah ke aplikasi banknya. Pembeli QRIS di ponsel yang sama biasanya menyimpan tangkapan layar QR lalu membukanya dari aplikasi dompet digital atau bank. WebView yang tertanam di Activity membuat perpindahan itu rapuh: halamannya ikut mati kalau Activity dihancurkan saat pembeli berada di aplikasi lain. Custom Tab berjalan di peramban, menjaga tab itu tetap hidup, dan halaman pembayaran memeriksa statusnya sendiri setiap beberapa detik. Kalau tidak ada peramban yang mendukung Custom Tabs, launchUrl membuka peramban bawaan. Apa saja yang tampil di halaman itu diuraikan di dokumentasi checkout.
// ui/PesananActivity.kt
class PesananActivity : AppCompatActivity() {
private val api = PembayaranApi("https://api.toko.example") { Sesi.token }
private lateinit var pesananId: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_pesanan)
// Dibuka dari daftar pesanan (extra) atau dari App Link return_url
// (?pesanan=...). Keduanya hanya menunjuk pesanan, bukan bukti bayar.
pesananId = intent.getStringExtra("pesanan_id")
?: intent.data?.getQueryParameter("pesanan")
?: return finish()
findViewById<Button>(R.id.tombolBayar).setOnClickListener {
lifecycleScope.launch {
val tagihan = try {
api.buatTagihan(pesananId)
} catch (e: IOException) {
tampilkanGalat()
return@launch
}
CustomTabsIntent.Builder().build()
.launchUrl(this@PesananActivity, Uri.parse(tagihan.checkoutUrl))
}
}
lifecycleScope.launch {
// Berjalan ulang setiap kali layar terlihat lagi: sepulang dari
// Custom Tab, dari aplikasi bank, atau dari daftar aplikasi terbaru.
repeatOnLifecycle(Lifecycle.State.STARTED) {
api.pantauStatus(pesananId).collect { tampilkan(it) }
}
}
}
}repeatOnLifecycle(STARTED) adalah inti bagian ini. Begitu Custom Tab terbuka, Activity berhenti dan pemantauan ikut berhenti, jadi tidak ada baterai yang terbuang selama pembeli di aplikasi bank. Saat pembeli kembali, lewat tombol kembali, daftar aplikasi terbaru, atau App Link, pemantauan berjalan lagi dari awal. Karena yang dipegang hanya id pesanan, cara itu tetap benar setelah sistem mematikan proses aplikasi di latar belakang, kejadian yang biasa pada ponsel dengan memori kecil.
// data/PantauStatus.kt
// Status dibaca dari backend penjual, yang diperbarui oleh webhook
// payment.paid dan payment.expired. Nilainya mengikuti Kasera Pay:
// pending, succeeded, expired. Yang dipegang hanya id pesanan, jadi
// pemantauan bisa dimulai ulang dari Activity yang baru dibuat sekalipun.
import java.io.IOException
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun PembayaranApi.pantauStatus(pesananId: String): Flow<String> = flow {
var jeda = 2.seconds
while (true) {
val s = try {
bacaStatus(pesananId)
} catch (e: IOException) {
null // sinyal putus sebentar bukan alasan berhenti memantau
}
if (s != null) {
val lewat = s.kedaluwarsa?.let { Instant.now().isAfter(it) } ?: false
val status = if (s.status == "pending" && lewat) "expired" else s.status
emit(status)
if (status != "pending") return@flow
}
delay(jeda)
if (jeda < 15.seconds) jeda *= 2
}
}4. Pulang ke aplikasi lewat App Links
Setelah pembayaran berhasil, halaman pembayaran mengarahkan pembeli ke return_url dengan tambahan ?id=payreq_...&status=succeeded setelah tiga detik, dan menampilkan tautan ke alamat yang sama. Parameter yang sudah ada, seperti pesanan=ORD-1042, tetap ikut. Pengalihan itu hanya terjadi untuk pembayaran berhasil; tagihan yang kedaluwarsa tidak pernah mengirim pembeli pulang.
Skema kustom tidak bisa dipakai di sini karena mode live hanya menerima https. App Links membuat alamat https milik penjual dibuka oleh aplikasi:
<activity
android:name=".ui.PesananActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="toko.example" />
<data android:pathPrefix="/kembali" />
</intent-filter>
</activity>Lalu domain itu menyajikan berkas ini di https://toko.example/.well-known/assetlinks.json, dengan content-type application/json dan tanpa pengalihan 301 atau 302:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "id.toko.example",
"sha256_cert_fingerprints": ["14:6D:E9:...:44:E5"]
}
}]Jebakan yang paling sering: sidik jari SHA-256 dari keystore lokal tidak cocok dengan aplikasi yang dipasang dari Play Store kalau Play App Signing aktif. Sidik jari yang benar, bahkan potongan JSON lengkapnya, ada di Play Console bagian App signing. Kolomnya menerima beberapa sidik jari, jadi build debug dan rilis bisa didaftarkan bersamaan.
Custom Tabs mendukung App Links secara bawaan, tetapi apakah pengalihan otomatis tanpa ketukan diserahkan ke aplikasi bergantung pada peramban pembeli. Ketukan pada tautan di halaman itu adalah jalan yang lebih andal. Apa pun yang terjadi, halaman di /kembali harus berguna sebagai halaman web, dan aplikasi tetap membaca status dari backend saat pembeli kembali. status=succeeded di URL itu hanya penunjuk layar, bukan bukti: siapa pun bisa membuka alamat itu tanpa membayar. Bukti satu-satunya adalah event payment.paid bertanda tangan yang diterima backend, seperti dijelaskan di dokumentasi webhook.
5. Sebelum kunci live dipakai
Kunci berawalan kp_test_ membuat transaksi tes yang tidak menyentuh uang. Rinciannya ada di dokumentasi mode tes. Lima hal yang layak dicoba di perangkat:
- Bayar sampai selesai, lalu tutup paksa aplikasi sebelum kembali. Pesanan tetap lunas, karena yang menandainya adalah webhook di backend.
- Buka
https://toko.example/kembali?pesanan=ORD-1042&status=succeededtanpa membayar. Layar harus tetap menunggu. - Ketuk tombol bayar dua kali secepat mungkin. Yang terbit tetap satu tagihan.
- Aktifkan Don't keep activities di opsi developer, buka pembayaran, pindah ke aplikasi lain, lalu kembali. Pemantauan harus berjalan lagi dari id pesanan.
- Jalankan
adb shell pm get-app-links id.toko.examplesetelah memasang build rilis, dan pastikan domainnya berstatus verified.
Pertanyaan yang sering muncul
Apakah ada SDK Kasera Pay untuk Kotlin atau Java?
Belum. SDK resmi saat panduan ini ditulis tersedia untuk PHP dan JavaScript/TypeScript, dan keduanya untuk backend. Bagian yang berjalan di aplikasi tidak membutuhkan SDK sama sekali, karena tugasnya hanya memanggil backend penjual dan membuka sebuah URL. Backend yang ditulis dengan Spring Boot atau Ktor memanggil API lewat HTTP biasa dengan header Authorization dan Idempotency-Key.
Kenapa tidak memakai skema kustom seperti tokosaya://selesai sebagai return_url?
Karena ditolak. Pada pembuatan transaksi mode live, return_url wajib https, dan nilai lain dijawab 422 validation_failed dengan return_url https_url. Kunci tes juga menerima http untuk pengujian lokal, tetapi tidak menerima skema kustom. App Links memecahkan ini dengan alamat https milik penjual yang sekaligus diakui sebagai milik aplikasi.
Bolehkah aplikasi di Google Play menjual kursus, langganan konten, atau koin lewat Kasera Pay?
Kebijakan pembayaran Google Play, saat panduan ini ditulis, mewajibkan sistem penagihan Google Play untuk konten, fitur, dan barang digital yang dibeli di dalam aplikasi, dan mengecualikan barang fisik serta layanan fisik seperti transportasi dan jasa kebersihan. Aplikasi toko barang, pemesanan jasa di tempat, atau tiket layanan fisik bisa memakai gateway sendiri. Aplikasi yang menjual konten digital untuk dipakai di dalam aplikasi harus membaca kebijakan itu lebih dulu, termasuk program penagihan alternatif yang hanya berlaku di negara tertentu.
Bagaimana kalau App Link belum terverifikasi atau aplikasinya belum terpasang?
Alamat return_url terbuka sebagai halaman web biasa. Karena itu halaman di alamat itu harus berguna dengan sendirinya: menyebut nomor pesanan, menjelaskan bahwa status pembayaran sedang dikonfirmasi, dan meminta pembeli kembali ke aplikasi. Pemantauan di aplikasi tetap berjalan saat pembeli kembali, jadi tidak ada yang hilang. Status verifikasi domain bisa dilihat dengan adb shell pm get-app-links diikuti nama paket aplikasi.