Webhook pembayaran: cara kerja dan cara memverifikasinya dengan aman

Apa itu webhook payment gateway, kenapa wajib diverifikasi HMAC, dan cara memprosesnya dengan aman: body mentah, timestamp, idempotensi, dan pengiriman ulang.

Tim WaroengPay · Diperbarui · 8 menit baca

Webhook adalah cara payment gateway memberi tahu servermu bahwa sesuatu terjadi, misalnya tagihan sudah lunas. Servermu tidak perlu bertanya berulang kali "sudah dibayar belum?"; payment gateway yang mengirim kabar lewat request HTTP POST ke alamat yang kamu tentukan, begitu statusnya berubah.

Karena webhook bisa memicu pengiriman barang atau produk digital, alamat webhook-mu perlu dijaga seperti pintu kasir: siapa pun bisa mengetuk, jadi setiap kiriman wajib diperiksa keasliannya.

Webhook, polling, atau halaman sukses?

CaraKelebihanKekurangan
WebhookKabar datang seketika tanpa request berulangButuh alamat publik dan verifikasi tanda tangan
Polling GET /api/payments/:idSederhana, tidak butuh alamat publikAda jeda dan request berulang
Pembeli kembali ke halaman suksesEnak untuk tampilanTidak bisa dipercaya: siapa pun bisa membuka alamat itu sendiri

Pakai webhook sebagai sumber utama dan polling sebagai cadangan. Jangan pernah menandai pesanan lunas hanya karena pembeli kembali ke redirect_url.

Isi webhook WaroengPay

WaroengPay mengirim POST berisi JSON saat status tagihan menjadi final. Event yang dikirim: payment.paid (lunas), payment.expired (kedaluwarsa), payment.cancelled (dibatalkan), dan ping dari tombol "Kirim tes" di dashboard.

Webhook
// Header
X-WaroengPay-Event: payment.paid
X-WaroengPay-Delivery: 1842
X-WaroengPay-Timestamp: 1790218992
X-Signature: 5f2b0c...e91a // hex HMAC-SHA256(secret, "<timestamp>.<body>")
// Body
{
"id": "evt_Qm2vK8sPz1Lx0aB7",
"event": "payment.paid",
"created_at": "2026-09-24T03:03:12.000Z",
"livemode": true, // false = event tagihan mode uji (sandbox)
"data": {
"id": "WRG-7K2M9QX4BD1PZ",
"reference": "INV-2041",
"status": "paid",
"base_amount": 50000,
"amount": 50137,
"pay_url": "https://waroengpay.com/pay/WRG-7K2M9QX4BD1PZ",
"paid_at": "2026-09-24T03:03:12.000Z",
"late": false, // true = pembayaran telat yang dicocokkan admin (bisa setelah payment.expired)
"livemode": true,
"link_id": null, // angka = dibuat pembeli lewat link pembayaran /l/<slug>
"payment": {
"issuer": "DANA",
"payer": "BU**I SAN**SO",
"bca_ref": "236433285",
"transaction_at": "2026-09-24T03:03:00.000Z"
}
}
}

Header X-Signature adalah tanda tangan HMAC-SHA256 dari teks <timestamp>.<body> dengan secret webhook-mu (whsec_...). HMAC menggabungkan secret dengan isi pesan: hanya pihak yang memegang secret yang bisa membuat tanda tangan yang cocok, dan satu karakter body yang berubah menghasilkan tanda tangan yang sama sekali berbeda.

Lima aturan memproses webhook dengan aman

1. Verifikasi tanda tangan dari body mentah

Hitung ulang HMAC dari body persis seperti yang diterima, lalu bandingkan dengan fungsi yang waktunya konstan: hash_equals di PHP, crypto.timingSafeEqual di Node.js, atau hmac.compare_digest di Python. Jangan menghitung dari JSON yang sudah di-parse lalu diubah lagi menjadi teks, karena urutan kunci dan spasinya bisa berubah sehingga tanda tangannya tidak cocok.

2. Tolak kiriman lama

Timestamp ikut ditandatangani. Tolak webhook yang timestamp-nya berbeda lebih dari 5 menit dari jam servermu, supaya kiriman asli yang disadap tidak bisa dikirim ulang di kemudian hari (replay attack). Pastikan jam servermu sinkron.

3. Cocokkan pesanan lewat id tagihan dan nominal

Tanda tangan yang valid hanya membuktikan event berasal dari WaroengPay, bukan bahwa event itu untuk pesanan yang kamu kira. Orang yang memegang API key atau sesi dashboard-mu bisa membuat tagihan Rp1.000 dengan reference milik pesanan Rp5.000.000, membayarnya, dan webhook-nya tetap asli. Karena itu:

  • Simpan id tagihan (WRG-...) di pesanan saat tagihan dibuat, lalu cari pesanan lewat data.id, bukan data.reference.
  • Pastikan data.base_amount sama dengan total pesanan. Beda sedikit saja, jangan tandai lunas dan cek manual.
  • Abaikan event dengan livemode: false di server produksi, karena itu event tagihan mode uji.

4. Proses setiap event sekali saja

Event yang sama bisa terkirim lebih dari sekali, misalnya saat balasan servermu terlambat. Simpan id event (evt_...) yang sudah diproses lalu lewati duplikatnya, atau cek dulu apakah pesanannya sudah lunas.

5. Balas cepat, proses berat belakangan

Balas dengan status 2xx dalam 10 detik. Pekerjaan berat seperti mengirim email atau membuat akun sebaiknya dimasukkan ke antrean dan dikerjakan setelah membalas.

Urutan pemeriksaan webhook: verifikasi HMAC, cek timestamp 5 menit, abaikan mode uji, cocokkan data.id dan nominal, lewati event duplikat, lalu tandai lunas dan balas 2xx
Urutan pemeriksaan sebelum menandai pesanan lunas.

Contoh kode verifikasi

Contoh berikut menerapkan kelima aturan di atas. Pilih bahasanya:

import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.WAROENGPAY_WEBHOOK_SECRET; // whsec_...
// pakai body mentah: tanda tangan dihitung dari teks persis 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; // tolak kiriman ulang > 5 menit
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) return res.sendStatus(200); // event mode uji: jangan pernah menandai pesanan asli
if (event.event === "payment.paid") {
// cari pesanan lewat id tagihan yang disimpan saat membuat tagihan, BUKAN reference (tidak unik, bebas diisi)
const order = await db.orders.findByWaroengpayId(event.data.id);
if (!order || event.data.base_amount !== order.total) return res.sendStatus(200); // nominal beda: jangan tandai lunas
await db.orders.markPaid(order.id, event.id); // lewati kalau event.id sudah pernah diproses
}
res.sendStatus(200);
});

Kalau servermu sedang gangguan

Webhook yang gagal, yaitu tidak dibalas 2xx dalam 10 detik, dikirim ulang dengan jeda bertahap: langsung, lalu setelah 30 detik, 2 menit, 10 menit, 30 menit, dan 2 jam. Totalnya 6 percobaan dalam sekitar 2,7 jam. Setelah itu:

  • Buka tagihannya di dashboard lalu tekan Kirim ulang untuk mengirim ulang event terakhirnya.
  • Atau ambil status terbarunya lewat GET /api/payments/:id. Field webhooks di respons berisi 20 riwayat pengiriman terakhir.

Urutan event yang perlu diantisipasi

Biasanya satu tagihan hanya menghasilkan satu event final. Pengecualiannya adalah pembeli yang tetap membayar setelah tagihan kedaluwarsa atau dibatalkan. Admin WaroengPay bisa mencocokkan pembayaran itu secara manual, dan kamu menerima payment.paid dengan data.late = true setelah sebelumnya menerima payment.expired atau payment.cancelled. Jangan tolak event ini hanya karena pesanannya sudah ditandai kedaluwarsa: proses pesanannya, atau hubungi pembeli untuk pengembalian dana.

Alamat webhook yang diterima

  • Harus bisa diakses dari internet lewat port 80, 443, 8080, atau 8443.
  • Alamat jaringan privat seperti localhost, 10.x, 192.168.x, dan metadata cloud ditolak, dan redirect tidak diikuti.
  • Untuk mencoba dari komputer lokal, pakai layanan tunnel seperti ngrok atau Cloudflare Tunnel.
  • Alamat webhook dicatat saat tagihan dibuat: dari callback_url tagihan itu, atau URL default akun di dashboard → Integrasi & API → Webhook.

Cara menguji

  1. Buat tagihan dengan API key mode uji (wp_test_...) dan isi callback_url. Webhook tagihan uji hanya dikirim ke callback_url yang diisi saat membuat tagihan, dan ditandatangani secret mode uji (whsec_test_...).
  2. Lunasi tagihan uji dengan tombol Simulasikan bayar di halaman bayarnya, atau lewat API:
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"

Sebelum go live, ganti ke secret live, isi URL webhook default di dashboard, lalu tekan Kirim tes. Dashboard mengirim event ping yang ditandatangani secret live; pastikan servermu membalas 2xx.

Dokumentasi lengkapnya ada di bagian webhook dan sandbox dokumentasi API. Untuk contoh integrasi dari awal sampai akhir, baca tutorial integrasi QRIS di PHP dan Laravel.

Panduan lain

Mulai terima QRIS hari ini

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