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

Tutorial integrasi pembayaran QRIS di Node.js: Express dan Next.js

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

  1. Pembeli checkout. Servermu menyimpan pesanan, lalu membuat tagihan lewat API.
  2. Servermu menyimpan id tagihan (WRG-...) di pesanan dan mengarahkan pembeli ke pay_url.
  3. Pembeli memindai QRIS yang nominalnya sudah terisi dari aplikasi bank atau e-wallet apa pun.
  4. 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:

.env
WAROENGPAY_API_KEY=wp_test_xxxxxxxxxxxxxxxx
WAROENGPAY_WEBHOOK_SECRET=whsec_test_xxxxxxxxxxxxxxxx

Node.js 20.6 ke atas bisa membaca file ini langsung tanpa paket dotenv:

Terminal
npm install express
node --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.

waroengpay.js
// 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_limited
err.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.

server.js
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 tersimpan
const tagihan = await waroengpay("POST", "/api/payments", {
amount: order.total, // rupiah, bilangan bulat, belum termasuk kode unik
reference: order.number, // mis. INV-2041
description: `Pesanan ${order.number}`,
redirect_url: `https://tokoku.id/pesanan/${order.number}`,
}, { "Idempotency-Key": order.number }); // terklik dua kali = tagihan yang sama
await db.orders.update(order.id, { waroengpayId: tagihan.id }); // WRG-..., kunci pencocokan webhook
res.redirect(303, tagihan.pay_url);
});
  • Simpan tagihan.id di pesanan. Webhook dicocokkan lewat id ini. reference tidak unik dan bebas diisi siapa saja yang memegang API key, jadi jangan dipakai untuk mencocokkan.
  • Idempotency-Key berisi 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:

Node.js
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
});
Jangan menandai pesanan lunas hanya karena pembeli membuka 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().

server.js (di atas app.use(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 dikirim
app.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 asli
if (event.event === "payment.paid" && !(await db.events.exists(event.id))) {
const order = await db.orders.findByWaroengpayId(event.data.id); // id tagihan, BUKAN reference
if (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:

LangkahKenapa perlu
Hitung HMAC-SHA256 dari "timestamp.body" dan bandingkan dengan timingSafeEqualMemastikan kiriman benar dari WaroengPay tanpa membocorkan informasi lewat lama perbandingan
Tolak timestamp yang selisihnya lebih dari 5 menitKiriman 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 totalTanda tangan asli belum tentu untuk pesanan yang kamu kira
Simpan event.id yang sudah diprosesEvent 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:

Node.js
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.ts
// app/api/webhook/waroengpay/route.ts
import crypto from "node:crypto";
export async function POST(req: Request) {
const body = await req.text(); // body mentah
const 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 atas
return new Response(null, { status: 200 });
}

8. Sebelum go live

  • Ganti WAROENGPAY_API_KEY ke 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.paid dengan data.late = true: pembayaran setelah tagihan kedaluwarsa yang dicocokkan admin. Event ini bisa datang setelah payment.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

KodeArtinyaYang perlu dilakukan
401 invalid_api_keyAPI key salah, kosong, atau sudah dibuat ulangCek apakah .env benar-benar terbaca (misalnya lupa --env-file)
400 invalid_amountNominal bukan bilangan bulat Rp1.000 sampai Rp10.000.000Kirim number, bukan string berformat seperti "50.000"
403 account_not_activeAkun belum disetujui atau sedang nonaktifTunggu persetujuan admin atau hubungi kami
429 rate_limitedTerlalu banyak request, misalnya lebih dari 30 tagihan per menitTunggu sesuai header Retry-After (err.retryAfter di modul langkah 2)
503 amount_busyKode unik untuk nominal itu sedang habisCoba lagi beberapa saat kemudian
Webhook selalu 401Body sudah di-parse sebelum diverifikasi, atau secret mode uji/live tertukarPasang 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.

Panduan lain

Mulai terima QRIS hari ini

Tanpa biaya bulanan. Buat akun, tunggu persetujuan admin, lalu kirim tagihan pertamamu.