Panduan · Terbit
Integrasi payment gateway di React Native dan Expo: EXPO_PUBLIC_ yang menanam kunci API ke dalam bundle, return_url yang wajib https sementara aplikasi hanya bisa dibuka lewat scheme sendiri, dan status yang dibaca ulang setiap kali aplikasi kembali aktif
Aplikasi React Native atau Expo tidak pernah memanggil API Kasera Pay secara langsung. Aplikasi meminta tagihan ke backend penjual, membuka checkout_url yang dikembalikan backend dengan WebBrowser.openAuthSessionAsync, lalu membaca status pesanan dari backend yang sama. Tiga hal di bawah ini khas React Native dan menjatuhkan integrasi yang sekilas sudah benar: awalan EXPO_PUBLIC_ yang menanam kunci ke dalam bundle, return_url yang wajib https sementara aplikasi hanya bisa dibuka lewat scheme miliknya sendiri, dan timer JavaScript yang tertunda saat pembeli pergi ke aplikasi banknya.
Sisi backend memakai kontrak yang sama dengan panduan per bahasa. Untuk backend Node, SDK JavaScript resmi adalah jalur terpendek, dan versi tanpa SDK beserta route webhook-nya ada di integrasi payment gateway di Node.js dan Express. Pembaca yang membangun aplikasinya dengan Flutter menemukan susunan yang sama di integrasi payment gateway di Flutter; halaman ini membahas bagian yang hanya muncul di React Native dan Expo.
1. EXPO_PUBLIC_ adalah cara tercepat membocorkan kunci live
Pola yang paling sering terjadi: kunci ditaruh di .env sebagai KASERA_PAY_KEY, aplikasi membacanya sebagai undefined, lalu variabelnya diganti nama menjadi EXPO_PUBLIC_KASERA_PAY_KEY dan semuanya berjalan. Berjalan karena nilainya kini ditulis ke dalam kode. Satu berkas yang ditransformasikan dengan babel-preset-expo 57 dalam mode produksi menghasilkan ini:
// Ditulis di aplikasi:
const kunci = process.env.EXPO_PUBLIC_KASERA_PAY_KEY;
// Keluar dari babel-preset-expo 57 saat build produksi:
var kunci = "kp_live_..."; // string utuh, terbaca di bundle
// Tanpa awalan EXPO_PUBLIC_, nilainya tidak pernah sampai ke aplikasi:
var kunci = process.env.KASERA_PAY_KEY; // undefined saat dijalankanAwalan itu memang dirancang untuk nilai publik, dan Expo tidak menyembunyikannya. Bundle JavaScript ikut di dalam APK dan IPA, dan string literalnya tetap utuh juga setelah dikompilasi ke bytecode Hermes. Hal yang sama berlaku untuk paket yang menyuntikkan .env saat build, seperti react-native-config dan react-native-dotenv: nilainya ikut ke dalam aplikasi yang dipasang pembeli.
Yang membuatnya mahal di mobile adalah pencabutannya. Kunci server yang bocor diganti dengan satu rotasi lalu deploy. Kunci di dalam aplikasi tetap terpasang di setiap ponsel yang belum memperbarui, dan rilis penggantinya harus melewati peninjauan toko aplikasi lebih dulu. Langkah rotasinya ada di menyimpan dan merotasi kunci API. Satu pemeriksaan sebelum setiap rilis: ekspor bundle dengan npx expo export, lalu cari kp_live_ dan kp_test_ di folder dist. Hasilnya harus kosong.
Backend yang membuat tagihan menyimpan Idempotency-Key bersama pesanannya, dan memakai halaman pantul dari bagian 3 sebagai return_url:
// Backend penjual (Node.js), bukan aplikasi.
import { KaseraPay } from "kasera-pay";
const kasera = new KaseraPay(process.env.KASERA_API_KEY);
app.post("/pesanan/:id/bayar", wajibLogin, async (req, res) => {
const pesanan = await muatPesananMilik(req.user, req.params.id);
// Key lahir sekali bersama pesanan, jadi tombol bayar yang tertekan dua
// kali mengembalikan tagihan yang sama, bukan tagihan kedua.
const tx = await kasera.createTransaction(
{
amount: pesanan.total,
external_id: pesanan.nomor,
description: "Pesanan " + pesanan.nomor,
return_url: "https://toko.example/kembali/" + pesanan.nomor,
checkout: {},
},
{ idempotencyKey: pesanan.idempotencyKey },
);
await simpanTagihan(pesanan.nomor, tx.id, tx.expires_at);
res.json({ checkout_url: tx.checkout_url, expires_at: tx.expires_at });
});2. openAuthSessionAsync, bukan WebView
Ada tiga cara membuka halaman pembayaran, dan hanya satu yang memenuhi semua kebutuhan.
| Cara | Pembeli bisa pindah ke aplikasi bank | Aplikasi tahu kapan pembeli kembali |
|---|---|---|
react-native-webview | Sering buntu | Ya |
Linking.openURL | Ya | Tidak, peramban terpisah dari aplikasi |
WebBrowser.openAuthSessionAsync | Ya | Ya, promise-nya selesai saat sesi ditutup |
WebView menggoda karena seluruh alurnya tetap di dalam aplikasi, tetapi pembayaran di Indonesia hampir selalu berakhir di aplikasi lain. Pembeli Virtual Account menyalin nomor lalu membuka aplikasi banknya; pembeli QRIS di ponsel yang sama menyimpan gambar QR lalu mengunggahnya di aplikasi dompet atau banknya. WebView yang mengurung halaman membuat kedua perpindahan itu rawan buntu. Sesi peramban sistem menyimpan halaman pembayaran di tempatnya selama pembeli pergi.
import * as WebBrowser from "expo-web-browser";
import * as Linking from "expo-linking";
export async function bayar(nomor, sesi) {
const res = await mintaKeBackend("/pesanan/" + nomor + "/bayar", sesi);
const { checkout_url } = await res.json();
// Sesi peramban aman milik sistem: ASWebAuthenticationSession di iOS,
// Custom Tabs di Android. Sesi ini menutup diri saat halaman pantul
// mengalihkan ke tokoku://pesanan/..., dan pembeli tetap bisa berpindah
// ke aplikasi bank atau dompetnya lalu kembali.
await WebBrowser.openAuthSessionAsync(
checkout_url,
Linking.createURL("pesanan/" + nomor),
);
// Apa pun hasilnya (success, cancel, dismiss), yang dipercaya hanya
// status dari backend.
return bacaStatus(nomor, sesi);
}
async function mintaKeBackend(path, sesi) {
// fetch di React Native tidak punya batas waktu bawaan.
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 20000);
try {
return await fetch("https://api.toko.example" + path, {
method: "POST",
headers: { Authorization: "Bearer " + sesi },
signal: ac.signal,
});
} finally {
clearTimeout(t);
}
}3. return_url wajib https, aplikasi hanya terbuka lewat scheme sendiri
Dua aturan bertabrakan di sini. API Kasera Pay di mode live hanya menerima return_url berskema https; tokoku://pesanan/123 dijawab 422 validation_failed pada field return_url. Mode tes melonggarkannya menjadi http juga, tidak lebih, seperti diuraikan di dokumentasi mode tes. Di sisi lain, dokumentasi expo-web-browser meminta alamat kembali di iOS memakai scheme aplikasi dari app.json, bukan https.
Jembatannya adalah halaman pantul kecil di domain penjual. Halaman pembayaran menampilkan tombol kembali ke return_url setelah pembayaran selesai, dengan tambahan ?id=payreq_...&status=succeeded. Tombol itu membuka halaman pantul, dan halaman pantul mengalihkan ke tokoku://pesanan/123, alamat yang ditunggu sesi peramban sehingga sesinya menutup diri dan aplikasi kembali ke depan.
// Halaman pantul di domain penjual. Inilah return_url-nya.
app.get("/kembali/:nomor", (req, res) => {
// Hanya nomor pesanan yang diteruskan, dan tujuannya selalu scheme milik
// aplikasi sendiri. Tujuan yang diambil dari query string menjadikan
// halaman ini pengalih terbuka.
const nomor = encodeURIComponent(req.params.nomor);
const keAplikasi = "tokoku://pesanan/" + nomor;
res.type("html").send(`<!doctype html>
<meta name="viewport" content="width=device-width">
<p>Kembali ke aplikasi untuk melihat status pesanan.</p>
<p><a href="${keAplikasi}">Buka aplikasi Toko</a></p>
<script>location.replace(${JSON.stringify(keAplikasi)});</script>`);
});Tautan yang terlihat di halaman itu bukan hiasan. Pengalihan otomatis lewat skrip ke scheme aplikasi bisa ditahan peramban di sebagian perangkat Android, dan tautan yang diketuk pembeli selalu berhasil. Tujuan pengalihan juga sengaja dirakit dari nomor pesanan saja, bukan diambil dari parameter query, supaya halaman ini tidak bisa dipakai mengalihkan orang ke alamat sembarang.
4. Status dibaca dari backend, dan dibaca ulang saat aplikasi aktif lagi
Hasil openAuthSessionAsync hanya menjawab apakah sesi peramban ditutup lewat scheme aplikasi atau oleh pembeli. Hasil itu tidak menjawab apakah uangnya masuk. Backend menerima payment.paid bertanda tangan dan memperbarui pesanan; tagihan yang habis masa berlakunya dikirim sebagai payment.expired ke endpoint yang berlangganan event itu. Bentuk tanda tangan dan daftar eventnya ada di dokumentasi webhook. Aplikasi hanya membaca status pesanan dari backend.
import { useEffect, useState } from "react";
import { AppState } from "react-native";
export function useStatusPesanan(nomor, sesi, kedaluwarsa) {
const [status, setStatus] = useState("pending");
useEffect(() => {
let jeda = 2000;
let timer;
let berhenti = false;
async function periksa() {
if (berhenti) return;
const s = await bacaStatus(nomor, sesi); // dari backend sendiri
setStatus(s);
if (s !== "pending" || Date.now() > Date.parse(kedaluwarsa)) return;
timer = setTimeout(periksa, jeda);
jeda = Math.min(jeda * 2, 15000);
}
// Pembeli Virtual Account meninggalkan aplikasi untuk mentransfer.
// Saat kembali, timer JavaScript bisa sudah lama tertunda, jadi status
// dibaca ulang seketika dan jedanya diulang dari awal.
const sub = AppState.addEventListener("change", (s) => {
if (s !== "active") return;
clearTimeout(timer);
jeda = 2000;
periksa();
});
periksa();
return () => {
berhenti = true;
clearTimeout(timer);
sub.remove();
};
}, [nomor, sesi, kedaluwarsa]);
return status;
}Bagian AppState adalah yang paling sering terlewat. Pembeli Virtual Account meninggalkan aplikasi untuk mentransfer, dan selama aplikasi di latar belakang, sistem operasi bebas menunda timer JavaScript. Tanpa pembacaan ulang saat aplikasi kembali active, pembeli yang sudah membayar menatap layar menunggu sampai timer berikutnya kebetulan berjalan. Jeda yang melebar sampai 15 detik menjaga baterai dan kuota selama pembeli belum bergerak, dan pemantauan berhenti di expires_at.
5. Sebelum kunci live dipasang di backend
Mode tes memakai kunci kp_test_ dengan signing secret webhook sendiri, dan tidak ada uang yang berpindah. Perpindahan ke live hanya penggantian kunci di backend, tanpa rilis aplikasi baru. Lima hal yang layak dicoba, di development build, bukan Expo Go:
- Cari
kp_di hasilnpx expo export. Tidak boleh ada satu pun. - Bayar sampai selesai lalu ketuk tombol kembali. Sesi peramban harus menutup diri dan layar pesanan menampilkan status dari backend.
- Salin nomor Virtual Account, pindah ke aplikasi lain selama beberapa menit, selesaikan pembayaran tes, lalu kembali. Status harus berubah tanpa menunggu timer.
- Buka
tokoku://pesanan/123secara manual tanpa membayar. Layar harus tetap menunggu. - Ketuk tombol bayar dua kali secepat mungkin. Yang terbit harus satu tagihan, karena backend memakai
Idempotency-Keyyang sama.
Pertanyaan yang sering muncul
Apakah SDK JavaScript resmi Kasera Pay bisa dipasang di aplikasi React Native?
Bukan di aplikasinya. Paket kasera-pay dibuat dengan kunci API sebagai argumen konstruktor dan ditujukan untuk Node 20.19+, Deno, Bun, dan edge runtime, jadi memasangnya di aplikasi berarti menaruh kunci di dalam bundle yang bisa dibongkar siapa saja. Tempatnya di backend yang dipanggil aplikasi: di sana SDK itu memang jalur terpendek. Aplikasinya sendiri tidak butuh paket pembayaran apa pun selain expo-web-browser untuk membuka halaman pembayaran.
Kenapa return_url tidak langsung diisi tokoku://pesanan/123?
Karena API menolaknya. Di mode live, return_url wajib berskema https dan nilai lain dijawab 422 validation_failed pada field return_url. Di mode tes, http juga diterima supaya toko di localhost bisa dicoba, tetapi scheme aplikasi tetap tidak. Halaman pantul https di domain penjual menyelesaikan keduanya: halaman pembayaran mengirim pembeli ke alamat https yang sah, dan alamat itu mengalihkan ke scheme aplikasi yang ditunggu openAuthSessionAsync.
Bagaimana mencoba alur kembali ke aplikasi di Expo Go?
Tidak bisa sepenuhnya. Di Expo Go, Linking.createURL menghasilkan alamat exp:// milik Expo Go dengan sisipan /--/ di jalurnya, bukan tokoku://, sehingga halaman pantul yang mengalihkan ke scheme aplikasi tidak akan membuka Expo Go. Alur ini dicoba di development build yang membawa scheme dari app.json, atau di build rilis dengan kunci tes di backend.
Bolehkah layar berhasil ditampilkan karena aplikasi dibuka lewat tokoku://pesanan/123?
Tidak. Tautan itu bisa dibuka siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar, dan id serta status yang ditambahkan ke return_url hanyalah penunjuk navigasi. Penanda lunas hanya event payment.paid bertanda tangan yang diterima backend, atau GET /v1/transactions/{id} yang dibaca backend. Layar pesanan menampilkan status yang dibacanya dari backend penjual.