Panduan · Terbit
Integrasi payment gateway di Firebase: Cloud Functions sebagai backend yang memegang kunci API, req.rawBody untuk webhook, dan Security Rules yang tidak boleh membiarkan aplikasi menulis status lunas
Aplikasi yang backend-nya Firebase memasang Kasera Pay dengan dua Cloud Functions: satu fungsi callable yang membuat permintaan pembayaran, dan satu fungsi HTTP yang menerima webhook. Aplikasi Flutter atau web tidak pernah memegang API key dan tidak pernah menulis status pembayaran. Tiga hal khas Firebase menentukan hasilnya: webhook harus diverifikasi dari req.rawBody, total harus dibaca dari dokumen yang ditulis server, dan Security Rules harus menutup penulisan dokumen pesanan dari klien. Tanpa yang terakhir, siapa pun yang login bisa menandai pesanannya sendiri lunas dari konsol peramban.
Contoh di bawah memakai Cloud Functions generasi kedua dengan TypeScript, Firestore, dan SDK JavaScript resmi (kasera-pay), yang menjadi jalur terpendek karena verifikasi tanda tangannya sudah tersedia. Sisi aplikasinya, termasuk alasan checkout dibuka di peramban sistem dan bukan WebView, dibahas di panduan integrasi Flutter.
Alurnya, sebelum menulis kode
Aplikasi memanggil bayarPesanan dengan id pesanan. Fungsi itu membaca total dari Firestore, membuat permintaan pembayaran, lalu mengembalikan checkout_url. Pembeli membayar dengan QRIS atau Virtual Account delapan bank. Kasera Pay mengirim payment.paid bertanda tangan ke kaseraWebhook, fungsi itu memperbarui dokumen pesanan, dan aplikasi yang mendengarkan dokumen tersebut lewat onSnapshot langsung menampilkan status lunas. Tidak ada polling dari aplikasi, dan kepulangan pembeli ke return_url tidak dianggap bukti apa pun. Panduan ini memakai tiga dari lima status permintaan pembayaran di Kasera Pay, yaitu pending, succeeded, dan expired; dua lainnya, canceled dan failed, tidak pernah menjadi lunas. payment.paid adalah nama event-nya.
1. Persiapan proyek dan rahasia
# Cloud Functions hanya bisa di-deploy pada paket Blaze.
firebase init functions # pilih TypeScript
cd functions
npm install kasera-pay # SDK resmi, butuh Node 20.19 ke atas
# Disimpan di Secret Manager, tidak pernah masuk repositori.
firebase functions:secrets:set KASERA_PAY_KEY # kp_test_... dulu
firebase functions:secrets:set KASERA_PAY_WEBHOOK_SECRET # whsec_... mode yang sama
# functions/package.json
# "engines": { "node": "22" }Dua rahasia disimpan dengan defineSecret, bukan di berkas .env fungsi yang ikut ter-deploy apa adanya. Setiap fungsi hanya bisa membaca rahasia yang ia sebut di opsi secrets, jadi fungsi lain di proyek yang sama tidak ikut memegang kunci pembayaran. Mengganti nilai rahasia tidak mengubah fungsi yang sedang berjalan: setelah secrets:set, fungsi yang memakainya harus di-deploy ulang. Hal itu penting saat pindah dari kp_test_ ke kp_live_.
2. Fungsi callable yang membuat permintaan pembayaran
// functions/src/bayar.ts
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
import { getFirestore } from "firebase-admin/firestore";
import { KaseraPay, KaseraPayError } from "kasera-pay";
const KASERA_PAY_KEY = defineSecret("KASERA_PAY_KEY");
export const bayarPesanan = onCall(
{ region: "asia-southeast2", secrets: [KASERA_PAY_KEY] },
async (request) => {
const uid = request.auth?.uid;
if (!uid) throw new HttpsError("unauthenticated", "Masuk dulu.");
const orderId = String(request.data?.orderId ?? "");
if (!orderId) throw new HttpsError("invalid-argument", "orderId kosong.");
const ref = getFirestore().collection("orders").doc(orderId);
const order = (await ref.get()).data();
// Total dibaca dari dokumen yang ditulis server. Angka dari aplikasi
// tidak pernah dipakai, karena aplikasi bisa mengirim angka apa saja.
if (!order || order.uid !== uid) throw new HttpsError("not-found", "Pesanan tidak ada.");
if (order.status === "LUNAS") throw new HttpsError("failed-precondition", "Sudah lunas.");
const kasera = new KaseraPay(KASERA_PAY_KEY.value());
try {
const tx = await kasera.createTransaction(
{
amount: order.total, // integer, rupiah utuh
description: "Pesanan " + orderId,
external_id: orderId,
customer: { name: order.customerName },
return_url: "https://toko.example/pesanan/" + orderId,
checkout: {},
},
// Ketukan kedua, atau percobaan ulang setelah jaringan putus,
// mengembalikan permintaan yang sama. Nomor percobaan naik saat
// permintaan sebelumnya kedaluwarsa, supaya pembeli bisa membayar lagi.
{ idempotencyKey: "order-" + orderId + "-" + (order.attempt ?? 0) },
);
await ref.update({ paymentRequestId: tx.id, status: "MENUNGGU" });
return { checkoutUrl: tx.checkout_url };
} catch (e) {
if (e instanceof KaseraPayError) {
throw new HttpsError("internal", "Pembayaran gagal dibuat: " + e.code);
}
throw e;
}
},
);Dokumen pesanan, termasuk total-nya, dibuat oleh fungsi lain yang membaca harga dari koleksi produk, bukan oleh aplikasi. Kalau aplikasi yang menulis total, pembeli yang sedikit paham bisa membuat pesanan Rp 1.000 untuk barang Rp 500.000 dan membayarnya dengan sah. Fungsi bayarPesanan hanya menerima id, memeriksa bahwa pesanan itu milik request.auth.uid, lalu memakai angka yang sudah tersimpan.
Kunci idempotensinya dibentuk dari id pesanan dan nomor percobaan. Id saja tidak cukup: key yang sama dengan body yang sama selalu mengembalikan permintaan asli, termasuk yang sudah kedaluwarsa, sehingga pembeli yang kembali setelah 60 menit tidak akan pernah bisa membayar lagi. Nomor percobaan dinaikkan oleh webhook saat payment.expired tiba. Kunci baru pada setiap ketukan juga salah, karena ketukan ganda di layar sentuh lalu menjadi dua permintaan pembayaran. Hanya header Idempotency-Key yang mencegah duplikat; external_id sekadar label. Latar belakangnya ada di idempotency untuk pembayaran.
Region adalah jebakan yang paling sering di sisi klien. Fungsi di atas di-deploy ke asia-southeast2 (Jakarta), sedangkan klien Firebase memanggil us-central1 kalau region tidak disebut, dan hasilnya galat not-found yang terlihat seperti fungsi belum ter-deploy.
// Flutter: region di klien harus sama dengan region fungsinya.
final res = await FirebaseFunctions.instanceFor(region: 'asia-southeast2')
.httpsCallable('bayarPesanan')
.call({'orderId': orderId});
await launchUrl(
Uri.parse(res.data['checkoutUrl'] as String),
mode: LaunchMode.externalApplication,
);3. Fungsi webhook
// functions/src/webhook.ts
import { onRequest } from "firebase-functions/v2/https";
import { defineSecret } from "firebase-functions/params";
import { getFirestore, FieldValue } from "firebase-admin/firestore";
import { constructWebhookEvent, SignatureError } from "kasera-pay";
const WEBHOOK_SECRET = defineSecret("KASERA_PAY_WEBHOOK_SECRET");
export const kaseraWebhook = onRequest(
{ region: "asia-southeast2", secrets: [WEBHOOK_SECRET] },
async (req, res) => {
if (req.method !== "POST") {
res.status(405).end();
return;
}
let event;
try {
// req.rawBody adalah Buffer berisi byte persis yang dikirim.
// req.body sudah di-parse dan tidak bisa dipakai untuk verifikasi.
event = await constructWebhookEvent(
req.rawBody.toString("utf8"),
req.get("kasera-signature-v1") ?? "",
WEBHOOK_SECRET.value(),
);
} catch (e) {
if (e instanceof SignatureError) {
res.status(400).send("bad signature");
return;
}
throw e;
}
// Tipe kasera-pay 0.1.0 baru mengenal payment.paid dan test.ping,
// jadi event dibaca lewat bentuk umum supaya payment.expired ikut
// lolos tsc. test.ping tidak membawa data dan berhenti di sini.
const type: string = event.type;
const data =
"data" in event
? (event.data as {
external_id?: string | null;
payment_request_id?: string;
paid_at?: string | null;
})
: undefined;
const orderId = data?.external_id;
if (!data || !orderId) {
res.status(200).send("ignored");
return;
}
const db = getFirestore();
const eventRef = db.collection("kaseraEvents").doc(event.id);
const orderRef = db.collection("orders").doc(orderId);
try {
await db.runTransaction(async (tx) => {
const [seen, order] = await Promise.all([tx.get(eventRef), tx.get(orderRef)]);
if (seen.exists) return; // pengiriman ulang event yang sama
tx.set(eventRef, { type, at: FieldValue.serverTimestamp() });
if (!order.exists) return;
if (type === "payment.paid") {
// Uang yang masuk selalu menang, termasuk dari percobaan lama.
tx.update(orderRef, {
status: "LUNAS",
paidAt: data.paid_at ? new Date(data.paid_at) : FieldValue.serverTimestamp(),
});
} else if (
type === "payment.expired" &&
order.get("status") !== "LUNAS" &&
order.get("paymentRequestId") === data.payment_request_id
) {
tx.update(orderRef, { status: "KEDALUWARSA", attempt: FieldValue.increment(1) });
}
});
res.status(200).send("ok");
} catch (e) {
console.error(e);
res.status(500).send("retry"); // Kasera Pay mengulang pengirimannya
}
},
);Cloud Functions mem-parse body JSON sebelum handler dipanggil, tetapi tetap menyimpan byte aslinya di req.rawBody. Kasera Pay menandatangani byte itu, dan constructWebhookEvent memeriksa header Kasera-Signature-V1 terhadapnya, termasuk toleransi waktu dan dua entri v1 selama rotasi signing secret. Menyusun ulang req.body dengan JSON.stringify menghasilkan isi yang sama dengan byte yang berbeda, dan setiap pengiriman sah akan ditolak.
Pengiriman bersifat at-least-once, jadi event yang sama bisa datang dua kali. Transaksi Firestore membaca dokumen event dan dokumen pesanan lebih dulu; dua pengiriman yang berbarengan tidak bisa sama-sama melihat event itu belum ada, karena transaksi yang kalah menunggu atau diulang, lalu menemukan dokumen yang sudah ditulis yang menang. Galat apa pun dijawab 500, dan jawaban selain 2xx membuat Kasera Pay mengulang pengiriman sampai tujuh kali dalam kurang lebih 33 jam. Cold start yang membuat jawaban pertama lambat tidak merusak apa pun karena alasan yang sama.
Pemeriksaan paymentRequestId pada cabang kedaluwarsa menjaga urutan antar percobaan. Tanpa itu, payment.expired dari percobaan pertama yang terlambat tiba bisa menandai kedaluwarsa pesanan yang sedang menunggu pembayaran percobaan kedua. payment.paid tidak diberi syarat itu: uang yang sudah masuk selalu menang, dan dua pembayaran untuk satu pesanan diselesaikan manual. Event kedaluwarsa hanya dikirim ke endpoint yang mencentangnya; endpoint lama menerima payment.paid saja sampai diubah. Rinciannya ada di referensi webhook.
URL yang didaftarkan di dashboard, menu Developer, adalah URL kaseraWebhook yang dicetak oleh firebase deploy.
4. Security Rules: klien membaca, server yang menulis
// firestore.rules
match /orders/{orderId} {
allow read: if request.auth != null && resource.data.uid == request.auth.uid;
allow write: if false; // hanya Cloud Functions (Admin SDK) yang menulis
}
match /kaseraEvents/{eventId} {
allow read, write: if false;
}Admin SDK di Cloud Functions melewati Security Rules, jadi menutup seluruh penulisan dari klien tidak menghalangi kedua fungsi di atas. Yang tertutup adalah jalur yang sering terbuka tanpa disadari: aturan allow write: if request.auth != null dari tahap prototipe, yang membuat status, total, dan nomor percobaan bisa diubah oleh pemilik pesanan sendiri. Kalau aplikasi memang perlu menulis sebagian kolom pesanan, misalnya catatan pengiriman, izinkan kolom itu saja dengan request.resource.data.diff(resource.data).affectedKeys().hasOnly([...]).
5. Menguji sebelum kunci live dipasang
Dengan kunci kp_test_, halaman checkout menampilkan tombol simulasi, dan hasil berhasil maupun kedaluwarsa juga bisa dipicu lewat endpoint simulasi yang dijelaskan di dokumentasi mode tes. Emulator Firebase membaca rahasia dari berkas functions/.secret.local, tetapi webhook butuh URL https publik, jadi selama memakai emulator diperlukan terowongan; caranya ada di webhook di localhost dan mode tes. Lima hal yang layak dicoba: ketukan ganda menghasilkan satu permintaan pembayaran, event yang dikirim ulang tidak mengubah apa pun, simulasi kedaluwarsa lalu bayar ulang menghasilkan permintaan baru, pembaruan status dari konsol peramban ditolak Security Rules, dan Firestore yang sengaja dibuat gagal menghasilkan 500, bukan 200.
Pertanyaan yang sering muncul
Bisakah Kasera Pay dipanggil langsung dari aplikasi Flutter atau web tanpa Cloud Functions?
Tidak boleh. Membuat permintaan pembayaran membutuhkan API key rahasia, dan apa pun yang ikut di dalam aplikasi terpasang atau bundle JavaScript bisa dibaca siapa saja. Pada proyek Firebase, Cloud Functions adalah tempat paling dekat untuk menyimpan kunci itu tanpa menyewa server sendiri. Aplikasi hanya memanggil fungsi callable dan membuka checkout_url yang dikembalikannya.
Kenapa verifikasi tanda tangan gagal padahal signing secret-nya benar?
Biasanya karena yang diverifikasi adalah JSON.stringify(req.body). Cloud Functions sudah mengubah body menjadi objek sebelum handler dipanggil, dan menyusunnya kembali menghasilkan byte yang berbeda dari yang ditandatangani. Pakai req.rawBody. Penyebab kedua yang sering: signing secret mode tes dipasang untuk payload live, atau sebaliknya, karena keduanya endpoint terpisah dengan secret masing-masing.
Apakah Firebase Extensions atau package khusus Firebase diperlukan?
Tidak ada extension Kasera Pay, dan tidak diperlukan. Yang dipakai hanya SDK JavaScript resmi (npm install kasera-pay) di dalam Cloud Functions, ditambah firebase-admin dan firebase-functions yang sudah terpasang oleh firebase init. Untuk aplikasi Flutter tidak ada SDK Dart, dan memang tidak dibutuhkan karena aplikasi tidak pernah berbicara langsung dengan Kasera Pay.
Apakah paket Spark yang gratis cukup?
Tidak untuk panduan ini. Cloud Functions hanya bisa di-deploy pada paket Blaze. Blaze tetap membawa kuota gratis bulanan untuk Cloud Functions, tetapi paketnya harus diaktifkan dengan akun penagihan lebih dulu.