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:

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.

FieldIsi dari POSSifatnya
external_idNomor pesanan POS, tetap sama di semua percobaanDisimpan, dikembalikan, bisa difilter. Tidak mencegah duplikat
merchant_refKode toko, terminal, dan shift, maksimum 64 karakterSama: label, bukan kunci. Dua create dengan nilai sama tetap jadi dua
descriptionKeterangan pendek, maksimum 255 karakterTampil di halaman pembayaran, jadi tulis untuk pembeli
Idempotency-KeyUUID yang dibuat POS sekali per percobaan bayarHeader, 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.

  1. Verifikasi tanda tangan atas body mentah. Header Kasera-Signature-V1 berisi t=<unix>,v1=<hex>, dengan v1 berupa HMAC-SHA256 atas t + "." + rawBody. Tolak pengiriman yang t-nya melenceng lebih dari lima menit. Setelah rotasi secret, header membawa dua entri v1 selama 24 jam, jadi terima kalau salah satu cocok.
  2. Dedupe berdasarkan id event. Pengiriman bersifat at-least-once: event yang sama bisa datang lebih dari sekali dan selalu membawa id yang sama.
  3. 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.

KasusCara mengujinyaYang benar
Pembayaran berhasilSatu pembayaran test sampai event datangPesanan lunas sekali, struk tercetak sekali
Event gandaKirim ulang body dan header yang sama ke endpoint webhookTetap 2xx, pesanan tidak berpindah dua kali, struk tidak tercetak lagi
Tanda tangan salahUbah satu byte pada body, kirim dengan header lamaDitolak non-2xx, tidak ada perubahan apa pun pada pesanan
QR kedaluwarsa lalu digantiBiarkan satu permintaan test melewati expires_at tanpa dibayar, lalu mulai percobaan baru pada pesanan yang samaQR lama tidak bisa dibuka lagi dari layar mana pun, pesanan tetap satu dengan dua percobaan
Retry mengembalikan 200Kirim create yang sama persis dua kali dengan key yang sama201 lalu 200, satu permintaan pembayaran, bukan dua
Pembeli mengaku sudah bayarTahan pengiriman webhook, panggil GET /v1/transactions/:idStatus 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.

Mulai sekarang

Terima pembayaran pertama minggu ini.

Daftar gratis, buat payment link atau pakai API, dan lihat statusnya berubah otomatis. Tanpa biaya bulanan, biaya transaksi hanya untuk pembayaran yang berhasil.

Dapatkan di Google PlayDownload di App Store