Webhook Stripe di Laravel tidak cukup hanya menerima request lalu menjalankan logika bisnis. Endpoint webhook harus memverifikasi signature dari Stripe, menghindari pemrosesan ganda saat Stripe melakukan retry, dan mengembalikan respons 2xx secepat mungkin agar pengiriman event tidak terus diulang.
Implementasi yang aman biasanya memakai pola berikut: verifikasi signature berdasarkan payload mentah, simpan event id untuk deduplikasi, tulis event ke tabel inbox, kembalikan respons sukses segera, lalu proses pekerjaan berat melalui queue. Dengan pola ini, aplikasi lebih tahan terhadap retry, timeout, event duplikat, dan event yang datang out-of-order.
Alur request webhook yang direkomendasikan
Untuk endpoint webhook Stripe, alur yang aman di Laravel umumnya seperti ini:
- Stripe mengirim HTTP POST ke endpoint webhook Anda.
- Aplikasi membaca raw body dan header signature.
- Signature diverifikasi menggunakan webhook signing secret.
- Jika valid, event disimpan ke database sebagai catatan penerimaan.
- Gunakan event id sebagai kunci unik agar event duplikat tidak diproses dua kali.
- Kembalikan respons 2xx secepat mungkin.
- Dispatch job queue untuk pemrosesan async.
- Worker queue menjalankan logika bisnis dengan cara idempotent.
Prinsip penting: webhook adalah jalur masuk data eksternal. Perlakukan seperti sistem integrasi, bukan seperti controller biasa yang langsung menjalankan semua logika bisnis dalam satu request.
Kenapa verifikasi signature wajib
Kesalahan paling berbahaya adalah memproses webhook tanpa verifikasi signature. Jika endpoint hanya memeriksa bahwa request berisi JSON yang tampak valid, siapa pun yang tahu URL endpoint Anda bisa mengirim request palsu dan memicu perubahan status pembayaran, aktivasi langganan, atau pemberian akses yang tidak sah.
Stripe menyediakan header signature untuk memastikan bahwa payload benar-benar dikirim oleh Stripe dan belum dimodifikasi di tengah jalan. Verifikasi ini harus memakai payload mentah persis seperti yang diterima server. Jangan membangun ulang payload dari array hasil decode JSON, karena perubahan kecil pada whitespace atau urutan serialisasi bisa membuat verifikasi gagal.
Konfigurasi endpoint dan secret
Simpan signing secret di environment. Jangan hardcode ke source code.
STRIPE_WEBHOOK_SECRET=whsec_xxxPastikan endpoint webhook dikecualikan dari verifikasi CSRF jika aplikasi Anda menerapkan middleware CSRF untuk request web biasa.
Contoh route Laravel
<?php
use App\Http\Controllers\StripeWebhookController;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/stripe', StripeWebhookController::class);
Contoh controller untuk verifikasi signature
Contoh berikut menunjukkan pola umum. Nama kelas SDK dapat berbeda tergantung paket Stripe yang Anda pakai, tetapi idenya tetap sama: ambil raw payload, ambil header signature, verifikasi, lalu simpan event.
<?php
namespace App\Http\Controllers;
use App\Models\StripeWebhookEvent;
use App\Jobs\ProcessStripeWebhookEvent;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class StripeWebhookController
{
public function __invoke(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
if (!$signature || !$secret) {
Log::warning('Stripe webhook missing signature or secret');
return response('Invalid webhook', HttpResponse::HTTP_BAD_REQUEST);
}
try {
// Ganti dengan mekanisme verifikasi signature dari SDK Stripe yang Anda gunakan.
$event = app('stripe.webhook_verifier')->constructEvent($payload, $signature, $secret);
} catch (\Throwable $e) {
Log::warning('Stripe webhook signature verification failed', [
'message' => $e->getMessage(),
]);
return response('Invalid signature', HttpResponse::HTTP_BAD_REQUEST);
}
$stored = StripeWebhookEvent::firstOrCreate(
['stripe_event_id' => $event['id']],
[
'type' => $event['type'] ?? null,
'payload' => $event,
'status' => 'received',
'received_at' => now(),
]
);
if ($stored->wasRecentlyCreated) {
ProcessStripeWebhookEvent::dispatch($stored->id);
}
return response()->json(['received' => true], Response::HTTP_OK);
}
}
Mengapa pola ini bekerja:
- Verifikasi dilakukan sebelum logika bisnis, sehingga request palsu ditolak di awal.
- firstOrCreate dengan kunci unik pada stripe_event_id mencegah enqueue ganda untuk event yang sama.
- Respons 200 dikirim cepat, sehingga Stripe tidak perlu retry hanya karena aplikasi Anda sedang memproses pekerjaan berat.
Struktur tabel untuk menyimpan event masuk
Menyimpan event masuk memberi beberapa keuntungan: audit trail, deduplikasi, replay internal, debugging, dan observabilitas. Tabel ini sering disebut pola webhook inbox.
Contoh struktur tabel
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('stripe_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('stripe_event_id')->unique();
$table->string('type')->nullable()->index();
$table->string('status')->default('received')->index();
$table->json('payload');
$table->text('processing_error')->nullable();
$table->timestamp('received_at')->nullable()->index();
$table->timestamp('processed_at')->nullable()->index();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('stripe_webhook_events');
}
};
Field yang paling berguna
- stripe_event_id: kunci unik untuk deduplikasi.
- type: memudahkan routing logika berdasarkan jenis event.
- status: misalnya received, processing, processed, failed.
- payload: salinan payload asli untuk audit dan debugging.
- processing_error: simpan ringkasan error terakhir jika pemrosesan gagal.
- received_at dan processed_at: membantu melihat latensi dan retry.
Jika volume event tinggi, pertimbangkan strategi retensi data, arsip, atau menyimpan payload yang relevan saja. Menyimpan semua payload selamanya bisa membebani storage.
Retry aman: deduplikasi dan idempotensi
Stripe dapat mengirim ulang event ketika endpoint Anda timeout, mengembalikan status non-2xx, atau terjadi gangguan jaringan. Bahkan jika event yang sama dikirim ulang dengan payload identik, sistem Anda tetap harus aman terhadap pemrosesan berulang.
Deduplikasi dengan event ID
Langkah pertama adalah mencegah event yang sama masuk lebih dari sekali ke alur pemrosesan. Itulah fungsi kunci unik stripe_event_id. Jika request datang ulang, aplikasi cukup mengenali bahwa event tersebut sudah pernah dicatat dan tetap membalas 2xx.
Namun deduplikasi event saja belum cukup. Anda juga perlu memastikan logika bisnisnya idempotent.
Idempotensi pada level bisnis
Contoh masalah umum: event invoice.paid diproses dua kali lalu sistem menambah saldo dua kali, mengaktifkan subscription dua kali, atau mengirim email berulang. Walaupun event inbox sudah deduplicated, efek ganda masih bisa terjadi jika worker gagal di tengah proses lalu job dijalankan ulang.
Karena itu, handler bisnis harus memeriksa apakah perubahan yang akan dilakukan sudah pernah diterapkan. Strateginya bergantung pada domain:
- Simpan referensi stripe payment intent id, invoice id, atau subscription id pada entitas internal.
- Gunakan status transisi yang aman, misalnya hanya mengubah invoice lokal dari pending ke paid jika belum paid.
- Gunakan unique constraint tambahan pada tabel transaksi internal bila diperlukan.
Contoh job queue yang aman
<?php
namespace App\Jobs;
use App\Models\StripeWebhookEvent;
use App\Services\StripeWebhookService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessStripeWebhookEvent implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $webhookEventId)
{
}
public function handle(StripeWebhookService $service): void
{
$event = StripeWebhookEvent::findOrFail($this->webhookEventId);
if ($event->status === 'processed') {
return;
}
$event->update(['status' => 'processing']);
try {
$service->handle($event);
$event->update([
'status' => 'processed',
'processed_at' => now(),
'processing_error' => null,
]);
} catch (\Throwable $e) {
$event->update([
'status' => 'failed',
'processing_error' => $e->getMessage(),
]);
throw $e;
}
}
}
Poin penting dari job di atas:
- Job memeriksa status sebelum memproses.
- Status disimpan di database agar jejak proses terlihat.
- Jika job gagal dan di-retry queue worker, handler masih bisa dibuat idempotent.
Respons 2xx cepat sebelum proses berat
Jangan melakukan operasi berat di request webhook, misalnya memanggil banyak API internal, membuat laporan, sinkronisasi besar, atau memproses banyak query kompleks. Semakin lama request menggantung, semakin besar kemungkinan Stripe menganggap endpoint gagal dan melakukan retry.
Pola yang lebih aman adalah:
- Verifikasi signature.
- Simpan event.
- Dispatch job queue.
- Segera kembalikan 200 atau 204.
Trade-off dari pendekatan ini adalah keberhasilan respons 2xx hanya berarti event sudah diterima aplikasi Anda, bukan berarti seluruh logika bisnis selesai. Karena itu Anda perlu monitoring pada queue dan status event di database.
Kenapa queue penting
- Memperpendek waktu respons ke Stripe.
- Memisahkan concern antara penerimaan webhook dan pemrosesan bisnis.
- Memudahkan retry internal dengan kontrol yang lebih baik.
- Lebih aman terhadap lonjakan event, misalnya saat banyak invoice dibayarkan bersamaan.
Untuk backend produksi, queue berbasis Redis atau backend queue lain yang andal biasanya lebih cocok daripada driver sinkron.
Menangani event out-of-order
Kesalahan desain lain adalah mengasumsikan event Stripe selalu datang dalam urutan yang Anda harapkan. Dalam sistem terdistribusi, event bisa terlambat, datang bersamaan, atau terlihat tidak berurutan dari sudut pandang aplikasi Anda.
Contohnya, aplikasi menerima event pembaruan subscription sebelum data lokal untuk subscription tersebut dibuat lengkap, atau menerima event status terbaru sebelum event status sebelumnya sempat diproses.
Pendekatan yang lebih aman
- Jangan hanya bergantung pada urutan event. Gunakan ID objek Stripe sebagai sumber korelasi utama.
- Ambil keputusan berdasarkan status final yang diketahui, bukan asumsi urutan event sebelumnya.
- Jika data lokal belum siap, tandai event untuk retry internal atau lakukan fetch data terbaru dari API Stripe bila memang diperlukan.
- Gunakan transisi status yang defensif, misalnya jangan menurunkan status dari paid ke pending hanya karena event lama datang belakangan.
Contoh service handler ringkas
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\StripeWebhookEvent;
use Illuminate\Support\Arr;
use RuntimeException;
class StripeWebhookService
{
public function handle(StripeWebhookEvent $webhookEvent): void
{
$payload = $webhookEvent->payload;
$type = Arr::get($payload, 'type');
match ($type) {
'invoice.paid' => $this->handleInvoicePaid($payload),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($payload),
default => null,
};
}
protected function handleInvoicePaid(array $payload): void
{
$invoiceId = Arr::get($payload, 'data.object.id');
$orderId = Arr::get($payload, 'data.object.metadata.order_id');
$order = Order::find($orderId);
if (!$order) {
throw new RuntimeException('Order not found for invoice: '.$invoiceId);
}
if ($order->stripe_invoice_id === $invoiceId && $order->status === 'paid') {
return;
}
$order->forceFill([
'stripe_invoice_id' => $invoiceId,
'status' => 'paid',
'paid_at' => now(),
])->save();
}
protected function handleSubscriptionUpdated(array $payload): void
{
// Terapkan logika berdasarkan status terbaru yang aman,
// bukan asumsi bahwa event datang berurutan.
}
}
Di sini, handler invoice.paid tidak langsung menambah efek baru jika order sudah berstatus paid dengan invoice yang sama. Ini contoh idempotensi sederhana pada level bisnis.
Middleware, controller, atau service: pembagian tanggung jawab
Di Laravel, implementasi yang rapi biasanya memisahkan tanggung jawab sebagai berikut:
- Route: mendefinisikan endpoint webhook.
- Middleware opsional: jika ingin memusatkan verifikasi signature atau logging request mentah.
- Controller: menerima request, memverifikasi, menyimpan event, dan dispatch job.
- Service: menangani routing berdasarkan jenis event dan logika bisnis domain.
- Job Queue: menjalankan pemrosesan secara async.
- Model: merepresentasikan inbox event.
Apakah verifikasi signature harus di middleware? Bisa, tetapi ada trade-off:
- Middleware cocok jika Anda ingin konsisten memvalidasi semua endpoint webhook dengan pola yang sama.
- Controller lebih sederhana jika hanya ada satu endpoint Stripe dan Anda ingin alur penerimaan terlihat utuh di satu tempat.
Yang penting bukan lokasinya, melainkan urutannya: verifikasi harus terjadi sebelum payload dianggap tepercaya.
Logging dan observabilitas yang cukup
Logging yang minim membuat debugging webhook sangat sulit. Anda tidak perlu mencatat semua data sensitif, tetapi sebaiknya log mencakup informasi berikut:
- stripe_event_id
- type
- status pemrosesan
- waktu terima dan waktu proses
- pesan error ringkas
Hindari mencetak payload penuh ke log aplikasi tanpa pertimbangan, terutama jika payload mengandung data yang sensitif atau membuat log terlalu bising. Praktik yang umum adalah menyimpan payload di database inbox dan menulis log ringkas yang menunjuk ke record id internal.
Contoh logging yang berguna
Log::info('Stripe webhook received', [
'stripe_event_id' => $event['id'] ?? null,
'type' => $event['type'] ?? null,
]);
Log::error('Stripe webhook processing failed', [
'webhook_event_id' => $stored->id,
'stripe_event_id' => $stored->stripe_event_id,
'type' => $stored->type,
'error' => $e->getMessage(),
]);
Kesalahan umum yang sering terjadi
1. Memproses tanpa verifikasi signature
Ini membuka celah keamanan langsung. Endpoint webhook bukan endpoint publik biasa yang bisa dipercaya begitu saja.
2. Tidak idempotent
Jika retry dari Stripe atau retry dari queue worker menimbulkan efek ganda, berarti desain handler Anda belum aman. Deduplikasi event masuk saja tidak cukup.
3. Menjalankan proses berat sebelum membalas 2xx
Ini memicu timeout dan retry yang tidak perlu. Akibatnya, satu event bisa masuk berkali-kali saat sistem sedang lambat.
4. Logging terlalu minim
Tanpa jejak event ID, status, dan error, Anda sulit membedakan apakah masalah terjadi pada verifikasi, penyimpanan, enqueue, atau logika bisnis.
5. Mengasumsikan urutan event selalu konsisten
Dalam integrasi berbasis event, urutan tidak selalu bisa diandalkan. Bangun handler yang tahan terhadap keterlambatan dan ketidakteraturan.
6. Menggunakan payload yang sudah dimodifikasi untuk verifikasi
Verifikasi signature harus memakai raw request body. Jika payload sudah diubah lebih dulu, verifikasi dapat gagal walaupun request asli valid.
Skenario pengujian lokal
Sebelum dipakai di production, uji endpoint webhook secara lokal dengan skenario yang mendekati kondisi nyata.
Checklist pengujian
- Kirim event valid dan pastikan signature diverifikasi.
- Kirim event yang sama dua kali dan pastikan hanya satu record diproses sebagai event baru.
- Paksa handler gagal lalu pastikan job retry tidak menimbulkan efek ganda.
- Uji event yang referensi datanya belum ada di database lokal.
- Uji respons endpoint tetap cepat meskipun proses bisnis berat dijalankan di queue.
- Periksa log dan tabel inbox untuk memastikan jejak debugging cukup.
Contoh skenario praktis
- Signature invalid: ubah secret lokal lalu kirim ulang event, endpoint harus menolak request.
- Duplicate delivery: kirim payload event yang sama dua kali, record kedua tidak boleh membuat job bisnis baru.
- Worker retry: buat exception sementara di service, jalankan ulang worker, hasil akhir tetap satu kali perubahan bisnis.
- Out-of-order: kirim event update sebelum data lokal siap, pastikan sistem tidak menulis status yang salah atau memicu efek permanen yang keliru.
Jika Anda memakai alat CLI atau tunnel lokal untuk menerima webhook Stripe, pastikan signing secret yang dipakai cocok dengan endpoint yang sedang diuji. Secret yang salah adalah penyebab umum verifikasi gagal saat testing lokal.
Checklist implementasi webhook Stripe yang aman di Laravel
- Endpoint webhook menerima raw body dan header signature.
- Signature diverifikasi sebelum payload diproses.
- CSRF tidak menghalangi endpoint webhook.
- stripe_event_id disimpan dengan unique constraint.
- Payload event dicatat ke tabel inbox untuk audit dan debugging.
- Endpoint segera mengembalikan respons 2xx setelah event tersimpan.
- Pemrosesan berat dilakukan melalui queue.
- Handler bisnis dibuat idempotent, bukan hanya deduplicated pada level event.
- Status pemrosesan dan error tercatat jelas.
- Logika tahan terhadap event out-of-order.
- Retry dari Stripe dan retry dari queue tidak menimbulkan efek ganda.
Penutup
Membangun webhook Stripe di Laravel yang benar bukan hanya soal menerima request JSON. Fokus utamanya adalah memastikan setiap event benar-benar berasal dari Stripe, tidak diproses ganda saat retry, dan tetap aman saat urutan event tidak ideal. Pola paling praktis adalah verify, persist, acknowledge, process asynchronously.
Jika Anda menerapkan verifikasi signature, deduplikasi berbasis event ID, queue untuk pemrosesan async, dan handler idempotent yang tahan terhadap out-of-order events, endpoint webhook Anda akan jauh lebih stabil, aman, dan mudah di-debug saat masuk ke production.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!