Masalah Nyata: Webhook Terkirim, Order Diproses Dua Kali
Pada integrasi payment gateway atau provider shipping, webhook adalah mekanisme umum untuk mengirim status transaksi ke aplikasi Laravel. Masalahnya, webhook hampir tidak pernah bisa diasumsikan datang hanya satu kali. Banyak provider menerapkan pola at least once delivery, artinya event yang sama dapat dikirim ulang jika mereka menganggap pengiriman sebelumnya gagal, timeout, atau tidak mendapat respons yang valid.
Inilah mengapa topik Laravel webhook duplicate dan idempotency key sangat penting dalam aplikasi produksi. Tanpa desain yang tepat, satu event yang sama bisa memicu perubahan data dan side effect berulang.
Mengapa webhook bisa terkirim ulang?
- Server Anda merespons terlalu lambat sehingga provider menganggap request gagal.
- Respons bukan HTTP
2xx. - Koneksi putus setelah provider mengirim payload, tetapi sebelum provider menerima respons.
- Provider memang memiliki mekanisme retry otomatis.
- Terjadi race condition ketika dua request webhook masuk hampir bersamaan.
- Aplikasi melakukan proses berat langsung di controller sebelum mengembalikan respons.
Contoh kasus yang sering terjadi
Studi kasus umum pada payment gateway:
- Payment gateway mengirim event
payment.paid. - Controller Laravel langsung menjalankan proses berat: update order, kirim email, generate invoice, sinkron ke ERP.
- Respons ke provider menjadi lambat, misalnya lebih dari 10 detik.
- Provider menganggap webhook gagal dan melakukan retry.
- Webhook kedua masuk saat proses pertama belum selesai atau baru selesai sebagian.
Akibatnya, order bisa ditandai lunas dua kali, stok berkurang dua kali, invoice ganda terbentuk, atau email notifikasi terkirim berulang.
Kasus serupa juga sering muncul pada integrasi shipping. Misalnya event shipment.delivered atau shipment.updated diproses berulang. Hasilnya bisa berupa histori pengiriman duplikat, notifikasi pelanggan terkirim dua kali, atau alur operasional internal maju terlalu cepat.
Kasus tambahan yang sering luput
- Top up saldo: saldo pelanggan bertambah dua kali karena event
wallet.crediteddiproses ulang. - Membership atau subscription: langganan diperpanjang dua kali untuk satu pembayaran yang sama.
- Voucher atau poin: reward diberikan berulang karena event sukses transaksi tidak ditahan dengan idempotency.
- Sinkronisasi ERP/WMS: dokumen pengiriman atau invoice eksternal dibuat dua kali karena callback diproses ganda.
- Status order mundur atau meloncat: event datang tidak berurutan, misalnya
deliveredlebih dulu daripadaout_for_delivery, lalu sistem memproses semuanya tanpa validasi transisi status.
Inti masalahnya: endpoint webhook harus idempoten. Artinya, menerima event yang sama berkali-kali tetap menghasilkan efek akhir yang sama, bukan menjalankan side effect berulang.
Apa Itu Idempotency pada Webhook Laravel?
Dalam konteks webhook, idempotency berarti event yang identik hanya menghasilkan satu efek bisnis, meskipun request masuk lebih dari sekali. Jika provider mengirim ulang payload yang sama lima kali, aplikasi tetap hanya melakukan satu perubahan penting.
Perlu dibedakan bahwa idempotency bukan sekadar ignore duplicate request. Idempotency yang baik juga harus tahan terhadap kondisi berikut:
- Dua request identik masuk hampir bersamaan.
- Request kedua datang ketika request pertama masih diproses.
- Proses pertama gagal di tengah jalan setelah sebagian data berubah.
- Job queue ikut dipush lebih dari sekali.
Karena itu, solusi yang kuat biasanya tidak hanya mengandalkan satu lapisan. Anda perlu kombinasi validasi signature webhook, idempotency key, unique constraint database, locking, dan queue Laravel.
Strategi Aman: Jangan Proses Berat Langsung di Controller
Salah satu kesalahan paling umum adalah menjalankan seluruh logika bisnis langsung di endpoint webhook. Secara teknis bisa, tetapi risikonya tinggi. Semakin lama endpoint merespons, semakin besar kemungkinan provider mengirim ulang event yang sama.
Pola yang lebih aman di Laravel adalah:
- Terima request webhook.
- Validasi signature dan struktur payload.
- Ambil event identifier atau bentuk idempotency key.
- Simpan event ke database dengan penanda unik.
- Jika event baru, push ke queue.
- Kembalikan respons HTTP
200atau202secepat mungkin.
Dengan pendekatan ini, endpoint menjadi ringan, sedangkan proses berat dijalankan terpisah oleh worker queue.
Lapisan 1: Validasi Signature Webhook
Sebelum memikirkan duplikasi, pastikan request memang datang dari provider yang sah. Banyak integrasi payment gateway Laravel dan shipping webhook Laravel menyediakan header signature, HMAC, atau token rahasia.
Tanpa validasi signature, endpoint webhook Anda bisa dipanggil siapa saja. Ini bukan hanya soal keamanan, tetapi juga mengurangi noise dari request palsu yang bisa ikut membebani mekanisme idempotency.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
public function handle(Request $request)
{
$signature = $request->header('X-Signature');
$payload = $request->getContent();
$secret = config('services.provider.webhook_secret');
$computed = hash_hmac('sha256', $payload, $secret);
if (! hash_equals($computed, (string) $signature)) {
Log::warning('Invalid webhook signature', [
'signature' => $signature,
]);
return response()->json(['message' => 'Invalid signature'], 401);
}
// lanjut ke proses idempotency
}Selalu cek dokumentasi provider. Ada yang menandatangani raw body, ada yang memakai timestamp + body, dan ada yang menyertakan beberapa header tambahan untuk mencegah replay attack.
Lapisan 2: Gunakan Idempotency Key yang Stabil
Idempotency key adalah identitas unik untuk satu event webhook. Idealnya, ambil dari field resmi yang dikirim provider, misalnya:
event_ididreferencetransaction_id- kombinasi
provider + event_type + external_id
Jika provider tidak memberi event ID yang andal, Anda bisa membentuk key sendiri dari data yang benar-benar unik dan stabil. Hindari memakai timestamp yang berubah-ubah, karena itu justru membuat event duplikat terlihat sebagai event baru.
$provider = 'midtrans';
$eventType = $request->input('event');
$externalId = $request->input('data.transaction_id');
$idempotencyKey = sha1($provider.'|'.$eventType.'|'.$externalId);Kunci utama di sini: event yang secara bisnis sama harus menghasilkan key yang sama.
Lapisan 3: Simpan Event di Database dengan Unique Constraint
Jangan hanya mengecek duplikasi di level aplikasi dengan if biasa. Itu rentan race condition. Dua request bisa sama-sama lolos pengecekan lalu sama-sama memproses event.
Solusi yang lebih kuat adalah menyimpan event webhook ke tabel khusus dengan unique constraint pada kolom idempotency key.
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('event_type');
$table->string('idempotency_key')->unique();
$table->string('external_event_id')->nullable()->index();
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
});Saat webhook masuk, coba insert record baru. Jika gagal karena pelanggaran unique constraint, berarti event tersebut sudah pernah diterima.
use App\Models\WebhookEvent;
use Illuminate\Database\QueryException;
try {
$event = WebhookEvent::create([
'provider' => 'midtrans',
'event_type' => $request->input('event'),
'idempotency_key' => $idempotencyKey,
'external_event_id' => $request->input('id'),
'payload' => $request->all(),
]);
} catch (QueryException $e) {
return response()->json([
'message' => 'Duplicate webhook ignored'
], 200);
}Pendekatan ini jauh lebih aman dibanding hanya menjalankan:
if (! WebhookEvent::where('idempotency_key', $idempotencyKey)->exists()) {
// simpan dan proses
}Alasannya sederhana: pengecekan dan penyimpanan bukan operasi atomik jika tidak dilindungi database.
Lapisan 4: Gunakan Transaction dan Locking untuk Resource Bisnis
Meskipun event webhook sudah unik, Anda tetap perlu hati-hati saat mengubah entitas bisnis seperti order, shipment, invoice, atau saldo. Di sinilah database locking berperan.
Misalnya, dua event berbeda tetapi terkait order yang sama masuk hampir bersamaan. Contoh: payment.authorized dan payment.paid, atau shipment.updated dan shipment.delivered. Jika keduanya mengubah record yang sama, race condition masih bisa terjadi.
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($orderId, $event) {
$order = Order::where('id', $orderId)
->lockForUpdate()
->firstOrFail();
if ($order->payment_status === 'paid') {
return;
}
$order->payment_status = 'paid';
$order->paid_at = now();
$order->save();
// side effect penting lain yang harus konsisten
});lockForUpdate() membantu memastikan satu transaksi selesai lebih dulu sebelum transaksi lain mengubah baris yang sama.
Locking bukan pengganti idempotency key. Locking melindungi konsistensi data saat resource yang sama dimodifikasi bersamaan, sedangkan idempotency key mencegah event yang sama diproses dua kali.
Lapisan 5: Push ke Queue, Jangan Kirim Email dari Endpoint
Untuk menjaga endpoint webhook tetap cepat, pindahkan pekerjaan berat ke queue Laravel. Yang termasuk proses berat antara lain:
- kirim email, WhatsApp, atau push notification
- generate invoice PDF
- sinkronisasi ke ERP, CRM, atau WMS
- perhitungan komisi, cashback, atau loyalty point
- update banyak tabel turunan
Controller webhook sebaiknya hanya melakukan validasi, pencatatan event, dan enqueue job.
ProcessWebhookEvent::dispatch($event->id);
return response()->json(['message' => 'Accepted'], 202);Namun, perlu diingat: queue juga bisa memproses job dua kali dalam kondisi tertentu, misalnya worker restart, timeout, atau retry. Karena itu, job Anda juga harus idempoten.
Membuat Job Queue Tetap Idempoten
Jangan anggap masalah selesai setelah event masuk ke queue. Job yang memproses event tetap harus mengecek apakah event sudah dieksekusi.
class ProcessWebhookEvent implements ShouldQueue
{
public function __construct(public int $eventId) {}
public function handle(): void
{
$event = WebhookEvent::findOrFail($this->eventId);
if ($event->processed_at) {
return;
}
DB::transaction(function () use ($event) {
$freshEvent = WebhookEvent::whereKey($event->id)
->lockForUpdate()
->firstOrFail();
if ($freshEvent->processed_at) {
return;
}
// proses bisnis berdasarkan payload
// update order, shipment, invoice, dll
$freshEvent->processed_at = now();
$freshEvent->save();
});
}
}Dengan pola ini, meskipun job yang sama sempat dieksekusi ulang, event tidak akan menimbulkan efek bisnis ganda.
Validasi Transisi Status: Jangan Hanya Cek Duplikasi
Dalam integrasi dunia nyata, masalah tidak selalu berupa event yang identik. Kadang event berbeda datang dengan urutan yang tidak ideal. Misalnya:
payment.pendingdatang setelahpayment.paidshipment.in_transitdatang setelahshipment.deliveredorder.cancelleddatang setelah order sudah selesai diproses
Karena itu, selain idempotency key, Anda perlu aturan transisi status. Sistem harus tahu status mana yang valid untuk maju, status mana yang harus diabaikan, dan status mana yang memerlukan investigasi.
if ($shipment->status === 'delivered') {
return;
}
$allowedTransitions = [
'pending' => ['picked_up', 'cancelled'],
'picked_up' => ['in_transit', 'cancelled'],
'in_transit' => ['delivered', 'failed'],
];
if (! in_array($newStatus, $allowedTransitions[$shipment->status] ?? [], true)) {
// log anomali, abaikan, atau kirim ke monitoring
return;
}Pendekatan ini sangat membantu pada integrasi shipping dan order fulfillment yang event-nya sering tidak linear.
Contoh Alur Implementasi Webhook Laravel yang Aman
Berikut alur yang lebih aman dan realistis untuk implementasi Laravel webhook:
- Provider mengirim webhook ke endpoint Laravel.
- Laravel memvalidasi signature.
- Laravel mengekstrak event type dan idempotency key.
- Laravel mencoba menyimpan event ke tabel
webhook_events. - Jika insert gagal karena key sudah ada, kembalikan HTTP 200.
- Jika insert berhasil, dispatch job ke queue.
- Worker queue memproses event di dalam transaction.
- Record bisnis dikunci seperlunya dengan
lockForUpdate(). - Setelah sukses, tandai
processed_at. - Monitoring mencatat event sukses, duplikat, atau gagal.
Kesalahan yang Sering Terjadi
- Memakai cache saja untuk deduplication. Cache bisa expired, hilang, atau tidak konsisten antar node jika konfigurasi tidak tepat.
- Hanya mengandalkan pengecekan aplikasi. Tanpa unique constraint, race condition tetap bisa lolos.
- Tidak menyimpan payload mentah. Saat debugging insiden, Anda akan kesulitan menelusuri data asli dari provider.
- Tidak memisahkan penerimaan event dan proses bisnis. Ini membuat endpoint lambat dan mudah memicu retry.
- Mengirim side effect sebelum data inti aman. Misalnya email sukses terkirim padahal transaksi database gagal di tengah jalan.
- Tidak memonitor duplicate rate. Jika tiba-tiba banyak retry dari provider, itu bisa menandakan masalah performa atau timeout.
Kapan Cache atau Redis Tetap Berguna?
Database tetap menjadi fondasi utama untuk jaminan konsistensi, tetapi Redis atau cache masih berguna sebagai lapisan tambahan. Misalnya:
- menahan ledakan request identik dalam hitungan detik
- memberi lock singkat sebelum masuk ke proses lanjutan
- menyimpan rate limit atau anti-replay window berbasis timestamp
Namun untuk data kritis seperti pembayaran, jangan jadikan cache sebagai satu-satunya mekanisme anti-duplikasi. Unique constraint di database tetap lebih dapat diandalkan.
Monitoring dan Debugging Webhook Duplicate
Supaya sistem mudah dirawat, tambahkan observabilitas sejak awal. Minimal, log hal berikut:
- provider
- event type
- idempotency key
- external event ID
- waktu diterima
- status: baru, duplikat, diproses, gagal
- error message jika ada exception
Anda juga bisa menambahkan metrik seperti:
- jumlah webhook per provider
- persentase event duplikat
- waktu respons endpoint webhook
- lama proses job queue
- jumlah retry job
Jika duplicate rate naik tiba-tiba, biasanya penyebabnya adalah endpoint melambat, worker queue penuh, atau provider sedang mengalami gangguan dan melakukan retry agresif.
Checklist Praktis Implementasi
- Validasi signature setiap webhook.
- Tentukan idempotency key yang stabil dan benar-benar unik secara bisnis.
- Simpan event ke tabel khusus.
- Pasang unique constraint pada idempotency key.
- Respons cepat dengan HTTP 200/202.
- Jalankan proses berat lewat queue Laravel.
- Pastikan job queue juga idempoten.
- Gunakan transaction dan
lockForUpdate()saat mengubah resource penting. - Validasi transisi status agar event tidak memundurkan atau merusak state.
- Simpan payload untuk audit dan debugging.
- Tambahkan logging, monitoring, dan alert.
Penutup
Masalah webhook diproses dua kali di Laravel bukan kasus langka, terutama pada integrasi pembayaran dan pengiriman. Retry dari provider adalah hal normal, jadi aplikasi Anda harus dirancang untuk menghadapinya.
Solusi yang paling aman bukan satu trik tunggal, melainkan kombinasi beberapa lapisan: validasi signature webhook, idempotency key, unique constraint database, database locking, dan queue Laravel. Dengan pendekatan ini, Anda bisa mencegah order lunas dua kali, stok terpotong ganda, notifikasi duplikat, dan inkonsistensi data yang sulit diperbaiki.
Jika Anda membangun integrasi payment gateway Laravel atau shipping webhook Laravel, anggap semua webhook berpotensi datang ulang. Saat sistem dirancang idempoten sejak awal, duplicate webhook tidak lagi menjadi sumber insiden produksi yang mahal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!