Tutorial 23 Aug 2026 1 Kali Dibaca

Cara Mencegah Transaksi Ganda dari Webhook Payment Gateway

Gugun Nurdiansyah
Penulis
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 Laravel

Namun, webhook yang sama dapat dikirim kembali:

Webhook #1
Webhook #2
Webhook #3

Jika Laravel langsung membuat transaksi baru setiap kali menerima webhook, data pembayaran dapat menjadi duplikat.

Mengapa Webhook Bisa Terkirim Berkali-kali?

Beberapa penyebabnya:

  1. Server Laravel terlambat memberikan response.

  2. Koneksi mengalami timeout.

  3. Payment gateway melakukan retry.

  4. Response 200 OK tidak diterima dengan benar.

  5. Event pembayaran dikirim kembali.

  6. 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 lagi

Ini 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 = 100

dan payment gateway memiliki:

transaction_id = TRX-001

Keduanya memiliki fungsi berbeda.

Gunakan ID gateway untuk mengenali transaksi yang berasal dari provider:

Internal ID       : 100
Gateway ID        : TRX-001
Status            : paid

3. 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:

paid

Kemudian 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_01HXYZ123

Simpan:

$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_events

Contoh:

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:30

9. 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
↓
Create

sebelum 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 Transaksi

Jangan 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
refunded

Kemudian tentukan aturan perubahan status.

Contohnya:

pending → paid
pending → failed
pending → expired
paid → refunded

Hindari perubahan status sembarangan seperti:

paid → pending

kecuali memang diperbolehkan oleh mekanisme payment gateway.

13. Jangan Menambahkan Saldo Dua Kali

Ini sangat penting untuk sistem wallet atau saldo.

Misalnya:

Pembayaran Rp100.000

Webhook pertama:

Saldo + Rp100.000

Webhook kedua:

Saldo + Rp100.000

Akhirnya:

Saldo + Rp200.000

Padahal 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 Payment

Contohnya:

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 #2

Karena 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
Response

Contohnya:

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 200

Dengan 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:

  1. Payment gateway.

  2. Webhook.

  3. Signature verification.

  4. Idempotency.

  5. Database transaction.

  6. Queue.

  7. Retry handling.

  8. Audit log.

  9. QRIS.

  10. 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
+
Idempotency

Alur yang aman:

Webhook
↓
Verifikasi
↓
Cek Event
↓
Cek Transaction
↓
Lock
↓
Cek Status
↓
Update Sekali

Dengan pendekatan tersebut, webhook yang sama dapat diterima beberapa kali tanpa menyebabkan saldo, transaksi, atau pembayaran tercatat lebih dari satu kali.

#Webhook Payment Gateway #Transaksi Ganda #Duplicate Transaction Laravel #Idempotency Laravel #Payment Gateway Laravel #Webhook Laravel #Laravel Payment #Laravel Database Transaction #Laravel Queue #Laravel Security #Database Locking #Laravel API #Payment Webhook #Laravel Billing #Laravel QRIS #Sistem Pembayaran #Laravel Production #Web Development #Ovla Media #Jasa Laravel
Beranda Produk Artikel
Konsultasi
OVLA

Navigasi Utama

Hubungi Kami

Mulai Konsultasi Sekarang