Panduan · Terbit
Integrasi payment gateway di Flutter: kunci API yang tidak boleh ikut terpasang di ponsel pembeli, dan layar yang tidak pernah boleh dipakai sebagai bukti pembayaran
Panduan ini memasang Kasera Pay di aplikasi Flutter, dan seluruh susunannya ditentukan oleh satu batas yang tidak bisa ditawar: kunci API tidak pernah ikut di dalam aplikasi. Aplikasi yang sudah terpasang berada di tangan pemiliknya dan bisa dibongkar, sehingga yang membuat tagihan adalah backend penjual, dan aplikasi hanya meminta tagihan itu lalu membuka halaman pembayarannya. Tidak ada SDK Flutter atau paket Dart resmi Kasera Pay, dan tidak ada yang perlu ditambahkan ke pubspec.yaml selain http dan url_launcher.
Dua kesalahan yang paling sering menjatuhkan integrasi mobile bukan soal kriptografi. Yang pertama adalah memindahkan pembuatan tagihan ke dalam aplikasi supaya tidak perlu menulis backend, yang berarti menaruh kunci live di dalam berkas yang bisa diunduh siapa saja. Yang kedua adalah memperlakukan kembalinya pembeli ke layar aplikasi sebagai bukti pembayaran, padahal layar itu bisa dicapai tanpa membayar sama sekali.
Sisi backend-nya tidak dibahas di sini karena sudah ditulis per bahasa, dan kontraknya sama persis: integrasi payment gateway di Node.js dan Express dan integrasi payment gateway di Go mengerjakan bagian yang sama.
Alurnya, sebelum menulis kode
Ada lima langkah, dan urutannya menentukan apa yang boleh dipercaya. Aplikasi meminta tagihan ke backend penjual. Backend memanggil Kasera Pay dan menerima checkout_url. Aplikasi membuka URL itu di peramban sistem. Pembeli membayar di sana, lalu kembali. Dan backend menerima payment.paid bertanda tangan, yang merupakan satu-satunya penentu bahwa uangnya masuk. Aplikasi tidak pernah berbicara dengan Kasera Pay, dan tidak pernah menyimpulkan apa pun dari layar yang dilihat pembeli.
1. Kunci API tinggal di backend, dan alasannya khas mobile
Bahwa kredensial tidak disimpan di sisi klien adalah aturan umum. Yang membuatnya lebih keras di aplikasi mobile adalah biaya pencabutannya. Kunci yang bocor dari server dicabut dengan satu rotasi lalu deploy, dan selesai dalam hitungan menit. Kunci yang tertanam di dalam aplikasi menuntut rilis baru yang harus melewati antrean peninjauan toko aplikasi, dan versi lama tetap terpasang di ponsel yang belum memperbarui, sehingga jendela kebocorannya tidak ditutup oleh keputusan penjual melainkan oleh kesediaan pembeli memperbarui aplikasinya.
Tiga tempat yang sering dikira aman dan tidak aman. --dart-define ikut terbakar ke dalam berkas aplikasi saat build. Obfuscation mengacak nama simbol, bukan isi string konstan. Berkas .env yang didaftarkan sebagai aset justru tersimpan apa adanya di dalam paket aplikasi. Penyimpanan dan rotasi kuncinya sendiri, termasuk apa yang harus dilakukan saat kunci live bocor, dibahas di menyimpan dan merotasi kunci API.
Yang berjalan di dalam aplikasi karena itu hanya sebuah panggilan ke backend penjual, dengan token sesi milik aplikasi itu sendiri:
// lib/pembayaran_api.dart
// Aplikasi hanya berbicara dengan backend milik penjual sendiri. Tidak ada
// satu pun kredensial Kasera Pay di dalam folder lib/, di dalam berkas
// konfigurasi build, maupun di dalam --dart-define.
import 'dart:convert';
import 'package:http/http.dart' as http;
class Tagihan {
Tagihan({
required this.pesananId,
required this.checkoutUrl,
required this.kedaluwarsa,
});
final String pesananId;
final String checkoutUrl;
final DateTime kedaluwarsa;
}
Future<Tagihan> buatTagihan(String pesananId, String tokenSesi) async {
final res = await http.post(
Uri.parse('https://api.toko.example/pesanan/$pesananId/bayar'),
headers: {
'Authorization': 'Bearer $tokenSesi',
'Content-Type': 'application/json',
},
).timeout(const Duration(seconds: 20));
if (res.statusCode != 200) {
throw Exception('gagal membuat tagihan: ${res.statusCode}');
}
final body = jsonDecode(res.body) as Map<String, dynamic>;
return Tagihan(
pesananId: pesananId,
checkoutUrl: body['checkout_url'] as String,
kedaluwarsa: DateTime.parse(body['expires_at'] as String),
);
}Backend yang menerima panggilan itulah yang memegang Idempotency-Key, dan key itu disimpan bersama pesanannya, bukan dibangkitkan pada tiap percobaan. Tombol bayar yang tertekan dua kali di layar sentuh dengan jaringan lambat adalah kejadian sehari-hari, dan key yang baru pada percobaan kedua mengubahnya menjadi tagihan kedua untuk satu pesanan tanpa galat apa pun yang memberi tahu.
2. Membuka halaman pembayaran di peramban sistem
import 'package:url_launcher/url_launcher.dart';
Future<void> bukaHalamanPembayaran(Tagihan tagihan) async {
final terbuka = await launchUrl(
Uri.parse(tagihan.checkoutUrl),
// Peramban sistem, bukan WebView di dalam aplikasi. Pembeli Virtual
// Account harus berpindah ke aplikasi banknya lalu kembali, dan pembeli
// QRIS harus membuka aplikasi dompet atau banknya di perangkat yang sama.
// WebView yang mengurung halaman membuat perpindahan itu berakhir buntu.
mode: LaunchMode.externalApplication,
);
if (!terbuka) {
throw Exception('tidak ada aplikasi yang bisa membuka halaman pembayaran');
}
}Pilihan LaunchMode.externalApplication di baris itu adalah keseluruhan bagian ini. Pembayaran di Indonesia hampir selalu berakhir di aplikasi lain: pembeli Virtual Account berpindah ke aplikasi banknya untuk mentransfer ke nomor yang baru diterimanya, dan pembeli QRIS membuka aplikasi dompet atau banknya di perangkat yang sama untuk memindai kode yang sedang tampil. WebView yang mengurung halaman pembayaran di dalam aplikasi membuat perpindahan itu berakhir buntu, dan pada sebagian perangkat juga menghalangi tombol menyimpan gambar QR. Peramban sistem menjaga halaman itu tetap hidup di tab-nya sendiri, sehingga pembeli yang kembali dari aplikasi banknya menemukan halamannya masih di tempat semula. Apa saja yang ditampilkan halaman itu, termasuk hitung mundur sampai expires_at, diuraikan di dokumentasi checkout.
Setelah pembayaran selesai, halaman itu menyediakan tombol kembali ke return_url dengan tambahan ?id=payreq_...&status=succeeded. Mengarahkan return_url ke tautan dalam milik aplikasi membuat pembeli mendarat kembali di layar pesanan alih-alih di halaman web kosong, dan kedua nilai itu berguna untuk memilih layar mana yang dibuka. Keduanya bukan bukti. URL itu bisa dicapai siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar.
3. Cara aplikasi tahu pembayaran benar-benar selesai
Jawabannya tidak berada di aplikasi. Backend penjual menerima payment.paid yang bertanda tangan, memverifikasinya, lalu memperbarui pesanannya, dan aplikasi membaca status pesanan itu dari backend yang sama. Aplikasi tidak memanggil API Kasera Pay karena panggilan itu menuntut kunci yang tidak boleh ada di sana.
// Status dibaca dari backend penjual, yang sudah diperbarui oleh webhook
// payment.paid. Aplikasi tidak pernah memanggil API Kasera Pay secara
// langsung, karena panggilan itu menuntut kunci yang tidak boleh ada di sini.
Stream<String> pantauStatus(Tagihan tagihan) async* {
var jeda = const Duration(seconds: 2);
while (DateTime.now().isBefore(tagihan.kedaluwarsa)) {
final status = await bacaStatusDariBackend(tagihan.pesananId);
yield status;
if (status != 'pending') return;
await Future<void>.delayed(jeda);
// Melebar sampai 15 detik. Pembeli Virtual Account bisa memakai puluhan
// menit sebelum benar-benar mentransfer, dan menanyakan tiap dua detik
// selama itu hanya menghabiskan baterai dan kuota tanpa mempercepat apa
// pun.
if (jeda < const Duration(seconds: 15)) jeda *= 2;
}
// Habisnya masa berlaku tidak pernah dikirim sebagai webhook, jadi batas
// ini dijalankan dari expires_at yang sudah dipegang aplikasi.
yield 'expired';
}Jeda yang melebar bukan penghematan yang manis. Pembeli Virtual Account sering menunda transfernya sampai puluhan menit setelah nomornya diterima, dan menanyakan status tiap dua detik selama itu menghabiskan baterai dan kuota tanpa mempercepat apa pun. Pemantauannya berhenti pada expires_at, karena habisnya masa berlaku tidak pernah dikirim sebagai webhook: penjadwalannya dijalankan dari nilai yang sudah dipegang sendiri. Pertimbangan memilih antara menunggu webhook dan menanyakan status berkala, di sisi backend, dibandingkan utuh di perbandingan webhook melawan menanyakan status berkala.
Satu keadaan yang wajib ditangani dan tidak bisa dicegah: aplikasi yang ditutup di tengah pembayaran. Pembeli bisa menyalin nomor Virtual Account, menutup aplikasi, lalu membayar setengah jam kemudian dari aplikasi banknya. Karena itu id pesanan dan expires_at disimpan di backend dan bukan hanya di dalam state layar, dan layar pesanan yang dibuka kembali membaca status terkini lalu melanjutkan pemantauan kalau masih menunggu. Pemenuhan pesanannya sendiri berjalan tanpa aplikasi sama sekali, karena yang menjalankannya adalah webhook yang diterima backend.
4. Sebelum kunci live dipakai
Mode tes adalah kunci tersendiri berawalan kp_test_ yang berdiri di samping kunci live kp_live_, dengan signing secret webhook sendiri, dan pembayaran mode tes tidak pernah menyentuh jalur uang. Perpindahan ke live adalah penggantian kunci di backend, bukan rilis baru aplikasi, karena aplikasinya memang tidak pernah memegang kunci apa pun. Rinciannya ada di dokumentasi mode tes, dan bentuk tanda tangan webhook beserta toleransi waktunya di dokumentasi webhook.
Empat hal yang layak dicoba, dan tiga di antaranya khas aplikasi mobile.
- Bayar sampai selesai, lalu tutup paksa aplikasi sebelum kembali. Pesanan harus tetap terpenuhi, karena yang memenuhinya adalah webhook.
- Buka
return_urlsecara manual tanpa membayar apa pun. Layar yang muncul harus tetap layar menunggu, bukan layar berhasil. - Tekan tombol bayar dua kali secepat mungkin. Yang terbit harus satu tagihan, bukan dua, dan itulah yang dijaga
Idempotency-Keydi backend. - Biarkan satu pembayaran melewati masa berlakunya tanpa dibayar. Pemantauan di aplikasi harus berhenti sendiri dan layarnya menawarkan penerbitan ulang, bukan berputar selamanya.
Pertanyaan yang sering muncul
Apakah Kasera Pay punya SDK Flutter atau paket Dart resmi?
Tidak ada, dan tidak diperlukan. Bagian yang berjalan di dalam aplikasi hanya memanggil backend penjual sendiri lalu membuka sebuah URL, sehingga paket http dan url_launcher sudah cukup dan keduanya bukan paket khusus pembayaran. Bagian yang benar-benar berbicara dengan Kasera Pay berjalan di backend, dan contohnya ada pada panduan integrasi per bahasa. Ketiadaan SDK juga berarti tidak ada rilis paket yang perlu ditunggu setiap kali API bertambah, dan tidak ada pustaka pihak ketiga yang ikut masuk ke dalam aplikasi yang dipasang pembeli.
Kenapa kunci API tidak boleh disimpan di dalam aplikasi meskipun sudah diobfuscate atau dipasang lewat --dart-define?
Karena keduanya hanya menyulitkan pembacaan, bukan mencegahnya. Nilai --dart-define ikut terbakar ke dalam berkas aplikasi saat build, obfuscation mengacak nama simbol dan bukan isi string konstannya, dan berkas .env yang dijadikan aset justru tersimpan apa adanya di dalam paket aplikasi. Aplikasi yang sudah terpasang berada sepenuhnya di tangan pemiliknya dan bisa dibongkar tanpa perangkat khusus. Yang membuat akibatnya jauh lebih mahal di aplikasi mobile daripada di server adalah rotasinya: mencabut kunci server hanya perlu deploy, sementara mencabut kunci yang tertanam di aplikasi menuntut rilis baru yang harus melewati antrean peninjauan toko aplikasi, dan versi lama tetap terpasang di ponsel yang belum memperbarui.
Kenapa halaman pembayaran dibuka di peramban sistem dan bukan di WebView dalam aplikasi?
Karena pembayaran di Indonesia hampir selalu berakhir di aplikasi lain. Pembeli Virtual Account berpindah ke aplikasi banknya untuk mentransfer, dan pembeli QRIS membuka aplikasi dompet atau banknya di perangkat yang sama untuk memindai kode yang sedang tampil di layar. WebView yang mengurung halaman membuat perpindahan itu berakhir buntu, dan pada sebagian perangkat juga menghalangi tombol simpan gambar QR. Peramban sistem menyimpan halaman itu tetap hidup di tab-nya sendiri, sehingga pembeli yang kembali dari aplikasi bank menemukan halamannya masih di tempat semula.
Bolehkah kepulangan pembeli ke aplikasi dipakai sebagai tanda pembayaran berhasil?
Tidak pernah. Halaman kembali dibuka dengan tambahan ?id=payreq_...&status=succeeded, dan keduanya berguna untuk menampilkan layar yang tepat, tetapi URL itu bisa dibuka siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar sepeser pun. Penanda yang sah hanya event payment.paid bertanda tangan yang diterima backend, atau pembacaan GET /v1/transactions/{id} dari backend. Aplikasi menampilkan hasil berdasarkan status yang dibacanya dari backend penjual, bukan berdasarkan fakta bahwa layarnya terbuka kembali.
Bagaimana kalau aplikasi ditutup atau mati di tengah pembayaran?
Keadaan itu normal dan harus ditangani, bukan dicegah. Pembeli bisa menutup aplikasi setelah menyalin nomor Virtual Account, lalu membayar dari aplikasi banknya setengah jam kemudian. Karena itu id permintaan pembayaran dan expires_at disimpan bersama pesanannya di backend, bukan hanya di dalam state layar, dan layar pesanan saat aplikasi dibuka kembali membaca status terkini dari backend lalu melanjutkan pemantauan kalau masih menunggu. Pemenuhan pesanannya sendiri tidak bergantung pada aplikasi sama sekali, karena yang menjalankannya adalah webhook yang diterima backend.
Apakah Idempotency-Key dibuat di aplikasi atau di backend?
Di backend, dan disimpan bersama pesanannya. Kunci yang dibangkitkan di dalam aplikasi lalu dikirim bersama permintaan akan berubah setiap kali pembeli menekan tombol bayar lagi, dan tombol yang ditekan dua kali adalah kejadian sehari-hari pada layar sentuh dengan jaringan yang lambat. Kunci yang lahir sekali di sisi pesanan membuat percobaan kedua mengembalikan tagihan yang sama alih-alih menerbitkan tagihan kedua. Hanya header Idempotency-Key yang memberi perlindungan itu; external_id dan merchant_ref hanya label yang disimpan dan bisa difilter.