Masalah utama pada integrasi webhook bukan sekadar mengirim HTTP request, tetapi memastikan event bisnis tetap terkirim walau aplikasi sempat crash, worker gagal, atau endpoint penerima lambat. Di Laravel, pola outbox adalah pendekatan yang praktis untuk mencegah kondisi ketika data sudah commit ke database, tetapi webhook tidak pernah terkirim.

Artikel ini membahas Laravel API: kontrak webhook outbox untuk cegah event hilang dari sisi desain dan implementasi. Fokusnya bukan hanya queue, tetapi juga kontrak payload, event_id yang stabil, versioning, signature HMAC, idempotency di sisi penerima, retry dengan backoff, timeout, dead-letter, observability, serta edge case yang sering terlewat.

Mengapa publish webhook langsung dari request lifecycle sering bermasalah

Pola yang sering ditemui adalah:

  1. Controller menerima request.
  2. Aplikasi menyimpan perubahan ke database.
  3. Aplikasi langsung memanggil webhook partner.

Pendekatan ini tampak sederhana, tetapi memiliki beberapa kegagalan yang sulit dideteksi:

  • Crash setelah commit: data sudah tersimpan, tetapi proses mati sebelum HTTP callback dikirim.
  • Rollback vs side effect: webhook sempat terkirim, tetapi transaksi database akhirnya gagal.
  • Endpoint lambat: request utama menjadi lambat atau timeout karena menunggu webhook.
  • Retry tidak terkelola: jika callback gagal, sering tidak ada mekanisme retry yang konsisten.
  • Observability minim: sulit menjawab pertanyaan seperti “event mana yang gagal terkirim?” atau “berapa kali sudah dicoba?”.

Pola outbox memisahkan commit state dari delivery side effect. Saat transaksi bisnis berhasil, aplikasi menulis catatan event ke tabel outbox dalam transaksi yang sama. Setelah itu, worker terpisah membaca outbox dan mengirim webhook secara andal.

Arsitektur pola outbox untuk webhook di Laravel

Alurnya sebagai berikut:

  1. Request API masuk.
  2. Laravel membuka transaksi database.
  3. Data bisnis ditulis, misalnya order dibuat atau status pembayaran berubah.
  4. Dalam transaksi yang sama, aplikasi menulis satu baris ke tabel webhook_outbox.
  5. Transaksi di-commit.
  6. Queue worker memproses record outbox yang siap dikirim.
  7. Worker mengirim webhook ke endpoint subscriber.
  8. Jika sukses, status outbox diubah menjadi sent.
  9. Jika gagal, sistem menjadwalkan retry dengan backoff atau memindahkan ke dead-letter setelah batas tertentu.

Kenapa ini bekerja? Karena state bisnis dan niat untuk mengirim event dicatat secara atomik. Jika transaksi sukses, event ada di outbox. Jika transaksi gagal, event juga tidak akan ada. Dengan begitu, Anda menghindari gap antara perubahan data dan side effect HTTP.

Tabel outbox yang disarankan

Struktur tabel tidak harus sama persis, tetapi umumnya memuat:

  • id
  • event_id: identifier publik yang stabil
  • aggregate_type: misalnya order, payment
  • aggregate_id: ID entitas terkait
  • event_type: misalnya order.created
  • payload: JSON payload yang akan dikirim
  • headers: opsional, jika ingin menyimpan header yang dikirim
  • status: pending, processing, sent, failed, dead_letter
  • attempt_count
  • available_at: kapan boleh dicoba lagi
  • last_error
  • sent_at
  • created_at, updated_at

Simpan payload final di outbox agar isi webhook konsisten saat retry. Jangan membangun ulang payload dari state terkini jika kontraknya mengharuskan snapshot pada saat event terjadi.

Kontrak API webhook: bagian yang wajib jelas

Outbox saja tidak cukup. Jika kontrak webhook longgar, penerima tetap akan kesulitan memproses event secara aman. Desain kontrak berikut membantu kedua sisi: pengirim dan penerima.

1. event_id harus stabil dan unik

event_id adalah kunci untuk deduplikasi dan replay tracking. Nilainya harus tetap sama untuk event yang sama, termasuk saat retry. Jangan membuat ID baru setiap kali pengiriman ulang.

Gunakan identifier yang aman untuk dibagikan ke pihak luar, misalnya UUID atau ULID. Hindari memakai ID auto-increment internal sebagai satu-satunya identitas publik jika itu membuka detail internal sistem.

2. event_type dan versioning

Penerima perlu tahu jenis event dan versi payload. Ada beberapa pendekatan yang umum:

  • Versi di field payload, misalnya version: 1
  • Versi di header, misalnya X-Webhook-Version: 1
  • Versi di nama event, misalnya order.created.v1

Yang penting adalah konsisten. Untuk integrasi jangka panjang, pemisahan event_type dan version biasanya lebih mudah dikelola daripada menaruh versi di nama event.

3. Timestamp yang jelas

Sertakan waktu terjadinya event, bukan hanya waktu pengiriman. Gunakan format yang mudah diparse seperti ISO 8601 UTC. Ini penting untuk audit, debugging, dan validasi replay window.

4. Signature atau HMAC

Webhook sebaiknya tidak hanya mengandalkan IP allowlist atau HTTPS. Tambahkan signature berbasis HMAC yang dihitung dari raw request body dengan secret per subscriber. Penerima memverifikasi signature sebelum memproses payload.

Praktik umum:

  • Gunakan secret unik per endpoint subscriber.
  • Sertakan timestamp di header signature untuk membatasi replay.
  • Bandingkan signature dengan fungsi yang tahan timing attack.
  • Hitung HMAC dari raw body, bukan hasil JSON yang diserialisasi ulang.

5. Idempotency di sisi penerima

Pengirim webhook harus mengasumsikan bahwa event bisa terkirim lebih dari sekali. Karena itu, penerima wajib memproses secara idempoten. Cara paling sederhana adalah menyimpan event_id yang sudah diproses dalam tabel atau cache dengan masa simpan yang sesuai.

Jika penerima menerima event dengan event_id yang sama, respons dapat tetap 2xx tanpa mengeksekusi efek bisnis kedua kalinya.

Contoh payload webhook

{
  "event_id": "01HX7YV8Q1G8R6Q6M4V3Z9M2AA",
  "event_type": "payment.succeeded",
  "version": 1,
  "occurred_at": "2026-06-11T08:15:30Z",
  "delivery_attempt": 3,
  "data": {
    "payment_id": "pay_12345",
    "order_id": "ord_98765",
    "amount": 250000,
    "currency": "IDR",
    "status": "succeeded"
  }
}

Header yang lazim dikirim:

  • Content-Type: application/json
  • X-Webhook-Event: payment.succeeded
  • X-Webhook-Event-Id: 01HX7YV8Q1G8R6Q6M4V3Z9M2AA
  • X-Webhook-Timestamp: 2026-06-11T08:15:30Z
  • X-Webhook-Signature: ...

Implementasi Laravel: transaction, outbox, dan queue worker

Migrasi tabel outbox

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('webhook_outbox', function (Blueprint $table) {
            $table->id();
            $table->uuid('event_id')->unique();
            $table->string('aggregate_type');
            $table->string('aggregate_id');
            $table->string('event_type');
            $table->json('payload');
            $table->string('status')->default('pending');
            $table->unsignedInteger('attempt_count')->default(0);
            $table->timestamp('available_at')->nullable()->index();
            $table->timestamp('sent_at')->nullable();
            $table->text('last_error')->nullable();
            $table->timestamps();

            $table->index(['status', 'available_at']);
            $table->index(['aggregate_type', 'aggregate_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('webhook_outbox');
    }
};

Jika Anda mengirim ke banyak subscriber, pertimbangkan menyimpan tabel subscription terpisah dan membuat satu record outbox per target delivery. Dengan begitu status pengiriman per endpoint bisa dipantau secara independen.

Menulis data bisnis dan outbox dalam satu transaksi

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\Order;
use App\Models\WebhookOutbox;

DB::transaction(function () use ($input) {
    $order = Order::create([
        'customer_id' => $input['customer_id'],
        'total' => $input['total'],
        'status' => 'paid',
    ]);

    WebhookOutbox::create([
        'event_id' => (string) Str::uuid(),
        'aggregate_type' => 'order',
        'aggregate_id' => (string) $order->id,
        'event_type' => 'order.paid',
        'payload' => [
            'event_id' => null,
            'event_type' => 'order.paid',
            'version' => 1,
            'occurred_at' => now()->toISOString(),
            'data' => [
                'order_id' => (string) $order->id,
                'customer_id' => (string) $order->customer_id,
                'total' => $order->total,
                'status' => $order->status,
            ],
        ],
        'status' => 'pending',
        'available_at' => now(),
    ]);
});

Pada contoh di atas, Anda sebaiknya mengisi event_id satu kali lalu menyimpannya juga ke dalam payload agar nilai yang dikirim stabil. Bisa dilakukan dengan menyiapkan variabel sebelum create().

Model sederhana untuk outbox

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class WebhookOutbox extends Model
{
    protected $table = 'webhook_outbox';

    protected $fillable = [
        'event_id',
        'aggregate_type',
        'aggregate_id',
        'event_type',
        'payload',
        'status',
        'attempt_count',
        'available_at',
        'sent_at',
        'last_error',
    ];

    protected $casts = [
        'payload' => 'array',
        'available_at' => 'datetime',
        'sent_at' => 'datetime',
    ];
}

Worker untuk mengirim webhook

Pengiriman bisa dijalankan melalui job queue terjadwal, command, atau loop worker. Prinsipnya sama: ambil record pending yang sudah jatuh tempo, tandai processing secara aman, kirim HTTP request, lalu update hasilnya.

namespace App\Jobs;

use App\Models\WebhookOutbox;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;

class DeliverWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $outboxId)
    {
    }

    public function handle(): void
    {
        $outbox = WebhookOutbox::find($this->outboxId);

        if (! $outbox || in_array($outbox->status, ['sent', 'dead_letter'], true)) {
            return;
        }

        $subscriberUrl = config('services.webhook_subscriber.url');
        $secret = config('services.webhook_subscriber.secret');

        $payload = $outbox->payload;
        $payload['event_id'] = $outbox->event_id;
        $payload['delivery_attempt'] = $outbox->attempt_count + 1;

        $body = json_encode($payload, JSON_UNESCAPED_SLASHES);
        $timestamp = now()->toISOString();
        $signature = hash_hmac('sha256', $timestamp . '.' . $body, $secret);

        try {
            $outbox->update([
                'status' => 'processing',
                'attempt_count' => $outbox->attempt_count + 1,
            ]);

            $response = Http::timeout(5)
                ->connectTimeout(2)
                ->withBody($body, 'application/json')
                ->withHeaders([
                    'X-Webhook-Event' => $outbox->event_type,
                    'X-Webhook-Event-Id' => $outbox->event_id,
                    'X-Webhook-Timestamp' => $timestamp,
                    'X-Webhook-Signature' => $signature,
                ])
                ->post($subscriberUrl);

            if ($response->successful()) {
                $outbox->update([
                    'status' => 'sent',
                    'sent_at' => now(),
                    'last_error' => null,
                ]);

                return;
            }

            $this->reschedule($outbox, 'HTTP ' . $response->status());
        } catch (\Throwable $e) {
            $this->reschedule($outbox, $e->getMessage());
        }
    }

    protected function reschedule(WebhookOutbox $outbox, string $error): void
    {
        $attempt = $outbox->attempt_count;
        $delaySeconds = min(300, 2 ** max(1, $attempt));

        $status = $attempt >= 10 ? 'dead_letter' : 'pending';

        $outbox->update([
            'status' => $status,
            'available_at' => now()->addSeconds($delaySeconds),
            'last_error' => mb_substr($error, 0, 2000),
        ]);
    }
}

Catatan penting:

  • Timeout harus ketat. Endpoint partner yang lambat tidak boleh menahan worker terlalu lama.
  • Retry harus eksplisit. Jangan mengandalkan retry default queue saja tanpa status outbox yang jelas.
  • Backoff perlu dibatasi. Jika tidak, sistem bisa menumpuk event terlalu lama.
  • Dead-letter queue atau status dead_letter penting agar event gagal permanen bisa diinspeksi dan direplay manual.

Mendispatch job setelah commit

Setelah record outbox dibuat dan transaksi sukses, Anda bisa menjadwalkan job untuk memprosesnya. Hindari men-dispatch proses pengiriman sebelum transaksi benar-benar commit.

DB::transaction(function () use (&$outboxId, $input) {
    // tulis data bisnis
    // tulis outbox
    $outboxId = $outbox->id;
});

DeliverWebhook::dispatch($outboxId);

Untuk beban besar, alternatifnya adalah scheduler atau command periodik yang memindai outbox pending dan me-dispatch batch job. Pendekatan ini lebih tahan terhadap crash tepat setelah commit karena dispatcher periodik tetap akan menemukan baris outbox yang belum diproses.

Verifikasi signature di sisi penerima

Penerima webhook harus memverifikasi signature sebelum memproses payload. Kuncinya adalah membaca raw body apa adanya.

use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

Route::post('/webhooks/provider', function (Request $request) {
    $secret = config('services.webhook_provider.secret');

    $rawBody = $request->getContent();
    $timestamp = $request->header('X-Webhook-Timestamp');
    $received = $request->header('X-Webhook-Signature');

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    if (! hash_equals($expected, (string) $received)) {
        return response()->json(['message' => 'Invalid signature'], Response::HTTP_UNAUTHORIZED);
    }

    $payload = $request->json()->all();
    $eventId = $payload['event_id'] ?? null;

    if (! $eventId) {
        return response()->json(['message' => 'Missing event_id'], Response::HTTP_BAD_REQUEST);
    }

    $alreadyProcessed = \DB::table('processed_webhooks')
        ->where('event_id', $eventId)
        ->exists();

    if ($alreadyProcessed) {
        return response()->json(['message' => 'Already processed'], Response::HTTP_OK);
    }

    \DB::transaction(function () use ($eventId, $payload) {
        \DB::table('processed_webhooks')->insert([
            'event_id' => $eventId,
            'created_at' => now(),
        ]);

        // proses efek bisnis di sini secara atomik
    });

    return response()->json(['message' => 'Accepted'], Response::HTTP_OK);
});

Jika ingin menahan replay lama, validasi juga selisih waktu dari header timestamp. Tetapi jangan terlalu agresif jika Anda memang mendukung replay resmi untuk debugging atau re-delivery.

Edge case yang wajib diantisipasi

Crash setelah commit

Ini alasan utama pola outbox dipakai. Bila aplikasi crash setelah transaksi sukses tetapi sebelum HTTP callback dilakukan, event tetap aman karena sudah tersimpan di outbox. Worker atau scheduler berikutnya akan mengirim ulang.

Duplikasi event

Duplikasi bisa terjadi karena timeout jaringan, retry otomatis, worker restart, atau kondisi ketika pengirim tidak tahu apakah penerima sempat memproses request. Karena itu:

  • Pengirim harus mempertahankan event_id yang sama pada retry.
  • Penerima harus idempoten dan menyimpan jejak event yang sudah diproses.

Urutan event tidak terjamin

Dalam sistem nyata, event order.updated bisa tiba sebelum order.created, terutama jika ada retry dan pemrosesan paralel. Jika urutan penting, beberapa opsi adalah:

  • Sertakan occurred_at dan versi state.
  • Sertakan snapshot yang cukup agar penerima tidak bergantung pada event sebelumnya.
  • Kelompokkan pemrosesan per aggregate bila benar-benar butuh urutan ketat, dengan konsekuensi throughput lebih rendah.

Secara umum, lebih aman mendesain penerima agar toleran terhadap event datang tidak berurutan.

Replay

Replay diperlukan untuk pemulihan atau pengujian. Tetapi replay juga bisa menjadi vektor serangan jika signature bisa dipakai ulang tanpa batas. Mitigasinya:

  • Sertakan timestamp dalam signature.
  • Terapkan jendela waktu validasi bila sesuai.
  • Catat siapa dan kapan replay dilakukan di sisi pengirim.
  • Pastikan replay tetap memakai event_id yang sama jika tujuannya adalah pengiriman ulang event yang sama.

Endpoint lambat atau sering timeout

Jangan menunggu terlalu lama. Timeout koneksi dan timeout respons perlu dipisahkan. Jika endpoint partner sering lambat:

  • Gunakan timeout pendek.
  • Naikkan retry dengan backoff.
  • Pantau rasio timeout per subscriber.
  • Pertimbangkan circuit breaker atau rate limit internal jika satu partner mengganggu throughput worker.

Observability: tanpa ini, webhook sulit dioperasikan

Sistem webhook yang andal harus bisa diamati, bukan hanya “berjalan”. Minimal, catat dan ukur:

  • Jumlah event dibuat per jenis event
  • Jumlah sukses, gagal, retry, dead-letter
  • Latency dari occurred_at sampai sent_at
  • HTTP status code dari endpoint partner
  • Distribusi attempt count
  • Ukuran backlog outbox

Log yang berguna biasanya menyertakan:

  • event_id
  • event_type
  • aggregate_id
  • subscriber atau destination
  • attempt_count
  • status akhir dan pesan error singkat

Jika Anda tidak bisa mencari event berdasarkan event_id dari log sampai database outbox, debugging insiden webhook akan jauh lebih lambat.

Checklist desain kontrak webhook outbox

  • Event dibuat di tabel outbox dalam transaksi yang sama dengan perubahan data bisnis.
  • event_id unik, stabil, dan tidak berubah saat retry.
  • Payload memiliki event_type, version, dan occurred_at.
  • Payload final disimpan di outbox, bukan dibangun ulang dari state terbaru saat retry.
  • Pengiriman memakai queue worker atau scheduler terpisah dari request utama.
  • Timeout koneksi dan respons diatur ketat.
  • Retry memakai backoff dan ada batas maksimum.
  • Event gagal permanen masuk ke dead-letter.
  • Signature HMAC dihitung dari raw body dan timestamp.
  • Penerima menerapkan idempotency berdasarkan event_id.
  • Sistem mengasumsikan event bisa duplikat dan urutannya tidak terjamin.
  • Ada log, metrik, dan dashboard untuk backlog, error rate, dan latency.
  • Ada prosedur replay manual yang aman dan terdokumentasi.

Trade-off dibanding publish langsung dari request lifecycle

Kelebihan outbox

  • Lebih andal: event tidak hilang saat crash setelah commit.
  • Lebih cepat untuk user: request utama tidak menunggu endpoint partner.
  • Lebih mudah dioperasikan: retry, dead-letter, dan observability bisa dirancang jelas.
  • Lebih aman untuk integrasi jangka panjang: kontrak event lebih eksplisit.

Kekurangan outbox

  • Kompleksitas bertambah: ada tabel tambahan, worker, retry policy, dan monitoring.
  • Eventual consistency: webhook tidak selalu terkirim seketika setelah API merespons sukses.
  • Perlu disiplin kontrak: tim harus menjaga versi payload dan kompatibilitas mundur.

Jika kebutuhan Anda sangat sederhana, volume kecil, dan kehilangan event masih bisa ditoleransi secara operasional, publish langsung mungkin terasa cukup. Tetapi untuk pembayaran, order, settlement, sinkronisasi partner, atau audit trail, pola outbox biasanya lebih tepat.

Kesimpulan

Laravel API: kontrak webhook outbox untuk cegah event hilang bukan hanya soal memindahkan pengiriman webhook ke queue. Intinya adalah memastikan perubahan data bisnis dan pencatatan event terjadi secara atomik, lalu delivery dikelola oleh proses terpisah yang mendukung retry, timeout, dead-letter, dan observability.

Kontrak webhook yang baik harus menganggap jaringan tidak andal: event bisa terlambat, duplikat, tidak berurutan, atau perlu direplay. Karena itu, event_id stabil, versioning, timestamp, HMAC, dan idempotency penerima bukan fitur tambahan, melainkan bagian inti desain. Dengan pola ini, integrasi webhook Laravel menjadi jauh lebih aman untuk dijalankan di sistem produksi.