Tutorial integrasi pembayaran QRIS di Node.js: Express dan Next.js
Tambahkan pembayaran QRIS otomatis ke aplikasi Node.js: buat tagihan dengan fetch, terima webhook HMAC di Express atau Next.js, lalu uji di mode uji.
Tim WaroengPay · Diperbarui · 9 menit baca

Untuk menambahkan pembayaran QRIS otomatis ke aplikasi Node.js, servermu cukup melakukan dua hal: membuat tagihan lewat POST /api/payments saat pembeli checkout, lalu menerima webhook payment.paid yang ditandatangani HMAC begitu pembayaran masuk. Tutorial ini memakai Node.js 20 ke atas dengan fetch bawaan dan Express, tanpa SDK tambahan, plus versi singkat untuk route handler Next.js.
Alurnya
- Pembeli checkout. Servermu menyimpan pesanan, lalu membuat tagihan lewat API.
- Servermu menyimpan
idtagihan (WRG-...) di pesanan dan mengarahkan pembeli kepay_url. - Pembeli memindai QRIS yang nominalnya sudah terisi dari aplikasi bank atau e-wallet apa pun.
- Begitu dana terbaca di mutasi rekening, WaroengPay mengirim webhook. Servermu memverifikasi tanda tangannya, mencocokkan nominal, lalu menandai pesanan lunas.
1. Siapkan API key mode uji
Setelah akun WaroengPay-mu disetujui, buka dashboard → Integrasi & API → Kredensial dan buat API key mode uji (wp_test_...). Secret webhook mode uji ada di bagian Webhook dan baru tampil setelah kamu memasukkan password akun. Simpan keduanya di file .env yang tidak ikut ke repository:
WAROENGPAY_API_KEY=wp_test_xxxxxxxxxxxxxxxxWAROENGPAY_WEBHOOK_SECRET=whsec_test_xxxxxxxxxxxxxxxx
Node.js 20.6 ke atas bisa membaca file ini langsung tanpa paket dotenv:
npm install expressnode --env-file=.env server.js
Dengan key mode uji, seluruh alur bisa dicoba tanpa uang sungguhan. Tagihan uji tidak bisa dibayar dengan aplikasi apa pun; kamu melunasinya lewat simulasi di langkah 6.
2. Modul kecil untuk memanggil API
Satu fungsi pembungkus fetch sudah cukup. Batas waktu 15 detik mencegah checkout menggantung kalau jaringan bermasalah, dan kode error dari API ikut dibawa supaya bisa ditangani.
// Panggil API WaroengPay dari server. API key tidak boleh sampai ke browser.const BASE = "https://waroengpay.com";export async function waroengpay(method, path, body, headers = {}) {const res = await fetch(BASE + path, {method,headers: {Authorization: `Bearer ${process.env.WAROENGPAY_API_KEY}`,"Content-Type": "application/json",...headers,},body: body ? JSON.stringify(body) : undefined,signal: AbortSignal.timeout(15_000),});const data = await res.json().catch(() => null);if (!res.ok || !data) {const err = new Error(data?.error || "WaroengPay tidak bisa dihubungi");err.code = data?.code; // mis. invalid_amount, rate_limitederr.retryAfter = res.headers.get("retry-after");throw err;}return data;}
3. Buat tagihan saat checkout
Panggil API setelah pesanan tersimpan. amount harus bilangan bulat rupiah antara Rp1.000 dan Rp10.000.000; kode unik kecil ditambahkan otomatis oleh WaroengPay.
import express from "express";import { waroengpay } from "./waroengpay.js";const app = express();// ... route webhook dipasang DI SINI, sebelum express.json() (lihat langkah 5)app.use(express.json());app.post("/checkout/:orderId", async (req, res) => {const order = await db.orders.find(req.params.orderId); // pesanan yang sudah tersimpanconst tagihan = await waroengpay("POST", "/api/payments", {amount: order.total, // rupiah, bilangan bulat, belum termasuk kode unikreference: order.number, // mis. INV-2041description: `Pesanan ${order.number}`,redirect_url: `https://tokoku.id/pesanan/${order.number}`,}, { "Idempotency-Key": order.number }); // terklik dua kali = tagihan yang samaawait db.orders.update(order.id, { waroengpayId: tagihan.id }); // WRG-..., kunci pencocokan webhookres.redirect(303, tagihan.pay_url);});
- Simpan
tagihan.iddi pesanan. Webhook dicocokkan lewat id ini.referencetidak unik dan bebas diisi siapa saja yang memegang API key, jadi jangan dipakai untuk mencocokkan. Idempotency-Keyberisi nomor pesanan membuat request yang terulang mengembalikan tagihan yang sama, bukan membuat tagihan baru.- Nominal yang dibayar ada di
tagihan.amount, sudah termasuk kode unik. Kalau ditampilkan sendiri, tampilkan persis angka itu.
4. Tampilkan pembayaran
Cara paling sederhana adalah mengarahkan pembeli ke pay_url seperti di atas. Halaman bayar WaroengPay sudah berisi QR, nominal, hitung mundur, dan profil tokomu, lalu berubah sendiri begitu lunas. Bila redirect_url diisi, pembeli kembali ke tokomu 6 detik setelah lunas.
Kalau ingin QR tampil di halaman checkout sendiri, buat gambar QR dari string tagihan.qris dengan pustaka QR apa pun, misalnya paket qrcode di npm. Halaman checkout lalu menanyakan status ke servermu tiap 5 sampai 10 detik, dan servermu yang bertanya ke API:
app.get("/pesanan/:orderId/status", async (req, res) => {const order = await db.orders.find(req.params.orderId);const tagihan = await waroengpay("GET", `/api/payments/${order.waroengpayId}`);res.json({ status: tagihan.status }); // pending | paid | expired | cancelled});
redirect_url. Alamat itu bisa dibuka siapa saja. Status lunas hanya boleh berasal dari webhook yang terverifikasi atau dari API.5. Terima webhook di Express
Bagian ini yang paling sering salah di Node.js. Tanda tangan dihitung dari body mentah, persis byte yang dikirim. Kalau app.use(express.json()) dipasang lebih dulu, body sudah diubah jadi objek dan express.raw di route webhook tidak lagi menerima teks aslinya, sehingga tanda tangan selalu tidak cocok. Karena itu, daftarkan route webhook sebelum express.json().
import crypto from "node:crypto";const SECRET = process.env.WAROENGPAY_WEBHOOK_SECRET;const TEST_MODE = process.env.WAROENGPAY_API_KEY?.startsWith("wp_test_");// express.raw: req.body berupa Buffer, persis seperti yang dikirimapp.post("/webhook/waroengpay", express.raw({ type: "application/json" }), async (req, res) => {const ts = req.get("x-waroengpay-timestamp") || "";const sig = req.get("x-signature") || "";const expected = crypto.createHmac("sha256", SECRET).update(`${ts}.${req.body}`).digest("hex");const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;if (!fresh || sig.length !== expected.length ||!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {return res.sendStatus(401);}const event = JSON.parse(req.body);if (event.livemode === false && !TEST_MODE) return res.sendStatus(200); // event uji tidak menyentuh pesanan asliif (event.event === "payment.paid" && !(await db.events.exists(event.id))) {const order = await db.orders.findByWaroengpayId(event.data.id); // id tagihan, BUKAN referenceif (order && !order.paidAt && order.total === event.data.base_amount) {await db.orders.update(order.id, { paidAt: new Date() });}await db.events.save(event.id); // event yang sama bisa datang lagi}res.sendStatus(200); // balas cepat; kirim email/stok di belakang});
Yang dilakukan kode ini:
| Langkah | Kenapa perlu |
|---|---|
| Hitung HMAC-SHA256 dari "timestamp.body" dan bandingkan dengan timingSafeEqual | Memastikan kiriman benar dari WaroengPay tanpa membocorkan informasi lewat lama perbandingan |
| Tolak timestamp yang selisihnya lebih dari 5 menit | Kiriman lama yang direkam orang lain tidak bisa diputar ulang |
| Abaikan livemode: false kecuali server memakai key wp_test_ | Event mode uji tidak pernah mengubah pesanan asli |
| Cari pesanan lewat data.id dan cocokkan base_amount dengan total | Tanda tangan asli belum tentu untuk pesanan yang kamu kira |
| Simpan event.id yang sudah diproses | Event yang sama bisa terkirim lebih dari sekali |
Balas dengan status 2xx dalam 10 detik. Pekerjaan berat seperti mengirim email atau produk digital sebaiknya dijalankan di belakang. Kalau servermu gagal membalas, WaroengPay mencoba ulang beberapa kali dengan jeda yang makin panjang: 30 detik, 2 menit, 10 menit, 30 menit, lalu 2 jam. Pembahasan setiap langkah keamanannya ada di panduan webhook pembayaran.
6. Uji dengan mode uji
Webhook tagihan uji hanya dikirim ke callback_url yang diisi saat membuat tagihan; URL webhook default akun tidak dipakai. Saat menguji, tambahkan field ini ke data tagihan di langkah 3:
callback_url: "https://alamat-publikmu/webhook/waroengpay",
Lalu buka pay_url tagihan uji dan tekan Simulasikan bayar, atau panggil endpoint simulasi dari skrip:
curl -X POST https://waroengpay.com/api/payments/WRG-7K2M9QX4BD1PZ/simulate \-H "Authorization: Bearer $WAROENGPAY_TEST_KEY" \-H "Content-Type: application/json" \-d '{"action":"pay"}' # atau "expire"
Tagihan berubah menjadi paid dan webhook payment.paid dengan livemode: false dikirim ke servermu. Pakai {"action":"expire"} untuk menguji tagihan yang kedaluwarsa. Daftar skenario uji lengkapnya, termasuk webhook dobel dan tanda tangan palsu, ada di panduan testing pembayaran di sandbox.
Server masih berjalan di localhost? WaroengPay menolak alamat jaringan privat, jadi pakai tunnel seperti ngrok atau Cloudflare Tunnel untuk mendapat alamat publik sementara. Alamat webhook juga hanya boleh memakai port 80, 443, 8080, atau 8443.
7. Versi Next.js (App Router)
Di route handler Next.js, body mentah didapat dengan await req.text() sebelum di-parse. Pastikan route ini berjalan di runtime Node.js (bawaan route handler), karena kode di bawah memakai modul node:crypto. Pembuatan tagihan bisa memakai fungsi waroengpay() dari langkah 2 di Server Action atau route handler lain.
// app/api/webhook/waroengpay/route.tsimport crypto from "node:crypto";export async function POST(req: Request) {const body = await req.text(); // body mentahconst ts = req.headers.get("x-waroengpay-timestamp") ?? "";const sig = req.headers.get("x-signature") ?? "";const expected = crypto.createHmac("sha256", process.env.WAROENGPAY_WEBHOOK_SECRET!).update(`${ts}.${body}`).digest("hex");const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;if (!fresh || sig.length !== expected.length ||!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {return new Response(null, { status: 401 });}const event = JSON.parse(body);// ...pemrosesan sama seperti versi Express di atasreturn new Response(null, { status: 200 });}
8. Sebelum go live
- Ganti
WAROENGPAY_API_KEYke key live (wp_live_...) dan secret webhook ke secret live. - Isi URL webhook default di dashboard, tekan Kirim tes, dan pastikan servermu membalas 2xx. Event tes bernama
ping, jadi kode di atas cukup membalas 200 tanpa memproses apa pun. - Tangani
payment.paiddengandata.late = true: pembayaran setelah tagihan kedaluwarsa yang dicocokkan admin. Event ini bisa datang setelahpayment.expired, jadi jangan langsung ditolak. - Pastikan tidak ada jalur lain yang bisa menandai pesanan lunas selain webhook atau pengecekan status lewat API.
Error yang sering muncul
| Kode | Artinya | Yang perlu dilakukan |
|---|---|---|
401 invalid_api_key | API key salah, kosong, atau sudah dibuat ulang | Cek apakah .env benar-benar terbaca (misalnya lupa --env-file) |
400 invalid_amount | Nominal bukan bilangan bulat Rp1.000 sampai Rp10.000.000 | Kirim number, bukan string berformat seperti "50.000" |
403 account_not_active | Akun belum disetujui atau sedang nonaktif | Tunggu persetujuan admin atau hubungi kami |
429 rate_limited | Terlalu banyak request, misalnya lebih dari 30 tagihan per menit | Tunggu sesuai header Retry-After (err.retryAfter di modul langkah 2) |
503 amount_busy | Kode unik untuk nominal itu sedang habis | Coba lagi beberapa saat kemudian |
| Webhook selalu 401 | Body sudah di-parse sebelum diverifikasi, atau secret mode uji/live tertukar | Pasang route webhook sebelum express.json() dan cek secret yang dipakai |
Pertanyaan singkat
Apakah wajib memakai Express?
Tidak. Fastify, Hono, NestJS, atau server node:http biasa juga bisa, asalkan kamu bisa membaca body mentah sebelum di-parse. Logika verifikasinya sama persis.
Apakah ada SDK resmi untuk Node.js?
Tidak perlu. API-nya berupa HTTP JSON biasa dengan otentikasi Bearer, jadi fetch bawaan Node.js sudah cukup seperti contoh di atas.
Bisa tanpa webhook?
Bisa. Kalau callback_url dan URL webhook default sama-sama kosong, cek status lewat GET /api/payments/:id secara berkala. Webhook tetap disarankan karena pesanan langsung lunas tanpa menunggu giliran polling.
Referensi lengkap setiap endpoint ada di dokumentasi API, dan gambaran fitur, sandbox, keamanan, serta biayanya ada di panduan API payment gateway QRIS. Memakai PHP? Ikuti tutorial integrasi QRIS di PHP dan Laravel. Rincian biaya per transaksi ada di halaman harga.


