Cara Mencegah Transaksi Ganda dari Webhook Payment Gateway
Apa Itu Transaksi Ganda dari Webhook?
Payment gateway biasanya mengirim webhook ketika status pembayaran berubah.
Contohnya:
Pembayaran berhasil
↓
Payment Gateway
↓
Webhook LaravelNamun, webhook yang sama dapat dikirim kembali:
Webhook #1
Webhook #2
Webhook #3Jika Laravel langsung membuat transaksi baru setiap kali menerima webhook, data pembayaran dapat menjadi duplikat.
Mengapa Webhook Bisa Terkirim Berkali-kali?
Beberapa penyebabnya:
Server Laravel terlambat memberikan response.
Koneksi mengalami timeout.
Payment gateway melakukan retry.
Response
200 OKtidak diterima dengan benar.Event pembayaran dikirim kembali.
Sistem provider mengalami gangguan sementara.
Karena itu, aplikasi harus menganggap webhook dapat diterima lebih dari satu kali.
Gunakan Idempotency
Idempotency berarti request yang sama dapat diproses berulang kali tanpa menghasilkan efek samping tambahan.
Contohnya:
Webhook pertama
↓
Transaksi diproses
Webhook kedua
↓
Transaksi sudah ada
↓
Tidak diproses lagiIni merupakan konsep penting dalam sistem pembayaran.
1. Simpan ID Transaksi dari Payment Gateway
Misalnya provider mengirim:
{
"transaction_id": "TRX-20260823-001",
"status": "paid"
}Simpan transaction_id tersebut pada database.
Migration:
$table->string('gateway_transaction_id')
->unique();unique() membantu database mencegah ID transaksi yang sama disimpan lebih dari sekali.
2. Jangan Hanya Mengandalkan ID Internal
Misalnya database memiliki:
id = 100dan payment gateway memiliki:
transaction_id = TRX-001Keduanya memiliki fungsi berbeda.
Gunakan ID gateway untuk mengenali transaksi yang berasal dari provider:
Internal ID : 100
Gateway ID : TRX-001
Status : paid3. Cek Transaksi Sebelum Diproses
Contoh sederhana:
$payment = Payment::where(
'gateway_transaction_id',
$data['transaction_id']
)->first();
if ($payment) {
return response()->json([
'message' => 'Transaction already processed',
]);
}Jika transaksi sudah ada, webhook tidak diproses kembali.
Namun, pengecekan ini saja belum cukup untuk kondisi concurrent request.
4. Gunakan Unique Constraint Database
Contoh migration:
Schema::table('payments', function (Blueprint $table) {
$table->string('gateway_transaction_id')
->unique();
});Database kemudian memastikan:
TRX-001 ✓
TRX-002 ✓
TRX-001 ✗Jika dua webhook datang hampir bersamaan, database tetap dapat mencegah duplicate berdasarkan constraint tersebut.
5. Gunakan Database Transaction
Proses pembayaran sebaiknya dilakukan dalam transaction:
DB::transaction(function () use ($data) {
$payment = Payment::create([
'gateway_transaction_id'
=> $data['transaction_id'],
'status' => 'paid',
]);
Order::where(
'id',
$data['order_id']
)->update([
'status' => 'paid',
]);
});Jika terjadi error di tengah proses, perubahan dapat dibatalkan.
6. Periksa Status Transaksi Sebelum Update
Misalnya transaksi sudah:
paidKemudian webhook yang sama datang lagi.
Jangan melakukan proses pembayaran ulang.
Contohnya:
if ($payment->status === 'paid') {
return response()->json([
'message' => 'Already paid',
]);
}Namun, jangan sembarang mengabaikan semua webhook berikutnya. Status pembayaran harus mengikuti aturan transisi status yang ditentukan provider.
7. Gunakan Event ID Jika Disediakan
Beberapa payment gateway menyediakan ID event selain transaction ID.
Contohnya:
event_id:
evt_01HXYZ123Simpan:
$table->string('event_id')
->unique();Kemudian:
$exists = WebhookEvent::where(
'event_id',
$data['event_id']
)->exists();
if ($exists) {
return response()->json([
'message' => 'Event already processed',
]);
}Event ID sangat berguna untuk memastikan satu event hanya diproses sekali.
8. Buat Tabel Webhook Events
Untuk sistem yang membutuhkan audit lebih lengkap, buat tabel:
webhook_eventsContoh:
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('event_id')->unique();
$table->string('event_type');
$table->string('transaction_id')->nullable();
$table->json('payload')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});Dengan cara ini, setiap webhook dapat dilacak.
Contohnya:
Event ID : evt_001
Event Type : payment.success
Transaction : TRX-001
Processed At : 23/08/2026 12:309. Gunakan Lock untuk Kondisi Concurrent
Masalah dapat terjadi jika dua webhook datang hampir bersamaan:
Webhook A ─┐
├→ Laravel
Webhook B ─┘Keduanya bisa melakukan:
Cek transaksi
↓
Belum ada
↓
Createsebelum transaksi pertama selesai.
Untuk kondisi seperti ini, database locking dapat digunakan.
Contohnya:
DB::transaction(function () use ($data) {
$payment = Payment::where(
'gateway_transaction_id',
$data['transaction_id']
)
->lockForUpdate()
->first();
if ($payment?->status === 'paid') {
return;
}
// Proses pembayaran
});Implementasi lock harus disesuaikan dengan struktur database dan transaksi aplikasi.
10. Jangan Membuat Transaksi Baru dari Webhook Tanpa Validasi
Webhook sebaiknya tidak menjadi sumber data yang langsung dipercaya.
Alur yang lebih aman:
Webhook
↓
Verifikasi Signature
↓
Validasi Event
↓
Cari Transaksi
↓
Validasi Status
↓
Cek Duplicate
↓
Update TransaksiJangan langsung:
Payment::create($request->all());11. Verifikasi Signature
Webhook payment gateway sebaiknya memiliki signature verification.
Contohnya:
$payload = $request->getContent();
$expected = hash_hmac(
'sha256',
$payload,
config('services.payment.secret')
);
if (!hash_equals(
$expected,
$request->header('X-Signature')
)) {
abort(401);
}Format sebenarnya harus mengikuti mekanisme signature dari payment gateway yang digunakan.
12. Gunakan Status Transaksi yang Jelas
Contohnya:
pending
paid
failed
expired
cancelled
refundedKemudian tentukan aturan perubahan status.
Contohnya:
pending → paid
pending → failed
pending → expired
paid → refundedHindari perubahan status sembarangan seperti:
paid → pendingkecuali memang diperbolehkan oleh mekanisme payment gateway.
13. Jangan Menambahkan Saldo Dua Kali
Ini sangat penting untuk sistem wallet atau saldo.
Misalnya:
Pembayaran Rp100.000Webhook pertama:
Saldo + Rp100.000Webhook kedua:
Saldo + Rp100.000Akhirnya:
Saldo + Rp200.000Padahal pelanggan hanya membayar Rp100.000.
Gunakan transaction ID dan status pembayaran sebagai idempotency key sebelum menambahkan saldo.
14. Contoh Controller Webhook
Contoh sederhana:
public function webhook(Request $request)
{
$this->verifySignature($request);
$data = $request->validate([
'event_id' => ['required', 'string'],
'transaction_id' => [
'required',
'string',
],
'status' => [
'required',
'string',
],
]);
return DB::transaction(function () use ($data) {
$event = WebhookEvent::firstOrCreate(
[
'event_id' => $data['event_id'],
],
[
'event_type' => 'payment',
'transaction_id'
=> $data['transaction_id'],
]
);
if (
$event->processed_at
) {
return response()->json([
'message' => 'Already processed',
]);
}
$payment = Payment::where(
'gateway_transaction_id',
$data['transaction_id']
)
->lockForUpdate()
->firstOrFail();
if ($payment->status === 'paid') {
$event->update([
'processed_at' => now(),
]);
return response()->json([
'message' => 'Already paid',
]);
}
$payment->update([
'status' => 'paid',
]);
$event->update([
'processed_at' => now(),
]);
return response()->json([
'message' => 'Payment processed',
]);
});
}Kode di atas adalah contoh konsep. Struktur sebenarnya perlu disesuaikan dengan format webhook payment gateway yang digunakan.
15. Gunakan Queue untuk Proses Berat
Webhook tidak sebaiknya menjalankan proses panjang.
Gunakan:
Webhook
↓
Validasi Signature
↓
Validasi Event
↓
Simpan Event
↓
Queue
↓
Process PaymentContohnya:
ProcessPaymentWebhook::dispatch(
$data
);Tetapi idempotency tetap harus dilakukan pada proses job, bukan hanya saat webhook diterima.
16. Retry Job Harus Tetap Aman
Queue juga dapat melakukan retry.
Contohnya:
Job #1 → gagal
↓
Retry
↓
Job #2Karena itu, job payment harus tetap idempotent.
Jangan menganggap job hanya akan dijalankan satu kali.
17. Audit Log
Setiap webhook penting dapat dicatat:
Event ID
Transaction ID
Event Type
Status
Waktu
ResponseContohnya:
Log::info(
'Payment webhook processed',
[
'event_id' => $data['event_id'],
'transaction_id'
=> $data['transaction_id'],
'status' => $data['status'],
]
);Jangan menyimpan secret atau data sensitif ke log.
Contoh Alur Payment yang Aman
Customer
↓
Payment Gateway
↓
Webhook Laravel
↓
Signature Verification
↓
Validate Payload
↓
Check Event ID
↓
Check Transaction ID
↓
Database Lock
↓
Check Payment Status
↓
Update Payment
↓
Update Order
↓
Mark Event Processed
↓
Response 200Dengan pola tersebut, webhook yang sama dapat diterima berkali-kali tanpa membuat transaksi ganda.
Kesalahan yang Perlu Dihindari
Tidak Menggunakan Unique Constraint
Database harus membantu mencegah data duplicate.
Hanya Menggunakan first() Tanpa Lock
Dalam kondisi concurrent, dua request masih dapat masuk bersamaan.
Membuat Transaksi Baru Setiap Webhook
Webhook harus diperlakukan sebagai event, bukan transaksi baru.
Tidak Memeriksa Status
Pembayaran yang sudah paid jangan diproses kembali.
Tidak Menyimpan Event ID
Jika provider menyediakan event ID, gunakan sebagai idempotency key.
Tidak Menggunakan Queue dengan Aman
Job retry juga harus idempotent.
Pencegahan Transaksi Ganda Bersama Ovla Media
Ovla Media dapat membantu membangun integrasi pembayaran Laravel dengan:
Payment gateway.
Webhook.
Signature verification.
Idempotency.
Database transaction.
Queue.
Retry handling.
Audit log.
QRIS.
Sistem billing dan invoice.
Implementasi dapat digunakan untuk Point of Sales, marketplace, sistem klinik, koperasi, SaaS, maupun aplikasi bisnis custom.
Kesimpulan
Transaksi ganda dari webhook biasanya terjadi karena webhook dapat dikirim ulang atau diterima secara bersamaan.
Solusi utamanya adalah:
Unique Transaction ID
+
Event ID
+
Database Transaction
+
Locking
+
Status Validation
+
IdempotencyAlur yang aman:
Webhook
↓
Verifikasi
↓
Cek Event
↓
Cek Transaction
↓
Lock
↓
Cek Status
↓
Update SekaliDengan pendekatan tersebut, webhook yang sama dapat diterima beberapa kali tanpa menyebabkan saldo, transaksi, atau pembayaran tercatat lebih dari satu kali.
Artikel Terkait
Cara Mengatasi Could Not Find Driver pada Laravel dan PHP
Error “Could Not Find Driver” pada Laravel biasanya terjadi karena extension PHP untuk database yang digunakan belum terpasang atau belum aktif. Masalah ini sering ditemukan saat menggunakan MySQL, PostgreSQL, SQLite, atau aplikasi Laravel pada server production.
Cara Mengatasi Database Connection Refused pada Laravel
Error “Database Connection Refused” pada Laravel biasanya terjadi ketika aplikasi tidak dapat terhubung ke server database. Penyebabnya dapat berasal dari konfigurasi .env, database yang belum berjalan, port yang salah, firewall, Docker, atau konfigurasi server. Berikut langkah sederhana untuk menemukan dan mengatasinya.
Cara Membuat Backup Database Laravel Otomatis Setiap Hari
Backup database Laravel otomatis membantu melindungi data aplikasi dari kerusakan server, kesalahan pengguna, atau kehilangan data. Dengan Laravel Scheduler dan cron job, proses backup dapat dijalankan secara rutin setiap hari tanpa dilakukan secara manual.