Pembeli membayar satu order, tetapi menerima dua kode voucher. Di log, order_id yang sama tercatat dua kali dengan kode berbeda, pada detik yang sama:

[2026-10-16 06:59:49] local.INFO: Voucher issued {"order_id":"DEMO-YEYRMSTR","code":"P2DZEDXKYJWN"}
[2026-10-16 06:59:49] local.INFO: Voucher issued {"order_id":"DEMO-YEYRMSTR","code":"2LEVML2YGIJJ"}

Kalau kamu menjual produk digital, setiap kode yang terbit dua kali adalah barang yang diberikan gratis. Di toko lain, bentuknya bisa stok yang terpotong dua kali atau poin loyalitas yang bertambah dua kali.

Midtrans sudah menyebut kemungkinan ini di dokumentasinya. Pada kasus yang sangat jarang, notifikasi untuk event transaksi yang sama bisa terkirim lebih dari sekali, dan itu tidak boleh membuat data ganda di sisi merchant. Jadi yang harus menjaga adalah handler-mu: notifikasi yang sama, diproses berapa kali pun, hasilnya tetap satu voucher. Handler seperti itu disebut idempoten.

Di artikel ini kita reproduksi dua cara satu pembayaran berubah menjadi dua voucher di Laravel, melihat kenapa test duplikat yang biasa tetap hijau, lalu memperbaikinya dengan satu UPDATE bersyarat. Semua output di sini berasal dari menjalankan repo contohnya dengan Laravel 13 di SQLite dan MySQL 8.4, jadi kamu bisa mencobanya sendiri.

Apa itu idempotent

RFC 9110, spesifikasi semantik HTTP, menyebut sebuah method idempoten kalau efek yang dimaksud di server dari beberapa request identik sama dengan efek satu request saja. Menurut RFC itu, PUT, DELETE, dan method yang aman seperti GET idempoten. POST tidak.

RFC yang sama juga bilang client sebaiknya tidak mengulang request dengan method yang tidak idempoten secara otomatis, kecuali ia tahu request itu sebenarnya idempoten, atau tahu request pertamanya tidak pernah diproses. Notifikasi Midtrans dikirim sebagai POST, dan Midtrans mengirimnya ulang kalau servermu tidak membalas 2xx. Supaya pengiriman ulang itu aman, efek notifikasi di servermu harus idempoten.

Untuk melacak apakah sebuah pembayaran sudah diproses, Midtrans menyebut order_id bisa dipakai sebagai kuncinya. Kita pakai kunci itu sepanjang artikel.

Dari mana notifikasi dobel datang

Ada empat jalan yang membuat handler-mu memproses pembayaran yang sama lebih dari sekali.

Retry. Midtrans menunggu balasan paling lama 15 detik, dan notifikasi baru dianggap berhasil kalau servermu membalas 2xx. Kalau tidak, Midtrans mengirim ulang notifikasi itu, paling banyak lima kali, dengan jeda sekitar 2 menit, 10 menit, 30 menit, 1,5 jam, dan 3,5 jam. Jumlah retry-nya tergantung status code yang kamu balas, dan redirect 301, 302, dan 303 tidak diulang sama sekali; tabel lengkapnya ada di artikel sebelumnya. Kalau voucher sudah terbit sebelum handler-mu gagal, retry akan menerbitkannya lagi.

Duplikat dari Midtrans. Seperti tadi: pada kasus yang sangat jarang, Midtrans bisa mengirim beberapa notifikasi untuk event yang sama.

Dua status yang sama-sama berarti lunas. Untuk pembayaran kartu, status capture sudah berarti pembayaran berhasil. Kalau merchant tidak melakukan apa-apa, transaksi itu di-settle pada hari yang sama, hari berikutnya, atau sesuai waktu settlement yang disepakati dengan bank partner, lalu statusnya berubah menjadi settlement. Midtrans juga mengirim notifikasi saat status transaksi berubah, jadi satu pembayaran kartu bisa datang sebagai dua notifikasi yang sama-sama lolos pengecekan “sudah dibayar”.

Jalur lain di aplikasimu sendiri. Kalau kamu memasang rekonsiliasi lewat Get Status API seperti di artikel sebelumnya, command itu bisa memproses order yang sama tepat saat notifikasinya datang.

Retry dan pasangan capture lalu settlement datang berurutan, berjarak menit atau hari. Dokumentasi Midtrans tidak menyebut jarak antar-duplikat, dan rekonsiliasi jelas bisa berjalan bersamaan dengan notifikasi. Jadi handler-mu harus aman untuk dua keadaan: duplikat yang datang belakangan, dan duplikat yang datang pada saat yang sama.

Reproduksi: satu pembayaran, dua voucher

Repo contohnya adalah toko voucher game: setiap order yang dibayar mendapat satu kode voucher lewat email. Handler di tag 1-bug memakai pola cek-lalu-kerjakan: cek status, kerjakan efeknya, lalu tandai order sebagai paid:

// app/Http/Controllers/MidtransNotificationController.php, tag 1-bug
$order = Order::where('order_id', $request->input('order_id'))->firstOrFail();

if ($order->status === 'pending' && Order::isPaid($request->all())) {
    $voucher = $order->vouchers()->create(['code' => Str::upper(Str::random(12))]);
    Log::info('Voucher issued', ['order_id' => $order->order_id, 'code' => $voucher->code]);

    Mail::to($order->email)->send(new VoucherIssued($voucher));

    $order->update(['status' => 'paid', 'paid_at' => now()]);
}

return response('OK');

Sebelum bagian ini, handler memverifikasi signature_key seperti di artikel sebelumnya. Order::isPaid() mengecek tiga field yang disebut Midtrans untuk transaksi yang berhasil: status_code bernilai 200, transaction_status bernilai capture atau settlement, dan fraud_status, kalau ada, bernilai accept.

Repo ini sudah punya test untuk notifikasi dobel:

public function test_duplicate_notification_issues_no_second_voucher(): void
{
    $this->postJson('/midtrans/notification', $this->notification())->assertOk();
    $this->postJson('/midtrans/notification', $this->notification())->assertOk();

    $this->assertSame(1, $this->order->vouchers()->count());
    $this->assertSame(1, $this->emailsSent());
}

Dan test itu hijau:

   PASS  Tests\Feature\MidtransNotificationTest
  ✓ settlement issues one voucher and emails it                          0.17s
  ✓ invalid signature is rejected                                        0.01s
  ✓ duplicate notification issues no second voucher                      0.01s

  Tests:    3 passed (10 assertions)
  Duration: 0.47s

Notifikasi kedua di test ini baru dikirim setelah yang pertama selesai. Saat itu order sudah paid, jadi if-nya menolak. Duplikat yang datang belakangan memang tertangani. Yang datang bersamaan belum.

Dua notifikasi pada saat yang sama

Server bawaan PHP berjalan sebagai satu proses single-threaded, jadi php artisan serve biasa tidak bisa memproses dua request sekaligus. Untuk mencoba request paralel, jalankan dengan beberapa worker lewat PHP_CLI_SERVER_WORKERS (tidak didukung di Windows). Lalu command demo:notifikasi-dobel membuat 50 order dan, untuk setiap order, mengirim notifikasi settlement yang sama dua kali sekaligus:

PHP_CLI_SERVER_WORKERS=4 php artisan serve --no-reload
php artisan demo:notifikasi-dobel --orders=50

Hasilnya di MySQL 8.4:

Status HTTP: {"200":100}
Order dengan lebih dari satu voucher: 49 dari 50

Angkanya berubah setiap kali dijalankan. Dalam beberapa kali percobaan di SQLite dan MySQL, hasilnya antara 35 dan 49 dari 50. Perhatikan baris pertamanya: semua request dibalas 200. Bagi Midtrans, balasan 2xx berarti berhasil, jadi tidak ada yang akan diulang. Log juga tidak mencatat satu error pun. Dua baris log di awal artikel berasal dari run ini.

Kenapa if di PHP tidak cukup

Kedua request membaca order sebelum salah satunya sempat menulis. Di MySQL, SELECT biasa adalah consistent read: ia membaca snapshot dan tidak memasang lock apa pun, jadi tidak ada yang menahan request kedua membaca baris yang sama. Dua request sama-sama melihat pending, dua-duanya masuk ke if, dan dua-duanya menerbitkan voucher.

Jendela race condition-nya adalah semua yang terjadi di antara SELECT di awal dan UPDATE di akhir: membuat voucher dan mengirim email. Makin lambat server email-mu, makin lebar jendelanya.

Diagram ini menunjukkan dua notifikasi untuk satu order, sebelum perbaikan dan sesudahnya:

Diagram dua notifikasi untuk satu order, dalam dua bagian. Sebelum perbaikan, notifikasi 1 dan notifikasi 2 sama-sama mengecek status dan mendapat pending, sama-sama menerbitkan voucher, lalu sama-sama menandai paid: hasilnya 2 voucher untuk 1 order. Sesudah perbaikan, UPDATE dari notifikasi 1 mengubah 1 baris, lalu notifikasi 1 menerbitkan voucher dan COMMIT. UPDATE dari notifikasi 2 menunggu sampai COMMIT melepas kunci, lalu tidak mengubah baris apa pun dan tidak menerbitkan voucher: hasilnya 1 voucher untuk 1 order.

Pola “cek dulu, baru kerjakan” aman selama hanya satu request yang berjalan. Begitu ada dua, cek dan tulisnya harus menjadi satu langkah, dan yang bisa menjamin itu adalah database.

Test yang menangkap bug ini

Tag 2-test-merah menambah tiga test. Dua yang pertama memakai event MessageSending dari Laravel, yang dikirim tepat sebelum sebuah email dikirim, untuk menyisipkan kejadian di tengah proses.

Test pertama meniru retry. Server email time out sekali, handler gagal, lalu Midtrans mengirim ulang:

public function test_retry_after_a_failed_email_issues_no_second_voucher(): void
{
    // The mail server times out once, so the first attempt fails and Midtrans retries.
    $failed = false;
    Event::listen(MessageSending::class, function () use (&$failed) {
        if (! $failed) {
            $failed = true;
            throw new TransportException('Connection to smtp.example.com:587 timed out.');
        }
    });

    $this->postJson('/midtrans/notification', $this->notification());
    $this->postJson('/midtrans/notification', $this->notification())->assertOk();

    $this->assertSame(1, $this->order->vouchers()->count());
}

Test kedua mengirim duplikat tepat saat notifikasi pertama sedang mengirim email. Itu salah satu titik di dalam jendela race tadi. Bedanya dengan demo paralel, hasil test ini selalu sama setiap kali dijalankan:

public function test_duplicate_arriving_mid_processing_issues_no_second_voucher(): void
{
    // The duplicate arrives while the first notification is still sending its email,
    // one spot inside the window that two parallel requests can hit in production.
    $duplicateSent = false;
    Event::listen(MessageSending::class, function () use (&$duplicateSent) {
        if (! $duplicateSent) {
            $duplicateSent = true;
            $this->postJson('/midtrans/notification', $this->notification())->assertOk();
        }
    });

    $this->postJson('/midtrans/notification', $this->notification())->assertOk();

    $this->assertSame(1, $this->order->vouchers()->count());
    $this->assertSame(1, $this->emailsSent());
}

Test ketiga memastikan database sendiri menolak voucher kedua untuk order yang sama, dari jalur kode mana pun:

public function test_database_refuses_a_second_voucher_for_one_order(): void
{
    $this->order->vouchers()->create(['code' => 'KODEPERTAMA1']);

    $this->expectException(UniqueConstraintViolationException::class);
    $this->order->vouchers()->create(['code' => 'KODEKEDUA002']);
}

Dengan kode 1-bug, ketiganya merah. Dua test pertama gagal dengan pesan yang sama, Failed asserting that 2 is identical to 1: dua voucher untuk satu order.

   FAIL  Tests\Feature\MidtransNotificationTest
  ✓ settlement issues one voucher and emails it                          0.10s
  ✓ invalid signature is rejected                                        0.01s
  ✓ duplicate notification issues no second voucher                      0.01s
  ⨯ retry after a failed email issues no second voucher                  0.02s
  ⨯ duplicate arriving mid processing issues no second voucher           0.01s
  ⨯ database refuses a second voucher for one order

Perbaikannya: klaim order di database

Ini handler di tag 3-perbaikan:

// app/Http/Controllers/MidtransNotificationController.php, tag 3-perbaikan
$order = Order::where('order_id', $request->input('order_id'))->firstOrFail();

if (! Order::isPaid($request->all())) {
    return response('OK');
}

$voucher = DB::transaction(function () use ($order) {
    // Claim the order with one conditional UPDATE. The database lets only
    // one notification move it out of pending; every other one gets 0 rows.
    $claimed = Order::whereKey($order->id)
        ->where('status', 'pending')
        ->update(['status' => 'paid', 'paid_at' => now()]) === 1;

    // Same transaction as the claim: if this fails, the order stays
    // pending and Midtrans' retry can try again.
    return $claimed ? $order->vouchers()->create(['code' => Str::upper(Str::random(12))]) : null;
});

if ($voucher) {
    Log::info('Voucher issued', ['order_id' => $order->order_id, 'code' => $voucher->code]);

    // After the commit, through the queue: a slow or failing mail server
    // no longer fails the notification, and the queue retries the email.
    Mail::to($order->email)->queue(new VoucherIssued($voucher));
}

return response('OK');

Ada empat perubahan.

1. Klaim dengan UPDATE bersyarat

UPDATE ... WHERE status = 'pending' mengecek dan menulis dalam satu perintah. Method update() di Laravel mengembalikan jumlah baris yang berubah, jadi === 1 berarti notifikasi ini yang menang. Notifikasi lain mendapat 0 dan tidak menerbitkan apa-apa.

Kenapa notifikasi kedua tidak ikut melihat pending? Di MySQL, UPDATE ... WHERE memasang exclusive lock pada baris yang ditemukannya. Dengan isolation level default-nya, REPEATABLE READ, lock itu ditahan sampai transaksi selesai, jadi UPDATE kedua tertahan sampai transaksi pertama commit atau rollback. Dokumentasi MySQL juga menyebut snapshot yang dipakai SELECT tidak selalu berlaku untuk UPDATE.

Dokumentasi PostgreSQL menulis langkah sesudahnya secara eksplisit untuk Read Committed, level default-nya: setelah transaksi pertama commit, WHERE milik UPDATE kedua dievaluasi ulang terhadap versi baris yang baru. Statusnya sudah paid, jadi tidak ada baris yang cocok. Itu bagian bawah diagram tadi. Hasilnya di MySQL 8.4 dan SQLite ada di demo paralel di bawah.

Kalau kamu sudah menerapkan artikel sebelumnya, applyMidtransStatus() di sana sudah memakai UPDATE bersyarat yang sama dan mengembalikan true kalau order berubah. Yang belum ada: efek sampingnya harus bergantung pada hasil itu, di dalam transaksi yang sama, bukan pada if yang membaca status sebelumnya.

2. Klaim dan voucher dalam satu transaksi

DB::transaction() melakukan commit kalau closure-nya selesai, dan rollback kalau ada exception. Kalau pembuatan voucher gagal, klaimnya ikut batal: order tetap pending, handler membalas 500, dan retry dari Midtrans bisa mencoba lagi. Tanpa transaksi, kamu bisa berakhir dengan order paid yang tidak punya voucher.

3. Email lewat antrean, setelah commit

Email adalah efek di luar database, jadi tidak ikut di-rollback. Di kode lama, email yang gagal membuat handler membalas 500 setelah voucher terbit, dan retry-nya menerbitkan voucher kedua. Sekarang email masuk antrean setelah transaksi selesai. Server email yang lambat atau mati tidak lagi menahan balasan ke Midtrans, yang menyarankan balasan di bawah 5 detik.

Sebagai gantinya, antrean yang mengulang email itu. Secara default Laravel hanya mencoba sebuah job sekali, jadi jumlah percobaannya ditulis di mailable:

// app/Mail/VoucherIssued.php
// Retried by the queue worker, not by a Midtrans retry.
#[Tries(5)]
#[Backoff(60)]
class VoucherIssued extends Mailable

Di test, antreannya sync, jadi email yang gagal di test retry tadi tetap membuat request pertama dibalas 500. Yang dicek test itu adalah hasil akhirnya: satu voucher.

4. Unique index sebagai pengaman terakhir

// database/migrations/2026_10_16_100002_add_unique_order_id_to_vouchers_table.php
Schema::table('vouchers', function (Blueprint $table) {
    // One voucher per order, enforced by the database for every code path.
    $table->unique('order_id');
});

Klaim tadi menjaga handler notifikasi. Unique index menjaga semua jalur: command rekonsiliasi, tombol di panel admin, atau kode yang ditulis tahun depan. Kalau ada yang mencoba menerbitkan voucher kedua, database menolaknya dengan UniqueConstraintViolationException.

Kalau tabelmu di production sudah berisi voucher dobel, migration ini gagal. Di MySQL, hasilnya seperti ini:

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '2' for key 'vouchers.vouchers_order_id_unique' (Connection: mysql, Host: 127.0.0.1, Port: 3307, Database: demo, SQL: alter table `vouchers` add unique `vouchers_order_id_unique`(`order_id`))

Cari dulu order yang terdampak, putuskan apa yang dilakukan dengan voucher kelebihannya, baru jalankan migration-nya:

SELECT order_id, COUNT(*) FROM vouchers GROUP BY order_id HAVING COUNT(*) > 1;

Dengan perbaikan ini, keenam test hijau:

   PASS  Tests\Feature\MidtransNotificationTest
  ✓ settlement issues one voucher and emails it                          0.20s
  ✓ invalid signature is rejected                                        0.01s
  ✓ duplicate notification issues no second voucher                      0.01s
  ✓ retry after a failed email issues no second voucher                  0.02s
  ✓ duplicate arriving mid processing issues no second voucher           0.01s
  ✓ database refuses a second voucher for one order

  Tests:    6 passed (17 assertions)
  Duration: 0.52s

Demo paralel yang tadi menghasilkan 49 dari 50 sekarang menghasilkan ini, di SQLite maupun MySQL 8.4:

Status HTTP: {"200":100}
Order dengan lebih dari satu voucher: 0 dari 50

Kalau efeknya memanggil API lain

Email bukan satu-satunya efek yang tidak bisa di-rollback. Kalau menerbitkan voucher berarti membeli kode dari supplier lewat API, panggilan itu juga ada di luar transaksi. Jangan panggil API itu di dalam DB::transaction(): kalau transaksinya rollback setelah API dipanggil, kodenya sudah terbeli tetapi tidak tercatat. Repo contoh tidak mencakup kasus ini, tetapi polanya sama dengan email. Catat dulu di database, dalam transaksi yang sama dengan klaim, lalu panggil API-nya dari job antrean setelah commit. Kalau API itu menerima idempotency key, kirim key yang sama setiap kali job-nya diulang, misalnya order_id.

Idempotency-Key saat kamu memanggil Midtrans

Masalah yang sama muncul ke arah sebaliknya: servermu mengirim request ke Midtrans, lalu koneksinya putus sebelum balasan datang. Kamu tidak tahu apakah request-nya sudah diproses. Untuk itu Midtrans menerima header Idempotency-Key. Request dengan key yang sama hanya diproses sekali, dan Midtrans mengembalikan balasan sukses yang sama, apa pun isi body-nya. Beberapa hal dari dokumentasinya:

  • Berlaku di semua request POST, kecuali ke endpoint /token dan /account.
  • Panjang maksimal 46 karakter. Key yang lebih panjang diabaikan. Midtrans menyarankan UUID.
  • Key berlaku lima menit. Setelah itu request diproses lagi.
  • Key berlaku global di semua endpoint, jadi buat key baru untuk setiap operasi yang berbeda pada transaksi yang sama.
  • Kalau kamu mengulang request saat request pertama masih diproses, Midtrans membalas 202 sampai request pertama selesai.
  • Belum didukung untuk Permata Virtual Account, CIMB Clicks, KlikBCA, dan Indomaret.

Checklist webhook yang idempoten

  • Tentukan kunci idempotensinya. Untuk Midtrans: order_id.
  • Cek dan klaim dalam satu UPDATE bersyarat, dan jalankan efeknya hanya kalau ada baris yang berubah.
  • Klaim dan efek di database ada dalam satu transaksi.
  • Tabel efeknya punya unique constraint pada kunci itu, supaya jalur lain ikut terjaga.
  • Efek di luar database, seperti email dan panggilan API, berjalan di antrean setelah commit, dengan retry di antrean.
  • Handler membalas 2xx dalam waktu kurang dari 5 detik.
  • Ada test untuk duplikat yang datang di tengah proses, bukan hanya duplikat yang berurutan.

Sumber

Semua sumber dicek pada 16 Oktober 2026.

Kode contoh lengkap di GitHub →