Panduan · 29 Agustus 2026
Integrasi QRIS di website: dari nol sampai pembayaran terkonfirmasi otomatis
Integrasi QRIS di website punya tiga bagian yang bergerak, dan selebihnya adalah detail: server membuat permintaan pembayaran, pembeli dibawa ke tampilan pembayaran, lalu sebuah webhook bertanda tangan memberi tahu server bahwa uangnya sudah masuk. Panduan ini menyusun ketiganya secara berurutan, dengan kontrak API yang berlaku hari ini, sampai bagian yang paling sering menjatuhkan integrasi pada hari pertama go-live. Ringkasan apa yang didapat dari API, tarif per metode, dan batas defaultnya ada di halaman API QRIS untuk developer.
Keputusan pertama: hosted checkout atau Direct API
Ada dua cara menampilkan pembayaran, dan pilihannya menentukan berapa banyak kode yang harus ditulis. Keduanya memakai endpoint create yang sama; bedanya hanya pada apa yang dipakai dari responsnya.
| Aspek | Hosted checkout | Direct API |
|---|---|---|
| Yang dipakai dari respons | checkout_url | payment.qr_string |
| Tampilan pembayaran | Halaman yang kami host dan rawat | Halaman sendiri, QR digambar sendiri |
| Instruksi cara bayar | Sudah ada di halamannya | Ikut di instructions, penempatan diatur sendiri |
| Metode baru yang kami aktifkan | Muncul tanpa deploy | Perlu penanganan per metode |
| Kerja frontend | Satu redirect | Render QR, hitung mundur, muat ulang status |
Untuk toko yang sudah punya alur checkout sendiri dan hanya perlu langkah pembayaran, hosted checkout adalah pilihan yang paling masuk akal: satu redirect, dan halaman pembayarannya ikut diperbarui tanpa rilis di sisi penjual. Detail alur dan langkah yang bisa diatur ada di dokumentasi Kasera Pay Checkout. Direct API masuk akal kalau QR harus tampil di dalam halaman sendiri, misalnya pada aplikasi kasir atau layar pemesanan yang tidak boleh berpindah halaman. Panduannya ada di dokumentasi QRIS Direct API. Satu catatan yang berlaku hari ini dan berpengaruh kalau nanti Virtual Account ikut dipasang: hosted checkout kini menuntaskan ketiga bentuk metode, termasuk Virtual Account yang tampil sebagai nomor pembayaran beserta petunjuknya, jadi memilih Direct API untuk metode itu adalah keputusan tentang tampilan di halaman sendiri, bukan lagi keharusan.
Langkah 1: kunci API dan mode tes
Kunci dibuat di dashboard pada menu Developer, dan dikirim sebagai header Authorization: Bearer. Kunci live berawalan kp_live_ dan kunci tes berawalan kp_test_. Kunci live tidak boleh berada di kode frontend atau repositori publik, karena siapa pun yang memegangnya bisa membuat permintaan pembayaran atas nama akun itu. Semua panggilan API berasal dari server.
Seluruh integrasi dibangun memakai kunci tes lebih dulu. Tidak ada konfigurasi khusus: create dengan kp_test_ menghasilkan permintaan pembayaran mode tes, dan pembayarannya dituntaskan lewat endpoint simulasi POST /api/v1/checkout/{token}/simulate-payment dengan body { "outcome": "succeeded" } atau expired. Mode live dan mode tes adalah dua endpoint webhook terpisah dengan signing secret masing-masing, jadi secret tes tidak akan pernah memverifikasi payload live.
Langkah 2: membuat permintaan pembayaran dari server
Satu panggilan POST /v1/transactions. Nominal ditulis dalam rupiah utuh tanpa desimal, dan identitas amount = fee + net selalu berlaku pada responsnya.
curl https://pay.kasera.id/v1/transactions \
-H "Authorization: Bearer kp_test_..." \
-H "Idempotency-Key: order-1234" \
-H "Content-Type: application/json" \
-d '{
"amount": 150000,
"description": "Kaos komunitas",
"external_id": "ORD-1234",
"payment_methods": ["qris"],
"return_url": "https://toko.example/selesai"
}'Header Idempotency-Key adalah satu-satunya hal yang mencegah permintaan yang terkirim dua kali menjadi dua tagihan. Nilainya harus dibuat sebelum percobaan pertama dan dipakai ulang pada setiap retry-nya, bukan dibuat baru setiap kali mengirim. Bagian ini punya pembahasan tersendiri di tagihan ganda dan Idempotency-Key, termasuk kenapa merchant_ref dan external_id tidak menutup celah yang sama.
Respons 201 membawa keduanya sekaligus, jalur hosted dan jalur Direct API:
{
"id": "payreq_9b2f...",
"status": "pending",
"amount": 150000,
"checkout_url": "https://pay.kasera.id/p/xK3f...",
"payment_method": "qris",
"payment": {
"type": "qr",
"qr_string": "00020101021226670016COM.KASERA.WWW...6304A1B2"
},
"expires_at": "2026-08-29T13:00:00+07:00"
}Batas default yang berlaku saat pembuatan perlu dicek terhadap nominal nyata di katalog produk: minimum Rp 10.000, maksimum Rp 10.000.000, 100 permintaan per hari, total Rp 50.000.000 per hari, dan masa berlaku 60 menit dengan maksimum 24 jam. Hari dihitung dalam zona waktu Asia/Jakarta, bukan UTC, dan angkanya dapat berbeda per akun. Daftar lengkap beserta kode error penolakannya ada di dokumentasi batas dan laju. Konsekuensi praktis dari masa berlaku 60 menit: permintaan dibuat saat pembeli menekan tombol bayar, bukan saat keranjang dibuat. Cara memilih angkanya per situasi penjualan, dan penanganan stok karena kedaluwarsa tidak dikirim lewat webhook, dibahas terpisah di tulisan tentang masa berlaku tagihan.
Langkah 3: menampilkan pembayaran
Pada jalur hosted, seluruh langkah ini adalah satu redirect ke checkout_url. Pada Direct API, payment.qr_string adalah payload EMV mentah berupa string biasa, dan pustaka QR mana pun bisa mengubahnya menjadi gambar. Tidak ada gambar yang perlu diunduh dari sisi kami.
Dua hal yang membuat tampilan QR terasa benar bagi pembayar: hitung mundur yang bersumber dari expires_at, dan nominal yang tertulis besar di dekat QR supaya tidak ada yang mengetik ulang angka. Satu QRIS berlaku untuk satu pembayaran ini saja, jadi tidak ada langkah mengetik nominal di sisi pembayar, dan tidak ada dua pembayaran berbeda yang menghasilkan catatan yang sama.
Langkah 4: webhook, dan kenapa bukan polling
Satu URL webhook diatur di dashboard, wajib https, dan harus mengarah ke alamat publik. Saat pembayaran terkonfirmasi, kami mengirim POST berisi event payment.paid. Setiap pengiriman membawa tanda tangan bertimestamp di header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas t + "." + rawBody. Id event ada di Kasera-Event-Id. Setelah rotasi secret, header membawa dua entri v1 selama 24 jam, jadi pengiriman diterima bila salah satunya cocok.
// Node.js, sebelum payload di-parse
const crypto = require("crypto");
// Kasera-Signature-V1: t=<unix>,v1=<hex>[,v1=<hex>]
function verify(rawBody, v1Header, secret, toleranceSeconds = 300) {
const parts = v1Header.split(",");
const t = Number(parts[0].slice(2)); // "t=<unix>"
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex")
);
return parts
.slice(1)
.filter((p) => p.startsWith("v1="))
.some((p) => {
const sig = Buffer.from(p.slice(3));
return sig.length === expected.length && crypto.timingSafeEqual(sig, expected);
});
}Tiga aturan yang menentukan handler ini benar atau tidak, dan ketiganya ada di dokumentasi webhook. Pertama, verifikasi tanda tangan dilakukan sebelum payload diproses, memakai body mentah, bukan hasil parse ulang menjadi JSON: parse ulang mengubah byte-nya dan tanda tangan tidak akan cocok lagi. Timestamp t ikut diperiksa dan pengiriman yang melenceng lebih dari lima menit ditolak, karena itulah yang mencegah pengiriman yang tersadap diputar ulang. Kedua, pengiriman bersifat at-least-once, sehingga event yang sama bisa tiba lebih dari sekali dengan id yang sama, dan handler harus melakukan dedupe berdasarkan id itu sebelum menandai pesanan lunas. Ketiga, endpoint harus menjawab 2xx; kalau tidak, pengiriman diulang dengan exponential backoff sampai tujuh percobaan dalam rentang sekitar 33 jam.
Pola handler yang aman: verifikasi tanda tangan, jawab 2xx secepatnya, lalu kerjakan pekerjaan berat di belakang. Handler yang mengirim email, mencetak label, dan menulis ke tiga sistem lain sebelum menjawab akan menabrak timeout, memicu percobaan ulang, dan mengerjakan semuanya dua kali. Pembahasan yang lebih dalam tentang sisi keamanannya, termasuk kenapa tanda tangan yang sah belum berarti nominalnya cocok dengan pesanan, ada di tulisan tentang webhook pembayaran yang aman.
Kesalahan yang paling mahal saat go-live
- Menjadikan redirect sebagai bukti. Kembalinya pembeli ke
return_urladalah navigasi, dan alamat itu bisa dibuka langsung tanpa membayar. Penentu status adalah webhook yang terverifikasi atauGET /v1/transactions/:iddari server. - Menulis status yang tidak ada. Statusnya hanya
pending,succeeded, danexpired. Katapaidhanya muncul sebagai nama eventpayment.paid, tidak pernah sebagai nilai status, dan kode yang mencocokkan stringpaidpada field status tidak akan pernah cocok. - Polling status setiap beberapa detik. Batas lajunya nyata dan polling menghasilkan 429 saat ramai, tepat pada saat kepastian paling dibutuhkan. Polling dipakai sebagai cadangan pada satu titik, misalnya saat halaman terima kasih dibuka, bukan sebagai mekanisme utama.
- Menulis permanen kode metode dan tarif di dalam kode.
GET /v1/payment_methodsmengembalikan daftar yang aktif untuk akun itu beserta tarif tiap metode dalam basis poin dan nominal tetap. Sebuah metode bisa diaktifkan atau diubah tarifnya tanpa rilis di sisi penjual. - Membuat permintaan dari browser. Kunci live di frontend berarti siapa pun bisa membuat tagihan atas nama akun itu, dan tidak ada cara menariknya kembali selain merotasi kunci.
Biaya yang berjalan di belakang integrasi
Tidak ada biaya bulanan dan tidak ada biaya pendaftaran. Yang dipotong adalah MDR QRIS 0,7 persen dari jaringan pembayaran, diteruskan apa adanya tanpa markup, ditambah Rp 250 per transaksi berhasil untuk Kasera Pay. Pencairan ke rekening bank dikenakan Rp 3.000 per pencairan, bukan per transaksi, sehingga mencairkan sekali seminggu jauh lebih murah daripada mencairkan setiap hari. Angka ilustratif, dan tarif yang berlaku selalu bisa dilihat di halaman harga Kasera Pay:
| Nominal | MDR 0,7 persen | Kasera Pay | Diterima |
|---|---|---|---|
| Rp 25.000 | Rp 175 | Rp 250 | Rp 24.575 |
| Rp 150.000 | Rp 1.050 | Rp 250 | Rp 148.700 |
| Rp 1.000.000 | Rp 7.000 | Rp 250 | Rp 992.750 |
Daftar periksa sebelum menyalakan mode live
- Kunci live hanya ada di variabel lingkungan server, dan kunci tes yang sempat masuk ke repositori sudah dirotasi.
- URL webhook mode live sudah diisi terpisah dari mode tes, dan secret-nya diambil dari mode yang benar.
- Handler webhook menolak tanda tangan yang salah, dan penolakan itu sudah pernah diuji dengan sengaja mengirim tanda tangan keliru.
- Event yang sama dikirim dua kali tidak menghasilkan dua pesanan lunas, dua email, atau dua pengiriman barang.
- Nominal terkecil dan terbesar di katalog masih berada dalam batas akun, dan permintaan yang ditolak menampilkan pesan yang bisa dimengerti pembeli.
- Alur kedaluwarsa sudah dicoba lewat simulasi
expired, dan halaman pesanan menawarkan pembuatan tagihan baru, bukan menggantung tanpa penjelasan.
Pertanyaan yang sering muncul
Perlu berapa lama untuk integrasi QRIS di website?
Untuk jalur hosted checkout, bagian yang harus ditulis adalah satu panggilan POST /v1/transactions dari server dan satu handler webhook. Yang biasanya memakan waktu bukan kodenya, melainkan menyiapkan endpoint webhook yang bisa diakses publik lewat https dan menguji ulang alurnya di mode tes sebelum dipakai live.
Apakah QR bisa ditampilkan di halaman sendiri, bukan halaman checkout?
Bisa, lewat Direct API. Respons create berisi payment.qr_string, yaitu payload EMV mentah, dan payload itu bisa diubah menjadi gambar QR oleh pustaka QR mana pun di sisi server atau browser. QR berhenti berlaku pada expires_at, dan pindaian setelah itu gagal di aplikasi bank pembayar, bukan di halaman penjual.
Apakah redirect kembali ke website sudah cukup sebagai bukti pembayaran?
Tidak. Redirect ke return_url adalah navigasi, bukan bukti, dan alamatnya bisa dibuka siapa saja tanpa membayar. Yang dijadikan penentu adalah webhook payment.paid yang tanda tangannya terverifikasi, atau pemanggilan GET /v1/transactions/:id dari server sendiri.
Apa yang terjadi kalau endpoint webhook sedang mati saat pembayaran masuk?
Pengiriman diulang dengan exponential backoff sampai maksimal tujuh percobaan dalam rentang sekitar 33 jam, sampai endpoint menjawab 2xx. Karena pengiriman bersifat at-least-once, event yang sama bisa datang lebih dari sekali dengan id yang sama, jadi handler tetap harus melakukan dedupe berdasarkan id event.