Tutorial Pasang
Panduan ini buat developer brand (misalnya website AeroConvo atau AbsenKuy) yang mau menerima pembayaran QRIS atau transfer bank (Virtual Account) lewat QRISKu. Alurnya: server kamu minta pembayaran ke API, pembeli bayar di halaman checkout, lalu kami kabari server kamu lewat webhook.
Tentang nama di aplikasi bank. Semua QRIS dan Virtual Account dibuat atas akun DOKU resmi ARENAGO. Saat pembeli scan QRIS atau transfer ke nomor VA, aplikasi bank/e-wallet menampilkan nama ARENAGO. Nama brand kamu tampil di halaman checkout QRISKu, bukan di aplikasi bank. Kami tidak mengubah data QR.
#1. Ambil API key
- Minta admin QRISKu membuatkan brand dan login Portal Brand untuk kamu.
- Masuk ke Portal Brand, buka menu API & Webhook.
- Klik Lihat di baris API key, lalu Salin. Lakukan juga untuk Webhook secret.
- Simpan keduanya di file
.envserver kamu:
QRISKU_BASE_URL=https://pay.arenago.app
QRISKU_API_KEY=qk_live_xxxxxxxxxxxxxxxx
QRISKU_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx
Jangan taruh API key di kode frontend (JavaScript browser, aplikasi Android) atau repo publik. Kalau bocor, klik Buat baru di Portal Brand dan kunci lama langsung mati.
Semua endpoint memakai POST dengan header X-API-Key: <api key> (atau Authorization: Bearer <api key>). Body boleh JSON atau form (x-www-form-urlencoded). Base URL API: https://pay.arenago.app/api/v1.
#2. Buat pembayaran
POST /api/v1/payments/create
| Field | Wajib | Keterangan |
|---|---|---|
amount |
ya | Nominal dalam rupiah, angka bulat, Rp 1.000 sampai Rp 10.000.000 |
description |
tidak | Keterangan yang tampil di checkout, maks. 200 karakter |
order_id |
tidak | ID order dari sistem kamu, maks. 64 karakter. Tidak bisa dipakai dua kali selama transaksi lama masih menunggu atau sudah lunas |
fee_by |
tidak | buyer (default, biaya layanan ditambahkan ke total) atau merchant (dipotong dari saldo brand) |
callback_url |
tidak | URL webhook khusus transaksi ini. Kalau kosong, pakai URL webhook di Portal Brand |
return_url |
tidak | Ke mana pembeli diarahkan 3 detik setelah bayar |
test |
tidak | Isi 1 untuk memaksa mode sandbox (QR tes yang tidak bisa dibayar) |
method |
tidak | qris (default) atau va (transfer bank lewat Virtual Account). Lihat Metode pembayaran |
bank |
kalau method = va |
Kode bank VA: bni, btn, bnc |
curl -X POST https://pay.arenago.app/api/v1/payments/create \
-H "X-API-Key: $QRISKU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 10000,
"description": "Top up saldo",
"order_id": "INV-1001",
"fee_by": "buyer",
"return_url": "https://tokokamu.com/terima-kasih"
}'
<?php
// create-payment.php — jalankan: QRISKU_API_KEY=qk_live_xxx php create-payment.php
$baseUrl = getenv('QRISKU_BASE_URL') ?: 'https://pay.arenago.app';
$apiKey = getenv('QRISKU_API_KEY'); // simpan di .env server, jangan di frontend
function qrisku_request(string $path, array $body): array {
global $baseUrl, $apiKey;
$ch = curl_init($baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: ' . $apiKey],
CURLOPT_POSTFIELDS => json_encode($body),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($raw === false) throw new Exception('Koneksi gagal: ' . curl_error($ch));
$json = json_decode($raw, true);
if (empty($json['success'])) throw new Exception("Error $status: " . ($json['error'] ?? $raw));
return $json['data'];
}
if (basename(__FILE__) === basename($_SERVER['SCRIPT_FILENAME'] ?? '')) {
$p = qrisku_request('/api/v1/payments/create', [
'amount' => 10000,
'description' => 'Top up saldo',
'order_id' => 'INV-' . time(),
'fee_by' => 'buyer',
]);
echo "ID transaksi : {$p['transaction_id']}\n";
echo "Total bayar : {$p['total_amount']}\n";
echo "Link checkout: {$p['payment_url']}\n";
}
// create-payment.mjs — jalankan: QRISKU_API_KEY=qk_live_xxx node create-payment.mjs
const BASE_URL = process.env.QRISKU_BASE_URL || 'https://pay.arenago.app';
const API_KEY = process.env.QRISKU_API_KEY; // simpan di .env server, jangan di frontend
export async function createPayment({ amount, description, orderId, returnUrl }) {
const res = await fetch(`${BASE_URL}/api/v1/payments/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
body: JSON.stringify({ amount, description, order_id: orderId, fee_by: 'buyer', return_url: returnUrl }),
});
const json = await res.json();
if (!json.success) throw new Error(`Gagal buat pembayaran (${res.status}): ${json.error}`);
return json.data; // transaction_id, payment_url, qr_image_url, total_amount, expires_at, ...
}
if (import.meta.url === `file://${process.argv[1]}`) {
const p = await createPayment({ amount: 10000, description: 'Top up saldo', orderId: 'INV-' + Date.now() });
console.log('ID transaksi :', p.transaction_id);
console.log('Total bayar :', p.total_amount);
console.log('Link checkout:', p.payment_url);
}
Contoh respon:
{
"success": true,
"data": {
"transaction_id": "TXMH7Q2K1A9F3C2B",
"order_id": "INV-1001",
"description": "Top up saldo",
"status": "pending",
"mode": "live",
"amount": 10000,
"fee": 70,
"fee_by": "buyer",
"total_amount": 10070,
"net_amount": 10000,
"method": "qris",
"va_bank": null,
"va_bank_name": null,
"va_number": null,
"qr_string": "00020101021226...",
"qr_image_url": "https://pay.arenago.app/pay/TXMH7Q2K1A9F3C2B/qr.png",
"payment_url": "https://pay.arenago.app/pay/TXMH7Q2K1A9F3C2B",
"expires_at": "2026-10-26T08:45:00.000Z",
"paid_at": null,
"created_at": "2026-10-26T08:30:00.000Z"
}
}
Cara paling gampang: arahkan pembeli ke payment_url. Halaman itu sudah berisi QR, total, hitung mundur, dan berubah otomatis begitu lunas. Mau tampilkan QR di halaman sendiri? Pakai qr_image_url (PNG) atau buat gambar QR dari qr_string.
total_amount adalah yang dibayar pembeli. net_amount adalah yang masuk ke saldo brand. QR berlaku 15 menit (expires_at, format UTC).
#2a. Metode pembayaran: QRIS atau Virtual Account
Tanpa field method, semuanya sama seperti sebelumnya: pembayaran QRIS. Integrasi lama tidak perlu diubah.
Halaman checkout (payment_url) punya dua tab: QRIS dan Transfer Bank (VA). Di tab VA, pembeli memilih bank, lalu nomor Virtual Account dibuat saat itu juga untuk transaksi yang sama (ID transaksi tidak berubah). Jadi cara paling gampang tetap: arahkan pembeli ke payment_url dan biarkan mereka memilih.
Mau langsung buat nomor VA dari server kamu (misalnya untuk ditampilkan di aplikasi sendiri)? Kirim method dan bank:
curl -X POST https://pay.arenago.app/api/v1/payments/create \
-H "X-API-Key: $QRISKU_API_KEY" -H "Content-Type: application/json" \
-d '{"amount": 10000, "order_id": "INV-1002", "method": "va", "bank": "bni"}'
Respon berisi field yang sama, ditambah nomor VA:
{
"success": true,
"data": {
"transaction_id": "TXMH7Q2K1A9F3C2C",
"status": "pending",
"total_amount": 10070,
"method": "va",
"va_bank": "bni",
"va_bank_name": "BNI",
"va_number": "9882917231250453",
"payment_url": "https://pay.arenago.app/pay/TXMH7Q2K1A9F3C2C",
"expires_at": "2026-10-26T08:45:00.000Z",
"...": "..."
}
}
Yang perlu diketahui:
- Bank yang tersedia sekarang: BNI (
bni), BTN (btn), Bank Neo Commerce (bnc). Daftar terbaru bisa diambil dariPOST /api/v1/payment-methods. Kode bank lain ditolak dengan400. - VA bersifat closed amount: pembeli harus transfer persis
total_amount. Biaya layanan dannet_amountsama dengan QRIS. - Nomor VA berlaku sampai
expires_attransaksi, sekali pakai. - QRIS tetap dibuat untuk transaksi VA, jadi pembeli masih bisa pindah ke tab QRIS di halaman checkout. Mana yang dibayar duluan, itu yang dipakai. Field
methoddi status/webhook menunjukkan cara yang benar-benar dibayar. - Di aplikasi bank, nama penerima VA tampil sebagai ARENAGO.
- Di sandbox, nomor VA diawali
99dan tidak terdaftar di bank mana pun (tidak bisa dibayar). Pakaisimulate-paidseperti biasa.
#3. Cek status pembayaran
POST /api/v1/payments/status dengan transaction_id atau order_id. Kamu hanya bisa melihat transaksi milik brand kamu sendiri.
curl -X POST https://pay.arenago.app/api/v1/payments/status \
-H "X-API-Key: $QRISKU_API_KEY" \
-H "Content-Type: application/json" \
-d '{"transaction_id": "TX..."}'
# atau cari pakai order_id kamu sendiri: -d '{"order_id": "INV-1001"}'
<?php
// check-status.php — jalankan: QRISKU_API_KEY=qk_live_xxx php check-status.php TX...
require __DIR__ . '/create-payment.php'; // pakai fungsi qrisku_request() dari contoh sebelumnya
$data = qrisku_request('/api/v1/payments/status', ['transaction_id' => $argv[1] ?? '']);
echo $data['transaction_id'] . ' => ' . $data['status'] . "\n"; // pending | paid | expired | failed
if ($data['status'] === 'paid') {
// tandai order kamu lunas di database
}
// check-status.mjs — jalankan: QRISKU_API_KEY=qk_live_xxx node check-status.mjs TX...
const BASE_URL = process.env.QRISKU_BASE_URL || 'https://pay.arenago.app';
const API_KEY = process.env.QRISKU_API_KEY;
export async function getStatus(transactionId) {
const res = await fetch(`${BASE_URL}/api/v1/payments/status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
body: JSON.stringify({ transaction_id: transactionId }),
});
const json = await res.json();
if (!json.success) throw new Error(`Gagal cek status (${res.status}): ${json.error}`);
return json.data; // data.status: pending | paid | expired | failed
}
if (import.meta.url === `file://${process.argv[1]}`) {
const d = await getStatus(process.argv[2]);
console.log(d.transaction_id, '=>', d.status, d.paid_at ? `(dibayar ${d.paid_at})` : '');
}
Nilai data.status:
| Status | Arti |
|---|---|
pending |
Menunggu dibayar |
paid |
Lunas, saldo brand sudah bertambah |
expired |
Lewat batas waktu, tidak dibayar. Buat pembayaran baru |
failed |
Gagal |
Webhook tetap cara utama. Cek status berguna sebagai cadangan, misalnya saat pembeli kembali ke return_url atau untuk job pengecekan berkala.
#4. Terima webhook
Setiap status berubah, kami kirim POST JSON ke URL webhook kamu:
{
"event": "payment.success",
"data": {
"transaction_id": "TX...",
"order_id": "INV-1001",
"description": "Top up saldo",
"status": "paid",
"mode": "live",
"amount": 10000,
"fee": 70,
"fee_by": "buyer",
"total_amount": 10070,
"net_amount": 10000,
"method": "va",
"va_bank": "bni",
"va_bank_name": "BNI",
"va_number": "9882917231250453",
"payment_url": "https://pay.arenago.app/pay/TX...",
"paid_at": "2026-10-26T08:31:10.000Z",
"...": "..."
},
"sent_at": "2026-10-26T08:31:12.000Z"
}
Field method berisi qris atau va (cara yang dipakai pembeli). Untuk va, va_bank, va_bank_name, dan va_number ikut dikirim. Untuk qris ketiganya null. Field qr_string tidak dikirim di webhook.
Event: payment.success, payment.expired, payment.failed, dan webhook.test (dari tombol Kirim webhook tes). URL tujuan dicatat saat transaksi dibuat, jadi kalau URL webhook di Portal Brand diganti, perubahan berlaku untuk transaksi baru. Pengiriman yang gagal (bukan HTTP 2xx) dicoba ulang 3 kali: setelah 10 detik, 1 menit, dan 5 menit. Webhook yang sama bisa datang lebih dari sekali, jadi proses secara idempotent.
Header yang dikirim:
| Header | Isi |
|---|---|
X-QRISKu-Event |
Nama event |
X-QRISKu-Timestamp |
Unix time (detik) saat dikirim |
X-QRISKu-Signature |
sha256= + HEX(HMAC-SHA256(webhook_secret, timestamp + "." + body mentah)) |
Wajib verifikasi signature sebelum percaya isinya. Hitung HMAC dari body mentah persis seperti diterima, bandingkan dengan perbandingan aman waktu (hash_equals / timingSafeEqual), dan tolak timestamp yang lebih tua dari 5 menit.
<?php
// webhook.php — taruh di https://tokokamu.com/qris-webhook.php lalu isi URL itu di Portal Brand.
$secret = getenv('QRISKU_WEBHOOK_SECRET'); // whsec_...
$raw = file_get_contents('php://input'); // body mentah, JANGAN pakai $_POST
$timestamp = $_SERVER['HTTP_X_QRISKU_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_QRISKU_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
if (!$timestamp || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $signature)) {
http_response_code(401);
exit('invalid signature');
}
$payload = json_decode($raw, true);
$event = $payload['event'];
$data = $payload['data'];
if ($event === 'payment.success') {
// Update order sekali saja (idempotent), contoh:
// UPDATE orders SET status='paid' WHERE order_id = ? AND status <> 'paid'
error_log("LUNAS: {$data['order_id']} Rp{$data['total_amount']}");
} elseif ($event === 'payment.expired' || $event === 'payment.failed') {
error_log("BATAL: {$data['order_id']} ({$data['status']})");
}
http_response_code(200);
echo 'ok';
// webhook-server.mjs — penerima webhook tanpa framework (Node 18+).
// Jalankan: QRISKU_WEBHOOK_SECRET=whsec_xxx PORT=8080 node webhook-server.mjs
import http from 'node:http';
import crypto from 'node:crypto';
const SECRET = process.env.QRISKU_WEBHOOK_SECRET;
const MAX_AGE = 5 * 60; // tolak webhook yang lebih tua dari 5 menit (anti replay)
export function verifySignature(rawBody, timestamp, signature, secret = SECRET) {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_AGE) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(String(signature));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const server = http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/qris-webhook') { res.writeHead(404).end(); return; }
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
// PENTING: verifikasi pakai body mentah (raw), bukan hasil JSON.parse lalu stringify.
const ok = verifySignature(raw, req.headers['x-qrisku-timestamp'], req.headers['x-qrisku-signature']);
if (!ok) { res.writeHead(401).end('invalid signature'); return; }
const { event, data } = JSON.parse(raw);
if (event === 'payment.success') {
// Update order sekali saja (idempotent): cek dulu apakah data.order_id sudah lunas.
console.log(`LUNAS: ${data.order_id || data.transaction_id} Rp${data.total_amount}`);
} else if (event === 'payment.expired' || event === 'payment.failed') {
console.log(`BATAL: ${data.order_id || data.transaction_id} (${data.status})`);
} else if (event === 'webhook.test') {
console.log('Webhook tes diterima, signature valid');
}
res.writeHead(200, { 'Content-Type': 'text/plain' }).end('ok'); // balas 2xx secepatnya
});
});
server.listen(process.env.PORT || 8080, () => console.log('Webhook siap di :' + (process.env.PORT || 8080) + '/qris-webhook'));
Aturan pengiriman:
- Balas HTTP
2xxsecepatnya (di bawah 10 detik). Proses berat taruh di antrean. - Kalau gagal (bukan 2xx, timeout), kami coba lagi 3x: setelah 10 detik, 1 menit, dan 5 menit.
- Webhook bisa datang lebih dari sekali. Buat handler kamu idempotent: kalau order sudah lunas, abaikan saja.
- Riwayat pengiriman dan respon server kamu bisa dilihat di Portal Brand > API & Webhook.
#5. Kode status dan error
Semua respon API berbentuk {"success": true, "data": {...}} atau {"success": false, "error": "pesan"}.
| HTTP | Arti | Yang perlu dilakukan |
|---|---|---|
200 |
Berhasil | |
400 |
Input tidak valid (nominal di luar batas, URL salah, method/bank tidak dikenal, dll.) |
Baca error, perbaiki request |
401 |
API key salah, kosong, atau brand nonaktif | Cek header X-API-Key |
403 |
Aksi tidak diizinkan (misalnya simulasi bayar di transaksi real) | |
404 |
Transaksi tidak ditemukan (atau milik brand lain) | Cek transaction_id / order_id |
409 |
order_id sudah dipakai transaksi yang masih aktif/lunas |
Pakai order_id baru |
413 |
Body terlalu besar | |
429 |
Terlalu banyak request (120/menit per API key) | Tunggu sesuai header Retry-After |
502 |
Penyedia pembayaran (DOKU) sedang bermasalah, atau VA bank itu gagal dibuat | Coba lagi beberapa saat, atau pakai bank lain / QRIS |
503 |
Mode real belum siap di sisi platform | Hubungi admin QRISKu |
#6. Contoh integrasi lengkap: tombol "Bayar pakai QRIS"
Alur: pembeli klik tombol, server kamu buat order + pembayaran, pembeli diarahkan ke halaman QRIS, setelah bayar kembali ke return_url, dan order ditandai lunas oleh webhook.
<?php
// checkout.php — tombol "Bayar pakai QRIS" sederhana.
require __DIR__ . '/create-payment.php'; // fungsi qrisku_request()
$product = ['name' => 'Paket Premium 1 Bulan', 'price' => 49000];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$orderId = 'ORD-' . time(); // simpan order ini di database kamu dengan status "menunggu"
try {
$p = qrisku_request('/api/v1/payments/create', [
'amount' => $product['price'],
'description' => $product['name'],
'order_id' => $orderId,
'return_url' => 'https://tokokamu.com/terima-kasih.php?order=' . $orderId,
]);
header('Location: ' . $p['payment_url'], true, 303); // ke halaman QRIS
exit;
} catch (Exception $e) {
http_response_code(502);
exit('Gagal membuat pembayaran: ' . htmlspecialchars($e->getMessage()));
}
}
?>
<!doctype html><meta charset="utf-8"><title>Toko Contoh</title>
<h1><?= htmlspecialchars($product['name']) ?></h1>
<p>Harga: Rp <?= number_format($product['price'], 0, ',', '.') ?></p>
<form method="post"><button>Bayar pakai QRIS</button></form>
// checkout-server.mjs — contoh toko mini: tombol "Bayar pakai QRIS" -> halaman checkout.
// Jalankan: QRISKU_API_KEY=qk_live_xxx PORT=8081 node checkout-server.mjs
import http from 'node:http';
const BASE_URL = process.env.QRISKU_BASE_URL || 'https://pay.arenago.app';
const API_KEY = process.env.QRISKU_API_KEY;
const SHOP_URL = process.env.SHOP_URL || `http://localhost:${process.env.PORT || 8081}`;
const PRODUCT = { name: 'Paket Premium 1 Bulan', price: 49000 };
const html = `<!doctype html><meta charset="utf-8"><title>Toko Contoh</title>
<h1>${PRODUCT.name}</h1><p>Harga: Rp ${PRODUCT.price.toLocaleString('id-ID')}</p>
<form method="post" action="/checkout"><button>Bayar pakai QRIS</button></form>`;
http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/') return res.writeHead(200, { 'Content-Type': 'text/html' }).end(html);
if (req.method === 'POST' && req.url === '/checkout') {
const orderId = 'ORD-' + Date.now(); // simpan order ini di database kamu dengan status "menunggu"
const r = await fetch(`${BASE_URL}/api/v1/payments/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
body: JSON.stringify({ amount: PRODUCT.price, description: PRODUCT.name, order_id: orderId, return_url: `${SHOP_URL}/terima-kasih?order=${orderId}` }),
});
const j = await r.json();
if (!j.success) return res.writeHead(502, { 'Content-Type': 'text/plain' }).end('Gagal membuat pembayaran: ' + j.error);
return res.writeHead(303, { Location: j.data.payment_url }).end(); // arahkan pembeli ke halaman QRIS
}
if (req.url.startsWith('/terima-kasih')) return res.writeHead(200, { 'Content-Type': 'text/html' }).end('<h1>Terima kasih!</h1><p>Status final tetap ikut webhook.</p>');
res.writeHead(404).end();
}).listen(process.env.PORT || 8081, () => console.log('Toko contoh di ' + SHOP_URL));
Jangan tandai order lunas hanya karena pembeli sampai di return_url. Status final selalu dari webhook (atau cek status).
#7. Endpoint lain
| Endpoint | Fungsi |
|---|---|
POST /api/v1/payment-methods |
Metode yang aktif: methods (["qris","va"]) dan va_banks ([{"code":"bni","name":"BNI"}, ...]) |
POST /api/v1/balance |
Saldo brand: balance (real, bisa ditarik) dan sandbox_balance (tes) |
POST /api/v1/withdrawals/create |
Ajukan penarikan: amount, bank_name, account_no, account_name. Minimal Rp 10.000 |
POST /api/v1/payments/simulate-paid |
Khusus transaksi sandbox: tandai lunas untuk tes webhook |
curl -X POST https://pay.arenago.app/api/v1/balance -H "X-API-Key: $QRISKU_API_KEY"
#8. Uji coba di sandbox
- Buat pembayaran dengan
"test": 1. QR yang keluar sengaja tidak valid dan tidak bisa dibayar, jadi aman. - Tandai lunas lewat tombol Tandai Lunas (Tes) di halaman checkout atau Portal Brand, atau panggil
simulate-paid. - Pastikan webhook kamu menerima
payment.successdan signature-nya valid. - Saldo tes masuk ke
sandbox_balancedan tidak bisa ditarik.
#9. Checklist sebelum live
- API key dan webhook secret disimpan di
.envserver, tidak ada di frontend atau repo. - URL webhook memakai HTTPS dan bisa diakses dari internet.
- Signature webhook diverifikasi pakai body mentah dan perbandingan aman waktu.
- Handler webhook idempotent (aman kalau dikirim dua kali).
-
order_idunik per order, dan nominal dicek ulang saat webhook datang. - Order hanya ditandai lunas dari webhook atau cek status, bukan dari
return_url. - Kamu sudah coba alur penuh di sandbox (
test: 1) termasuk webhook tes. - Sudah tes 1x pembayaran real nominal kecil (misalnya Rp 1.000) dan saldo masuk.
- Ada log error di server kamu untuk request ke API dan webhook yang gagal.