Tutorial integrasi pembayaran QRIS di website PHP dan Laravel

Langkah demi langkah menambahkan pembayaran QRIS otomatis ke website PHP atau Laravel: buat tagihan lewat API, terima webhook, lalu uji di mode uji.

Tim WaroengPay · Diperbarui · 9 menit baca

Tutorial ini menambahkan pembayaran QRIS otomatis ke website PHP: pembeli checkout, membayar QRIS dari aplikasi bank atau e-wallet apa pun, lalu pesanannya berubah lunas sendiri tanpa kamu mengecek mutasi. Contohnya memakai PHP 8 dengan ekstensi cURL, dan versi Laravel ada di bagian akhir.

Alurnya

  1. Pembeli checkout, lalu servermu membuat tagihan lewat POST /api/payments.
  2. Servermu menyimpan id tagihan di pesanan dan mengarahkan pembeli ke pay_url.
  3. Pembeli memindai QRIS yang nominalnya sudah terisi, lalu membayar.
  4. Begitu dananya terbaca di mutasi rekening, WaroengPay mengirim webhook payment.paid. Servermu memverifikasinya lalu menandai pesanan lunas.
Diagram urutan: pembeli checkout, server toko memanggil POST /api/payments, pembeli membayar QRIS, WaroengPay mengirim webhook payment.paid, server toko menandai lunas
Alur lengkap dari checkout sampai pesanan lunas.

1. Siapkan API key mode uji

Buat akun WaroengPay. Setelah akunmu disetujui, buka dashboard → Integrasi & API → Kredensial dan buat API key mode uji (wp_test_...). Secret webhook mode uji ada di bagian Webhook dan tampil setelah kamu memasukkan password akun. Simpan keduanya sebagai variabel lingkungan di server, bukan di kode yang ikut ke browser atau repository:

.env
WAROENGPAY_API_KEY=wp_test_xxxxxxxxxxxxxxxx
WAROENGPAY_WEBHOOK_SECRET=whsec_test_xxxxxxxxxxxxxxxx

Dengan key mode uji, semua langkah di bawah bisa dicoba tanpa uang sungguhan. Tagihan uji tidak bisa dibayar dengan aplikasi apa pun; kamu melunasinya lewat simulasi.

2. Fungsi kecil untuk memanggil API

waroengpay.php
<?php
// Panggil API WaroengPay dari server. API key tidak boleh sampai ke browser.
function waroengpay(string $method, string $path, ?array $body = null, array $headers = []): array
{
$ch = curl_init("https://waroengpay.com" . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => array_merge([
"Authorization: Bearer " . getenv("WAROENGPAY_API_KEY"),
"Content-Type: application/json",
], $headers),
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = is_string($raw) ? json_decode($raw, true) : null;
if ($status >= 400 || !is_array($data)) {
throw new RuntimeException($data["error"] ?? "WaroengPay tidak bisa dihubungi");
}
return $data;
}

3. Buat tagihan saat checkout

Panggil API setelah pesanan tersimpan di databasemu. Kirim total pesanan dalam rupiah, antara Rp1.000 dan Rp10.000.000. Kode unik ditambahkan otomatis.

checkout.php
<?php
require "waroengpay.php";
// $order = pesanan yang baru disimpan di databasemu
$tagihan = waroengpay("POST", "/api/payments", [
"amount" => (int) $order["total"], // rupiah, 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
save_waroengpay_id($order["id"], $tagihan["id"]); // WRG-..., kunci untuk mencocokkan webhook
header("Location: " . $tagihan["pay_url"]);
exit;

Tiga hal penting di sini:

  • Simpan id tagihan (WRG-...) di pesananmu. Webhook nanti dicocokkan lewat id ini, bukan lewat reference.
  • Header Idempotency-Key berisi nomor pesanan membuat request yang terulang, misalnya tombol terklik dua kali atau koneksi putus, mengembalikan tagihan yang sama, bukan tagihan baru.
  • Nominal yang harus dibayar ada di amount dan sudah termasuk kode unik. Kalau kamu menampilkannya sendiri, tampilkan apa adanya.

4. Tampilkan pembayaran

Cara termudah adalah mengarahkan pembeli ke pay_url seperti contoh di atas. Halaman bayar WaroengPay sudah berisi QR, nominal, hitung mundur, dan logo tokomu, lalu berubah sendiri begitu lunas. Kalau kamu mengisi redirect_url, pembeli diarahkan kembali ke tokomu 6 detik setelah lunas.

Mau menampilkan QR di halaman checkout sendiri? Buat gambar QR dari string qris di respons memakai pustaka QR apa pun, tampilkan amount persis, lalu cek statusnya secara berkala, cukup tiap 5 sampai 10 detik:

PHP
<?php
$tagihan = waroengpay("GET", "/api/payments/" . $order["waroengpay_id"]);
echo $tagihan["status"]; // pending | paid | expired | cancelled
Jangan menandai pesanan lunas hanya karena pembeli kembali ke redirect_url. Alamat itu bisa dibuka siapa saja. Status lunas hanya boleh berasal dari webhook atau dari API.

5. Terima webhook pembayaran

Buat satu endpoint di servermu, misalnya https://tokoku.id/webhook/waroengpay. Daftarkan sebagai URL default di dashboard → Integrasi & API → Webhook, atau kirim per tagihan lewat callback_url. Isi endpoint-nya:

webhook.php
<?php
// Dipanggil server WaroengPay, bukan oleh pembeli.
$secret = getenv("WAROENGPAY_WEBHOOK_SECRET");
$body = file_get_contents("php://input"); // body mentah, jangan diubah dulu
$ts = $_SERVER["HTTP_X_WAROENGPAY_TIMESTAMP"] ?? "";
$sig = $_SERVER["HTTP_X_SIGNATURE"] ?? "";
$expected = hash_hmac("sha256", $ts . "." . $body, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
$testMode = str_starts_with((string) getenv("WAROENGPAY_API_KEY"), "wp_test_");
if (($event["livemode"] ?? true) === false && !$testMode) { // event mode uji tidak boleh menyentuh pesanan asli
http_response_code(200);
exit;
}
if ($event["event"] === "payment.paid") {
$order = find_order_by_waroengpay_id($event["data"]["id"]); // id tagihan, BUKAN reference
if ($order && !$order["paid_at"] && (int) $order["total"] === $event["data"]["base_amount"]) {
mark_paid($order["id"]); // sudah lunas = event duplikat, dilewati
}
}
http_response_code(200);

Kode ini memverifikasi tanda tangan HMAC dari body mentah, menolak kiriman yang lebih tua dari 5 menit, lalu menandai pesanan lunas hanya kalau data.id dan nominalnya cocok. Event mode uji hanya diproses selama servermu memakai key wp_test_, jadi server produksi tidak pernah terpengaruh. Ganti find_order_by_waroengpay_id dan mark_paid dengan fungsi databasemu. Penjelasan lengkap setiap langkahnya 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. Jadi saat menguji, tambahkan callback_url ke data tagihan di langkah 3:

PHP
"callback_url" => "https://tokoku.id/webhook/waroengpay",

Lalu buka pay_url tagihan uji dan tekan Simulasikan bayar, atau panggil endpoint simulasi:

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. Karena servermu memakai key wp_test_, kode di langkah 5 memprosesnya seperti pembayaran sungguhan.

Servermu masih di komputer lokal? Alamat localhost tidak bisa menerima webhook. Pakai layanan tunnel seperti ngrok atau Cloudflare Tunnel untuk mendapat alamat publik sementara.

7. Versi Laravel

Simpan kredensial di config/services.php:

config/services.php
'waroengpay' => [
'key' => env('WAROENGPAY_API_KEY'),
'webhook_secret' => env('WAROENGPAY_WEBHOOK_SECRET'),
],

Buat tagihan dengan HTTP client bawaan Laravel:

app/Http/Controllers/CheckoutController.php
use Illuminate\Support\Facades\Http;
public function pay(Order $order)
{
$tagihan = Http::withToken(config('services.waroengpay.key'))
->withHeaders(['Idempotency-Key' => $order->number])
->timeout(15)
->post('https://waroengpay.com/api/payments', [
'amount' => (int) $order->total,
'reference' => $order->number,
'redirect_url' => route('orders.show', $order),
])
->throw()
->json();
$order->update(['waroengpay_id' => $tagihan['id']]);
return redirect()->away($tagihan['pay_url']);
}

Terima webhook di routes/web.php:

routes/web.php
use App\Models\Order;
use Illuminate\Http\Request;
Route::post('/webhook/waroengpay', function (Request $request) {
$body = $request->getContent(); // body mentah
$ts = (string) $request->header('X-WaroengPay-Timestamp');
$expected = hash_hmac('sha256', $ts . '.' . $body, config('services.waroengpay.webhook_secret'));
if (! hash_equals($expected, (string) $request->header('X-Signature')) || abs(time() - (int) $ts) > 300) {
abort(401);
}
$event = json_decode($body, true);
$testMode = str_starts_with((string) config('services.waroengpay.key'), 'wp_test_');
if (($event['livemode'] ?? true) === false && ! $testMode) {
return response()->noContent(); // event mode uji
}
if ($event['event'] === 'payment.paid') {
$order = Order::where('waroengpay_id', $event['data']['id'])->first();
if ($order && ! $order->paid_at && (int) $order->total === $event['data']['base_amount']) {
$order->update(['paid_at' => now()]);
}
}
return response()->noContent();
});

Webhook dikirim tanpa token CSRF, jadi kecualikan alamatnya dari pemeriksaan CSRF. Di Laravel 11 ke atas caranya lewat bootstrap/app.php seperti di bawah. Di Laravel 10 ke bawah, tambahkan alamat yang sama ke $except di app/Http/Middleware/VerifyCsrfToken.php.

bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhook/waroengpay',
]);
})

8. Sebelum go live

  • Ganti WAROENGPAY_API_KEY ke key live (wp_live_...) dan WAROENGPAY_WEBHOOK_SECRET ke secret webhook live.
  • Isi URL webhook default di dashboard, lalu tekan Kirim tes dan pastikan servermu membalas dengan status 2xx.
  • Pastikan pesanan hanya lunas lewat webhook atau pengecekan status, dan nominalnya selalu dicocokkan.
  • Tangani payment.paid dengan data.late = true, yaitu pembayaran setelah tagihan kedaluwarsa yang dicocokkan admin. Event ini bisa datang setelah payment.expired.

Error yang sering muncul

KodeArtinyaYang perlu dilakukan
401 invalid_api_keyAPI key salah atau sudah dibuat ulangPeriksa variabel lingkungan, buat key baru di dashboard bila perlu
400 invalid_amountNominal bukan bilangan bulat Rp1.000 sampai Rp10.000.000Kirim angka rupiah tanpa titik ribuan, misalnya 50000
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
503 amount_busyKode unik untuk nominal itu sedang habisCoba lagi beberapa saat kemudian

Referensi lengkap semua endpoint ada di dokumentasi API. Kalau kamu berjualan lewat toko online, lihat juga solusi untuk toko online.

Panduan lain

Mulai terima QRIS hari ini

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