Integrasi callback asinkron (webhook/event) antara layanan pihak ketiga dan aplikasi Laravel menuntut kontrak API yang jelas agar sistem tetap andal saat menerima event penting secara tidak sinkron. Dalam paragraf ini, masalah utama yang harus dijawab adalah bagaimana Laravel memverifikasi payload, mengelola retry otomatis, menangani clock skew, memperbarui token, dan menjaga observabilitas tanpa kehilangan keandalan.

Artikel ini membahas kontrak API callback tersebut, mulai dari struktur payload minimal sampai langkah-langkah teknis untuk membangun endpoint yang siap menangani retry, clock skew, token rotating, dan pemantauan.

Konteks Integrasi Callback Asinkron

Kebanyakan layanan webhook mengirim event ke endpoint Laravel setelah terjadi sesuatu di sistem mereka. Laravel hanya bertindak sebagai penerima: ia tidak selalu tahu berapa kali callback akan dipanggil, apakah ada delay, atau apakah request itu valid. Karena itu, kontrak API harus memastikan bahwa payload cukup untuk memutuskan apakah hendak diteruskan, di-retry, atau ditolak.

Misalnya, setiap callback harus mencakup metadata berikut:

  • event_id: UUID unik untuk idempotensi.
  • event_type: tipe event (misalnya order.completed).
  • version: schema version payload agar bisa evolve tanpa break.
  • timestamp: waktu pembuatan event dalam format ISO8601 UTC.
  • data: objek event yang hanya membawa field yang dibutuhkan.

Selain payload JSON, header juga berperan:

  • X-Webhook-Version: versi kontrak (misalnya 1.2).
  • X-Idempotency-Key: menegaskan identitas event untuk idempotensi 1x.
  • X-Service-Signature: signature HMAC untuk memverifikasi integritas.
  • Authorization atau token rotasi di header X-Service-Token jika otentikasi diperlukan.

Dengan struktur ini, Laravel cukup menerima, memvalidasi, lalu meneruskan atau menolaknya.

Merancang Kontrak API

Komponen terpenting dari kontrak adalah schema versi yang eksplisit. Di Laravel, Anda bisa menempatkan validasi dengan FormRequest atau Validator custom:

public function rules()
{
    return [
        'event_id' => 'required|uuid',
        'event_type' => 'required|string',
        'version' => 'required|string',
        'timestamp' => 'required|date_format:Y-m-d\TH:i:s\Z',
        'data' => 'required|array',
    ];
}

Schema versi memungkinkan Anda menambah field tanpa memecah klien lama. Pastikan header X-Webhook-Version digunakan untuk memutuskan handler mana yang dijalankan.

Contoh payload minimal:

{
  "event_id": "a3f5f5c2-7d5c-4f0b-9f1d-0c4f5947a123",
  "event_type": "order.completed",
  "version": "1.0",
  "timestamp": "2024-10-01T12:00:00Z",
  "data": {
    "order_id": 12345,
    "total": 125000
  }
}

Kirimkan response 202 Accepted sesegera mungkin setelah payload divalidasi. Gunakan respons seperti { "status": "accepted" } untuk memberi tahu pengirim bahwa Laravel menerima event.

Penanganan Retry dan Clock Skew

Layanan pihak ketiga biasanya menerapkan retry otomatis dengan exponential backoff. Laravel harus bisa membedakan callback pertama dan retry untuk menghindari pemrosesan ganda.

Simpan event_id dalam tabel webhook_events dengan status, timestamp, dan response. Jika event_id sudah ada dan status selesai, segera return 200/202 tanpa memproses ulang. Jika event belum selesai, pastikan X-Idempotency-Key sama untuk menandai percobaan yang sama.

Berikut contoh logika sederhana:

$eventId = $request->input('event_id');
$record = WebhookEvent::firstOrCreate(
    ['event_id' => $eventId],
    ['status' => 'processing']
);

if ($record->status === 'completed') {
    return response()->json(['status' => 'already_processed'], 202);
}

// lanjutkan pemrosesan bisnis
$record->status = 'completed';
$record->save();

Clock skew menjadi masalah saat timestamp request berbeda beberapa menit dari server Anda. Terapkan toleransi ±5 menit dan reject jika selisih lebih besar. Jangan gunakan Carbon::now() tanpa timezone.

Atur middleware berikut:

public function handle(Request $request, Closure $next)
{
    $timestamp = Carbon::parse($request->input('timestamp'));
    $now = Carbon::now('UTC');

    if ($now->diffInMinutes($timestamp) > 5) {
        return response()->json(['error' => 'timestamp_out_of_range'], 400);
    }

    return $next($request);
}

Retry backoff harus tercermin di sisi penerima: jangan memproses event di luar window toleransi atau sampai webhook selesai.

Autentikasi dan Token Rotating

Validasi signature memperkuat keamanan. Misalnya: signature di header adalah HMAC SHA256 terhadap payload, menggunakan shared secret yang bisa diputar secara periodik.

Contoh verifikasi di controller:

public function handleCallback(Request $request)
{
    $signature = $request->header('X-Service-Signature');
    $payload = $request->getContent();
    $validSecrets = config('webhook.secrets');

    foreach ($validSecrets as $secret) {
        $expected = hash_hmac('sha256', $payload, $secret);
        if (hash_equals($expected, $signature)) {
            return $this->process($request);
        }
    }

    return response()->json(['error' => 'invalid_signature'], 401);
}

Token rotation bisa diterapkan dengan menyimpan daftar secret aktif di konfigurasi cache/database. Saat token lama tidak valid lagi, tambahkan ke array validSecrets dan hapus setelah periode grace berakhir.

Gunakan middleware atau job terjadwal untuk memastikan secret secara berkala diperbarui dari vault/secret manager, sehingga tidak perlu deployed ulang.

Testing, Observabilitas, dan Debugging

Untuk menguji endpoint tanpa mengandalkan layanan third-party, gunakan Http::post() dan simulasi header:

$payload = json_encode([...]);
$signature = hash_hmac('sha256', $payload, config('webhook.secrets')[0]);

$this->postJson('/webhook/receive', $payload, [
    'X-Service-Signature' => $signature,
    'X-Webhook-Version' => '1.0',
    'X-Idempotency-Key' => 'test-key'
])->assertStatus(202);

Tambahkan tes untuk retry dengan memanggil endpoint kedua kalinya lalu periksa bahwa log status tidak berubah.

Observabilitas sangat penting. Gunakan logging terstruktur dengan konteks berikut: event_id, event_type, webhook_version, status. Kombinasikan dengan metrics (counter sukses/gagal, histogram latensi) di Prometheus atau Sentry untuk analisa error.

Debugging tip: tangkap payload lengkap sebelum parsing, log header signature, dan berikan response error jelas seperti { "error": "timestamp_out_of_range", "received_at": "..." }.

Panduan Transisi dari Protokol Lama

Jika Anda memindahkan sistem lama ke kontrak baru, jalankan mode dual endpoint:

  1. Buat route baru /webhook/v2 dengan kontrak versi terbaru.
  2. Redirect header X-Webhook-Version ke handler yang sesuai.
  3. Terapkan middleware untuk mencatat versi lama dan bantu tim pihak ketiga memperbarui client mereka.

Kirim respons 200 dengan body yang menyertakan field upgrade_to dan deadline agar client bisa melakukan upgrade. Setelah cukup waktu, matikan mode legacy secara bertahap.

Dengan pendekatan bertahap ini, Anda menjaga backward compatibility sambil memperkenalkan kontrak API yang lebih aman, dapat diobservasi, dan siap menghadapi retry serta clock skew.