Panduan · Terbit · Diperbarui

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.

AspekHosted checkoutDirect API
Yang dipakai dari responscheckout_urlpayment.qr_string
Tampilan pembayaranHalaman yang kami host dan rawatHalaman sendiri, QR digambar sendiri
Instruksi cara bayarSudah ada di halamannyaIkut di instructions, penempatan diatur sendiri
Metode baru yang kami aktifkanMuncul tanpa deployPerlu penanganan per metode
Kerja frontendSatu redirectRender 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. Kedua jalur dibandingkan berdampingan, dari kode yang harus ditulis sampai siapa yang menampilkan status, di perbandingan direct dan redirect.

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 1.000 untuk QRIS, maksimum Rp 10.000.000, dan masa berlaku 60 menit dengan maksimum 24 jam. Tidak ada batas harian untuk akun terverifikasi, 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 saat webhook kedaluwarsa datang, 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

Biaya yang berjalan di belakang integrasi

Tidak ada biaya bulanan dan tidak ada biaya pendaftaran. Yang dipotong adalah tarif QRIS 0,7 persen, 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:

NominalKomponen 0,7 persenKasera PayDiterima
Rp 25.000Rp 175Rp 250Rp 24.575
Rp 150.000Rp 1.050Rp 250Rp 148.700
Rp 1.000.000Rp 7.000Rp 250Rp 992.750

Daftar periksa sebelum menyalakan mode live

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.

Kalau aplikasinya dibangun dengan Laravel, alur yang sama tersedia sebagai kode yang bisa langsung disalin, lengkap dengan test-nya, di panduan integrasi payment gateway di Laravel 13.

Kalau yang dipasang bukan website melainkan software kasir, urutannya berbeda: pesanan sudah terbuka sebelum pembayaran ada, satu pesanan bisa punya beberapa percobaan bayar, dan struk tidak boleh tercetak sebelum event sukses. Semuanya dibahas di panduan integrasi QRIS di POS.

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