Integrasi payment gateway terlihat sederhana di dokumentasi: buat transaksi, arahkan pengguna ke halaman bayar, terima callback, tandai lunas. Yang tidak tertulis di sana adalah semua cara integrasi itu bisa salah, dan hampir semuanya baru muncul setelah ada uang sungguhan yang berpindah.
Halaman Redirect Bukan Bukti Pembayaran
Kesalahan paling umum, dan paling mahal, adalah menandai order lunas di halaman returnUrl. Alurnya terasa masuk akal: pengguna selesai bayar, gateway mengembalikannya ke situs kita, kita tandai lunas.
Masalahnya, halaman itu dikendalikan peramban pengguna. Ia bisa tidak pernah terbuka karena koneksi putus tepat setelah pembayaran berhasil. Ia bisa dibuka dua kali karena pengguna menekan refresh. Dan yang paling buruk, alamatnya bisa ditebak lalu dibuka langsung tanpa membayar apa pun.
Aturannya satu: returnUrl hanya untuk mengubah tampilan, callbackUrl untuk mengubah data. Halaman redirect boleh menampilkan βterima kasih, kami sedang memeriksa pembayaran Andaβ, tapi status di basis data hanya boleh berubah karena webhook dari gateway.
Verifikasi Tanda Tangan, Selalu
Endpoint callback Anda terbuka untuk umum. Siapa pun yang menebak alamatnya bisa mengirim POST yang isinya mengaku sebuah order sudah lunas.
Setiap gateway menyediakan mekanisme tanda tangan. Pola Duitku misalnya mengirim signature berupa hash dari gabungan merchant code, jumlah, order id, dan API key. Verifikasinya wajib dilakukan sebelum satu baris pun data disentuh:
import crypto from 'node:crypto';
function verifySignature(body, apiKey) {
const { merchantCode, amount, merchantOrderId, signature } = body;
const expected = crypto
.createHash('md5')
.update(`${merchantCode}${amount}${merchantOrderId}${apiKey}`)
.digest('hex');
// Perbandingan waktu tetap, supaya lama proses tidak membocorkan
// seberapa banyak karakter awal yang sudah benar.
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(String(signature))
);
}
Dua hal yang sering terlewat di sini. Pertama, timingSafeEqual akan melempar error kalau panjang kedua buffer berbeda, jadi bungkus dengan pemeriksaan panjang lebih dulu. Kedua, jangan pernah mencatat isi signature atau API key ke log. Log sering berakhir di layanan pihak ketiga.
Kiriman Ganda Itu Normal, Bukan Bug
Gateway akan mengirim ulang callback kalau tidak menerima respons 200 dalam batas waktu tertentu. Jaringan lambat, proses restart, atau query yang kebetulan berat sudah cukup untuk memicunya. Artinya endpoint Anda pasti akan menerima notifikasi yang sama lebih dari sekali.
Kalau penanganannya naif, satu pembayaran bisa menambah stok dua kali, mengirim dua invoice, dan memotong saldo dua kali.
Solusinya idempotensi. Simpan setiap notifikasi yang masuk dengan kunci unik dari gateway, lalu tolak yang sudah pernah diproses:
await prisma.$transaction(async (tx) => {
// Kolom reference punya unique index. Kalau sudah ada, ini melempar
// dan seluruh transaksi dibatalkan tanpa efek samping apa pun.
try {
await tx.paymentEvent.create({
data: { reference: body.reference, payload: body },
});
} catch (e) {
if (e.code === 'P2002') return; // sudah pernah diproses
throw e;
}
const order = await tx.order.findUnique({
where: { id: body.merchantOrderId },
});
if (!order || order.status === 'PAID') return;
if (order.amount !== Number(body.amount)) {
throw new Error('Jumlah tidak cocok');
}
await tx.order.update({
where: { id: order.id },
data: { status: 'PAID', paidAt: new Date() },
});
await tx.stockMovement.create({ data: buildMovement(order) });
});
Perhatikan pemeriksaan order.amount !== body.amount. Tanpa itu, seseorang yang berhasil memalsukan callback bisa melunasi order sepuluh juta dengan membayar seribu. Jumlah harus dicocokkan, bukan hanya status.
Perhatikan juga semuanya ada di dalam satu transaksi basis data. Kalau pencatatan mutasi stok gagal, status order ikut dibatalkan. Order yang lunas tapi stoknya tidak berkurang jauh lebih sulit ditelusuri daripada order yang gagal sepenuhnya.
Balas Cepat, Kerjakan Belakangan
Gateway punya batas waktu, biasanya lima sampai sepuluh detik. Kalau Anda mengirim email, membuat PDF, dan menembak WhatsApp di dalam handler callback, cepat atau lambat akan ada yang melewati batas waktu, dan gateway mengirim ulang notifikasinya. Anda pun kembali ke masalah kiriman ganda.
Pola yang aman: di dalam handler, lakukan hanya yang wajib atomik, yaitu verifikasi tanda tangan, catat event, ubah status. Selebihnya dorong ke antrean.
app.post('/webhook/payment', async (req, reply) => {
if (!verifySignature(req.body, env.DUITKU_API_KEY)) {
return reply.code(401).send('invalid signature');
}
await applyPayment(req.body); // cepat, transaksional
await queue.add('post-payment', { orderId: req.body.merchantOrderId });
return reply.code(200).send('OK'); // balas secepat mungkin
});
Selalu balas 200 untuk notifikasi yang sudah berhasil diproses, termasuk yang duplikat. Membalas error pada duplikat justru membuat gateway mengirim ulang tanpa henti.
Rekonsiliasi untuk Webhook yang Tidak Pernah Datang
Ini bagian yang paling sering dilupakan. Webhook bisa hilang. Server Anda sedang mati saat gateway mencoba, atau notifikasinya tersangkut di sisi mereka. Akibatnya ada pembayaran yang sudah masuk rekening tapi ordernya masih tertulis pending, dan yang pertama tahu biasanya pelanggan yang marah.
Siapkan pekerjaan terjadwal yang menyapu order pending dan menanyakan statusnya langsung ke gateway:
// Jalan tiap sepuluh menit
const stale = await prisma.order.findMany({
where: {
status: 'PENDING',
createdAt: { lt: new Date(Date.now() - 15 * 60 * 1000) },
},
take: 100,
});
for (const order of stale) {
const remote = await gateway.checkTransaction(order.id);
if (remote.status === 'SUCCESS') {
await applyPayment(remote); // fungsi yang sama, idempoten
}
}
Karena applyPayment sudah idempoten, memanggilnya dari dua jalur sekaligus aman. Webhook menangani jalur cepat, rekonsiliasi menangani jalur yang bocor.
Menguji Sebelum Uang Sungguhan Terlibat
Endpoint callback tidak bisa diuji dari localhost begitu saja, karena gateway perlu menjangkaunya dari internet. Gunakan terowongan seperti ngrok saat development, dan simpan contoh payload asli dari mode sandbox sebagai fixture untuk pengujian otomatis.
Minimal uji empat skenario ini:
- Notifikasi sah, order berubah jadi lunas
- Notifikasi yang sama dikirim dua kali, hasil akhirnya tetap satu kali efek
- Tanda tangan salah, dijawab 401 dan data tidak berubah
- Jumlah tidak cocok dengan order, ditolak
Keempatnya murah ditulis dan menangkap hampir semua kesalahan yang berakibat pada uang.
Catatan Penutup
Yang membedakan integrasi pembayaran yang tenang dengan yang bikin begadang bukan library yang dipakai, melainkan asumsi tentang keandalan. Anggap notifikasi bisa datang dua kali, bisa datang terlambat, bisa tidak datang sama sekali, dan bisa dipalsukan. Kalau keempat asumsi itu sudah ditangani sejak awal, sisanya tinggal detail.