Panduan · Terbit
Integrasi QRIS di POS: memetakan pesanan kasir ke permintaan pembayaran, mengganti QR yang kedaluwarsa, dan mencetak struk hanya setelah uangnya benar-benar masuk
Memasang QRIS di software kasir memakai endpoint yang sama dengan memasangnya di website, dan tetap merupakan pekerjaan yang berbeda. Sebuah website membuat pembayaran ketika pembeli menekan bayar, lalu menunggu. Sebuah POS sudah punya pesanan yang terbuka sebelum pembayaran ada, kasir yang bisa membatalkan satu percobaan bayar lalu memulai yang lain, printer struk yang tidak boleh menyala lebih awal, dan shift yang harus ditutup terhadap sesuatu. Panduan ini menyusun urutan itu. Kontrak API-nya ada di dokumentasi Direct API QRIS, dan alur web yang umum ada di panduan integrasi QRIS di website.
Cakupannya satu hal: penjual yang menerima pembayaran untuk usahanya sendiri, termasuk jaringan toko di bawah satu akun. Panduan ini tidak membahas marketplace atau agregasi submerchant, yaitu menerima uang atas nama penjual lain, karena itu bentuk kerja sama yang berbeda dan tidak bisa dibangun hanya dengan memakai API ini.
Tiga hal yang sering disatukan, dan sebaiknya tidak
Kesalahan struktural yang paling mahal di integrasi POS adalah memperlakukan pesanan dan pembayaran sebagai satu benda. Ada tiga:
- Pesanan adalah milik POS. Satu pesanan punya satu nomor yang tidak berubah sejak keranjang ditutup sampai shift selesai.
- Percobaan bayar adalah satu kali kasir menekan QRIS. Satu pesanan bisa punya beberapa percobaan: QR pertama kedaluwarsa, pembeli ganti metode, kasir membatalkan lalu mengulang.
- Permintaan pembayaran adalah milik Kasera Pay, satu per percobaan, dan punya
idsendiri berbentukpayreq_….
Kalau ketiganya disimpan sebagai satu baris, pesanan dengan dua percobaan akan menimpa datanya sendiri, dan tutup shift tidak bisa menjelaskan selisihnya. Simpan percobaan sebagai barisnya sendiri yang menunjuk ke pesanan.
Memetakan ke field yang ada
API ini tidak punya parameter terminal_id, dan tidak perlu ditunggu. Konteks toko, kasir, dan shift dititipkan pada field identifier yang sudah ada.
| Field | Isi dari POS | Sifatnya |
|---|---|---|
external_id | Nomor pesanan POS, tetap sama di semua percobaan | Disimpan, dikembalikan, bisa difilter. Tidak mencegah duplikat |
merchant_ref | Kode toko, terminal, dan shift, maksimum 64 karakter | Sama: label, bukan kunci. Dua create dengan nilai sama tetap jadi dua |
description | Keterangan pendek, maksimum 255 karakter | Tampil di halaman pembayaran, jadi tulis untuk pembeli |
Idempotency-Key | UUID yang dibuat POS sekali per percobaan bayar | Header, maksimum 255 byte. Satu-satunya yang mencegah pembayaran ganda |
Perbedaan antara ketiga identifier itu dijelaskan penuh di dokumentasi Idempotency. Yang perlu diingat di POS: external_id dan merchant_ref adalah label, dan satu pesanan memang bisa saja dibayar dua kali. Hanya header yang mencegahnya.
1. Kasir memilih QRIS
Buat Idempotency-Key sebelum request pertama, bukan di dalam fungsi retry. Key yang dibuat ulang setiap percobaan ulang membuat mekanismenya tidak berguna, dan tidak ada field lain yang bisa menangkapnya. Simpan key itu pada baris percobaan bayar, lalu pakai untuk setiap pengulangan request yang sama.
curl https://pay.kasera.id/v1/transactions \
-H "Authorization: Bearer kp_test_..." \
-H "Idempotency-Key: 7c1f0a6e-3b55-4d0a-9f21-2e3b8c7d1a04" \
-H "Content-Type: application/json" \
-d '{
"amount": 87000,
"description": "Toko Pusat / Kasir 2",
"external_id": "ORD-20260918-0413",
"merchant_ref": "T2-SHIFT7-A1",
"payment_methods": ["qris"]
}'Mengirim tepat satu kode di payment_methods membuat instrumennya dibuat saat create, jadi payment.qr_string sudah ikut di response dan kasir tidak perlu menunggu langkah kedua. Mengirim beberapa kode, atau tidak mengirimnya sama sekali, berarti pembeli belum memilih metode, sehingga belum ada QR yang dibuat. Di meja kasir yang memilih adalah kasir, jadi kirim satu kode.
{
"id": "payreq_9b2f...",
"status": "pending",
"amount": 87000,
"payment_method": "qris",
"payment": {
"type": "qr",
"qr_string": "00020101021226670016COM.KASERA.WWW...6304A1B2"
},
"instructions": {
"title": "Cara membayar dengan QRIS",
"steps": ["..."]
},
"expires_at": "2026-09-18T13:00:00+07:00"
}201 berarti baru dibuat dan 200 berarti key itu sudah menempel pada permintaan pembayaran sebelumnya, yang dikembalikan apa adanya. Keduanya berhasil. Yang tidak boleh dilakukan kode POS adalah menganggap 200 sebagai kegagalan lalu membuat permintaan lagi. Mengirim key yang sama dengan body berbeda ditolak 409 idempotency_conflict, dan itu biasanya tanda nominal pesanan berubah setelah key dibuat: mulai percobaan baru dengan key baru.
2. Layar kasir
payment.qr_string adalah payload EMV mentah, bukan gambar. Ubah menjadi QR dengan pustaka apa pun di layar kasir atau di layar pelanggan. Tampilkan blok instructions apa adanya: blok itu ditulis untuk pembeli, dalam bahasa Indonesia.
Hitung mundurnya dihitung dari expires_at, bukan dari timer lokal yang dimulai saat layar dibuka. Keduanya berbeda begitu ada jeda jaringan, dan yang berlaku adalah waktu di response. Saat hitung mundur habis, sembunyikan QR-nya. QR yang sudah lewat expires_at gagal di aplikasi bank pembeli, bukan di sisi kami, jadi yang dilihat pelanggan adalah kegagalan tanpa penjelasan dan yang disalahkan adalah kasirnya. Kalau pembeli masih mau membayar, buat permintaan pembayaran baru dengan key baru, dan pesanan yang sama tetap membawa external_id yang sama.
Satu aturan yang mudah dilanggar tanpa sengaja: jangan pernah menampilkan ulang QR dari percobaan sebelumnya, misalnya karena layar dibuka kembali dari riwayat pesanan. Simpan status percobaan, dan tampilkan QR hanya untuk percobaan yang masih pending dan belum lewat expires_at.
3. Konfirmasi
Konfirmasinya datang lewat webhook payment.paid. Tiga hal harus benar sekaligus, dan urutannya penting.
- Verifikasi tanda tangan atas body mentah. Header
Kasera-Signature-V1berisit=<unix>,v1=<hex>, denganv1berupa HMAC-SHA256 atast + "." + rawBody. Tolak pengiriman yangt-nya melenceng lebih dari lima menit. Setelah rotasi secret, header membawa dua entriv1selama 24 jam, jadi terima kalau salah satu cocok. - Dedupe berdasarkan id event. Pengiriman bersifat at-least-once: event yang sama bisa datang lebih dari sekali dan selalu membawa
idyang sama. - Pindahkan status pesanan dalam satu transaksi database. Mencatat pembayaran dan menutup pesanan harus berhasil bersama-sama atau gagal bersama-sama.
// Node.js / Express. express.raw() pada route ini, sebelum express.json()
// yang global: signature dihitung atas byte mentah, dan body hanya bisa
// dibaca sekali.
app.post("/webhooks/kasera", express.raw({ type: "*/*" }), async (req, res) => {
const raw = req.body; // Buffer, belum di-parse
if (!verify(raw.toString("utf8"), req.header("Kasera-Signature-V1"), SECRET)) {
return res.sendStatus(400); // jangan 2xx: itu menghentikan retry
}
const event = JSON.parse(raw.toString("utf8"));
if (event.type !== "payment.paid") return res.sendStatus(200);
// At-least-once: event yang sama bisa datang lagi dengan id yang sama.
// INSERT yang gagal karena unique index adalah dedupe-nya, bukan SELECT
// lebih dulu lalu INSERT — dua pengiriman bersamaan lolos dari celah itu.
const fresh = await db.insertEventIfNew(event.id); // unique index di event_id
if (!fresh) return res.sendStatus(200);
// Satu transaksi database: pembayaran tercatat dan pesanan berpindah status
// bersama-sama, atau tidak sama sekali.
await db.tx(async (t) => {
await t.markPaymentPaid(event.data.payment_request_id, event.data.paid_at);
await t.markOrderPaid(event.data.external_id, event.data.amount);
});
res.sendStatus(200); // cetak struk setelah ini, bukan sebelumnya
});Endpoint harus menjawab 2xx. Kalau tidak, pengiriman diulang dengan exponential backoff maksimal tujuh kali dalam kurang lebih 33 jam. Itu berarti menjawab 2xx pada pengiriman yang tanda tangannya gagal adalah kesalahan: pengiriman yang sah dan tertunda pun ikut berhenti. Detail lengkapnya, termasuk lima endpoint per mode dan secret yang terpisah antara live dan test, ada di dokumentasi webhook.
Polling dipakai sebagai pemulihan yang dibatasi, bukan sebagai jalur utama: GET /v1/transactions/:id saat kasir perlu kepastian, dan saat rekonsiliasi. Kapan masing-masing tepat dibandingkan di webhook melawan menanyakan status berkala.
4. Struk dan penyerahan barang
Struk lunas dicetak setelah event sukses yang terverifikasi, tidak sebelumnya. Layar pembeli bukan bukti, dan tangkapan layar aplikasi bank juga bukan. Di antara keduanya, layar kasir menampilkan keadaan menunggu yang jujur: nominal, sisa hitung mundur, dan satu tombol untuk memeriksa status sekali lagi.
Keadaan menunggu ini yang paling sering dilupakan, dan yang paling sering dipakai. Antrean tidak berhenti karena satu pembayaran lambat, jadi kasir butuh cara meninggalkan pesanan dalam keadaan menunggu dan kembali lagi. Pesanan yang menunggu tidak boleh menahan laci kas dan tidak boleh ikut terhitung sebagai penjualan sampai statusnya berpindah.
5. Yang disimpan untuk tutup shift
Rekonsiliasi akhir shift hanya bisa dikerjakan terhadap kolom yang disimpan sejak awal. Minimal: nomor pesanan POS, id permintaan pembayaran, nominal, konteks toko dan terminal, waktu pembuatan, paid_at dari event, status akhir, dan id event yang dipakai untuk dedupe. Dengan itu, selisih antara total kasir dan total di dashboard selalu bisa ditelusuri ke barisnya.
Yang perlu disadari saat mencocokkan: biaya per transaksi tidak dipotong dari nominal pesanan, dan pencairan ke rekening bank adalah kejadian terpisah dari pembayaran. Cara membukukan keduanya ada di panduan mencatat biaya transaksi untuk pembukuan.
Enam kasus yang harus diuji sebelum go-live
Lima dari enam ini tidak akan pernah muncul saat mencoba alurnya sekali dengan tangan. Ujilah masing-masing di mode test, dan perhatikan apa yang terjadi pada pesanan, bukan hanya pada response.
| Kasus | Cara mengujinya | Yang benar |
|---|---|---|
| Pembayaran berhasil | Satu pembayaran test sampai event datang | Pesanan lunas sekali, struk tercetak sekali |
| Event ganda | Kirim ulang body dan header yang sama ke endpoint Anda | Tetap 2xx, pesanan tidak berpindah dua kali, struk tidak tercetak lagi |
| Tanda tangan salah | Ubah satu byte pada body, kirim dengan header lama | Ditolak non-2xx, tidak ada perubahan apa pun pada pesanan |
| QR kedaluwarsa lalu diganti | Biarkan satu permintaan test melewati expires_at tanpa dibayar, lalu mulai percobaan baru pada pesanan yang sama | QR lama tidak bisa dibuka lagi dari layar mana pun, pesanan tetap satu dengan dua percobaan |
Retry mengembalikan 200 | Kirim create yang sama persis dua kali dengan key yang sama | 201 lalu 200, satu permintaan pembayaran, bukan dua |
| Pembeli mengaku sudah bayar | Tahan pengiriman webhook, panggil GET /v1/transactions/:id | Status dari API yang menentukan, dan handler tetap idempoten saat event menyusul |
Kasus event ganda dan kasus tanda tangan salah adalah dua yang paling sering lolos ke produksi, karena keduanya terlihat benar saat dicoba sekali. Ujilah dengan mematahkan kodenya lebih dulu: matikan dedupe dan pastikan tes event ganda menjadi merah, lalu pasang kembali.
Sebelum menyalakan live
Endpoint webhook wajib https dan mengarah ke alamat publik, dan endpoint live serta test punya signing secret masing-masing, jadi secret test tidak akan pernah bisa memverifikasi payload live. Daftar periksa selengkapnya ada di checklist sebelum go-live. Ringkasan yang didapat dari API, metode yang aktif hari ini, dan batas defaultnya ada di halaman API QRIS untuk developer. Toko yang belum ingin menulis kode sama sekali bisa memakai halaman pembayaran permanen dengan QR cetak di meja kasir lebih dulu.
Pertanyaan yang sering muncul
Apakah ada parameter terminal_id di API Kasera Pay?
Tidak ada. Konteks toko, kasir, dan shift dititipkan pada field identifier yang sudah ada: external_id untuk nomor pesanan POS, merchant_ref untuk referensi internal seperti kode terminal dan shift, dan description untuk keterangan yang ikut tampil di halaman pembayaran. Ketiganya disimpan, dikembalikan di response, dan bisa difilter di endpoint list.
Kalau kasir membatalkan lalu mengulang pembayaran, apakah Idempotency-Key-nya dipakai lagi?
Tidak. Satu key mewakili satu percobaan bayar. Retry karena gangguan jaringan pada percobaan yang sama memakai key yang sama, dan itulah yang mencegah tagihan ganda. Percobaan baru setelah QR kedaluwarsa atau setelah kasir sengaja membatalkan adalah percobaan yang berbeda, jadi key-nya baru. Nomor pesanan di external_id tetap sama pada keduanya, karena external_id memang tidak mencegah duplikat.
Apakah 200 dan 201 pada create berarti hal yang berbeda?
Ya, dan perbedaannya berguna di POS. 201 berarti permintaan pembayaran baru saja dibuat. 200 berarti Idempotency-Key itu sudah pernah menempel pada sebuah permintaan pembayaran dan yang dikembalikan adalah yang asli, tanpa ada yang dibuat untuk kedua kalinya. Kasir tidak perlu tahu bedanya, tetapi kode POS harus memperlakukan keduanya sebagai berhasil dan memakai QR dari response, bukan membuat permintaan lagi.
Boleh tidak menampilkan ulang QR yang sudah kedaluwarsa?
Tidak. Setelah expires_at, pindaian gagal di aplikasi bank pembeli, bukan di sisi Kasera Pay, sehingga yang terlihat pelanggan adalah kegagalan tanpa penjelasan. Sembunyikan QR-nya saat hitung mundur habis dan buat permintaan pembayaran baru kalau pembeli masih mau membayar.
Apakah layar kasir yang menunjukkan berhasil sudah cukup untuk mencetak struk?
Tidak. Layar pembeli maupun layar kasir bukan bukti. Yang menjadi penentu hanya event payment.paid yang tanda tangannya terverifikasi, atau hasil GET /v1/transactions/:id yang dipanggil dari server POS sendiri. Struk lunas dicetak setelah salah satu dari keduanya, dan sebelum itu layar kasir menampilkan keadaan menunggu.
Pembeli bilang sudah membayar tetapi webhook belum datang. Apa yang dilakukan kasir?
Panggil GET /v1/transactions/:id dari server POS untuk memastikan, dan perlakukan hasilnya sebagai jawaban. Polling hanya dipakai sebagai pemulihan yang dibatasi, bukan pengganti webhook. Kalau statusnya masih pending, uangnya memang belum terkonfirmasi di sistem kami, dan menahan barang adalah keputusan yang benar.