Webhook aman bukan sekadar endpoint yang menerima HTTP POST. Di sistem nyata, provider bisa mengirim event yang sama lebih dari sekali, mengirim event dalam urutan yang tidak konsisten, atau mengulang pengiriman beberapa menit hingga beberapa jam kemudian. Jika integrasi Anda mengasumsikan satu event selalu datang sekali, tepat waktu, dan berurutan, bug-nya biasanya baru terlihat saat volume naik atau saat provider mengalami gangguan.

Karena itu, desain webhook yang baik perlu memperjelas kontrak integrasi: bentuk payload, header signature, aturan retry, arti status code, masa berlaku timestamp, dan semantik event ID. Konteks seperti kebijakan NLNet Labs terhadap LLM mengingatkan satu hal penting: batas penggunaan dan ekspektasi integrasi harus eksplisit. Di level engineering, artinya jangan mengandalkan asumsi implisit. Tulis kontraknya, validasi input, dan buat konsumer webhook yang tahan terhadap perubahan serta kegagalan parsial.

Mengapa webhook sering rapuh di produksi

Ada beberapa pola kegagalan yang berulang:

  • Duplikasi event: provider me-retry karena timeout atau respons non-2xx, padahal proses Anda sebenarnya sudah berhasil.
  • Out-of-order delivery: event updated tiba lebih dulu daripada event created.
  • Delayed delivery: event baru tiba jauh setelah state di sistem Anda berubah.
  • Kontrak tidak eksplisit: provider menambah field, mengubah nilai enum, atau memperkenalkan versi payload baru tanpa konsumen siap.
  • Verifikasi signature keliru: body diubah middleware sebelum diverifikasi, sehingga semua request valid dianggap tidak sah.

Solusi utamanya bukan satu fitur tunggal, melainkan kombinasi beberapa lapisan: verifikasi autentisitas, idempotensi, deduplikasi, penyimpanan status event, retry yang aman, serta observability yang cukup untuk mendiagnosis masalah.

Kontrak webhook yang tidak rapuh

1. Payload harus eksplisit dan stabil

Payload sebaiknya memiliki elemen minimum berikut:

  • event_id: identitas unik untuk deduplikasi dan audit.
  • event_type: misalnya invoice.created atau payment.failed.
  • occurred_at: kapan event terjadi di sisi provider, bukan kapan dikirim.
  • delivery_attempt bila tersedia: berguna untuk observability.
  • resource id: identitas objek domain yang terdampak.
  • schema_version: versi kontrak payload.
  • data: isi event yang spesifik.

Contoh payload yang cukup aman:

{
  "event_id": "evt_01JABCXYZ",
  "event_type": "subscription.renewed",
  "occurred_at": "2026-06-27T10:15:30Z",
  "schema_version": "2026-06",
  "resource": {
    "type": "subscription",
    "id": "sub_12345"
  },
  "data": {
    "customer_id": "cus_987",
    "status": "active",
    "period_end": "2026-07-27T00:00:00Z"
  }
}

Kenapa ini bekerja? Karena konsumer bisa memutuskan banyak hal tanpa menebak: event apa, objek mana, kapan terjadi, dan versi skema mana yang dipakai.

2. Bedakan field wajib dan opsional

Jangan membuat konsumer gagal hanya karena provider menambahkan field baru. Gunakan prinsip berikut:

  • Field wajib harus divalidasi ketat.
  • Field opsional boleh diabaikan jika belum didukung.
  • Field baru harus dianggap kompatibel selama field lama tetap ada dan semantiknya tidak berubah.

Kesalahan umum adalah parser yang terlalu kaku terhadap field tambahan. Untuk integrasi jangka panjang, lebih aman memakai validasi yang toleran terhadap penambahan field, tetapi ketat pada field inti.

3. Header signature dan metadata pengiriman

Selain payload, definisikan header penting secara eksplisit, misalnya:

  • X-Webhook-Signature: hasil HMAC atau mekanisme signature lain.
  • X-Webhook-Timestamp: timestamp untuk replay protection.
  • X-Webhook-Id: bila event ID dipisah dari payload.

Nama header bisa berbeda tergantung provider, tetapi prinsipnya sama: jangan menyembunyikan metadata pengiriman di tempat yang sulit diverifikasi.

Verifikasi signature, timestamp, dan replay protection

1. Verifikasi harus memakai raw body

Jika provider menandatangani body mentah, Anda harus memverifikasi raw request body, bukan hasil parsing JSON. Middleware yang mengubah whitespace, urutan key, atau encoding dapat membuat signature mismatch.

Contoh pseudocode verifikasi HMAC:

raw_body = read_raw_request_body()
timestamp = header["X-Webhook-Timestamp"]
signature = header["X-Webhook-Signature"]

if abs(now_utc - timestamp) > allowed_skew:
    reject 401

signed_payload = timestamp + "." + raw_body
expected = hmac_sha256(secret, signed_payload)

if !constant_time_compare(expected, signature):
    reject 401

Mengapa timestamp digabungkan ke payload yang ditandatangani? Untuk mencegah attacker memutar ulang body lama dengan header timestamp baru.

2. Replay protection bukan hanya signature

Request yang signature-nya valid tetap bisa merupakan replay jika disalin dari traffic lama. Karena itu, gabungkan beberapa kontrol:

  • Batas skew waktu, misalnya hanya menerima request dalam jendela tertentu.
  • Penyimpanan nonce atau event ID untuk request yang sudah pernah diterima.
  • Audit log agar replay yang lolos bisa ditelusuri.

Jika provider tidak mengirim timestamp, Anda masih bisa mengandalkan event ID untuk deduplikasi, tetapi replay protection menjadi lebih lemah.

Idempotensi dan deduplikasi: inti dari webhook aman

1. Gunakan event ID atau idempotency key

Untuk webhook masuk, istilah yang paling umum adalah event ID. Setiap event harus punya identitas unik dan stabil. Konsumer menyimpan event ID yang sudah diproses agar event yang sama tidak dieksekusi dua kali.

Pola yang lazim:

  1. Terima request.
  2. Verifikasi signature dan timestamp.
  3. Cek apakah event_id sudah pernah diproses.
  4. Jika sudah, kembalikan 2xx tanpa memproses ulang.
  5. Jika belum, simpan status penerimaan lalu proses.

2. Simpan status event, bukan hanya boolean

Menyimpan processed=true sering tidak cukup. Simpan status yang lebih kaya:

  • received
  • processing
  • processed
  • failed
  • ignored

Ini membantu mengatasi crash di tengah proses. Misalnya, event sudah lolos verifikasi dan masuk database, tetapi worker mati sebelum side effect selesai. Dengan status yang eksplisit, Anda bisa membedakan event yang aman untuk diulang dari event yang memang sudah selesai.

3. Atomic insert untuk mencegah race condition

Di sistem paralel, dua request duplikat bisa masuk hampir bersamaan. Jika cek dan insert tidak atomik, keduanya bisa lolos. Solusi umum:

  • Unique constraint pada kolom event_id.
  • Upsert atau insert if not exists.
  • Transaksi database untuk menggabungkan penyimpanan event dan perubahan state bila perlu.

Contoh skema tabel sederhana:

CREATE TABLE webhook_events (
  event_id VARCHAR(128) PRIMARY KEY,
  event_type VARCHAR(128) NOT NULL,
  resource_id VARCHAR(128),
  occurred_at TIMESTAMP NULL,
  status VARCHAR(32) NOT NULL,
  received_at TIMESTAMP NOT NULL,
  processed_at TIMESTAMP NULL,
  payload JSON NOT NULL,
  error_message TEXT NULL
);

Alur amannya biasanya seperti ini:

BEGIN;
INSERT INTO webhook_events(event_id, status, ...)
VALUES(:event_id, 'received', ...)
ON CONFLICT (event_id) DO NOTHING;

if row_not_inserted:
  COMMIT;
  return 200  // duplikat, aman diabaikan

COMMIT;
enqueue_background_job(event_id);
return 202 or 200

Trade-off: menyimpan semua payload menambah kebutuhan storage, tetapi sangat membantu untuk audit dan reprocessing.

4. Idempotensi harus mencakup side effect

Banyak implementasi berhenti di level event, padahal side effect-nya belum idempotent. Contoh: event sama memicu pengiriman email dua kali, pembuatan invoice ganda, atau penambahan saldo berulang.

Karena itu, selain deduplikasi event, pastikan operasi domain juga punya kunci unik yang relevan, misalnya:

  • Unique constraint pada transaksi eksternal.
  • Status transisi yang divalidasi, misalnya hanya boleh pindah dari pending ke paid sekali.
  • Pencatatan referensi event sumber pada record bisnis.

Retry, backoff, dan status code yang benar

1. Jangan proses berat di request thread

Untuk webhook, respons cepat lebih penting daripada menyelesaikan semua pekerjaan sinkron. Pola yang umum dan aman:

  1. Verifikasi request.
  2. Simpan event.
  3. Masukkan ke queue.
  4. Balas 2xx secepat mungkin.

Ini mengurangi timeout dan retry yang tidak perlu dari provider.

2. Kapan mengembalikan 2xx, 4xx, atau 5xx

Aturan praktis:

  • 2xx: request valid dan sudah diterima, termasuk jika event duplikat dan sengaja diabaikan.
  • 4xx: request tidak valid dan retry tidak akan membantu, misalnya signature salah atau payload wajib hilang.
  • 5xx: kegagalan sementara di sisi Anda, misalnya database atau queue sedang bermasalah.

Contoh keputusan:

  • Signature tidak cocok → 401 atau 403.
  • JSON rusak atau field inti hilang → 400.
  • Database timeout saat menyimpan event → 500.
  • Event sudah pernah diproses → 200.

Kesalahan umum: mengembalikan 200 padahal event belum tercatat sama sekali. Jika penyimpanan gagal tetapi Anda membalas sukses, provider tidak akan retry dan event hilang permanen.

3. Retry dengan exponential backoff dan jitter

Jika Anda juga bertindak sebagai provider webhook, kirim ulang event gagal dengan exponential backoff dan tambahkan jitter agar tidak memicu lonjakan serentak. Prinsipnya:

  • Percobaan awal cepat untuk error sementara singkat.
  • Jeda makin panjang setiap kegagalan berikutnya.
  • Batasi jumlah retry dan total retention window.

Jitter penting agar ribuan delivery yang gagal tidak semuanya mengulang tepat di detik yang sama.

Jika Anda adalah konsumer, pahami bahwa provider mungkin mengirim ulang dalam pola seperti ini. Maka endpoint Anda harus tetap aman menerima duplikat kapan pun selama jendela retry provider masih aktif.

Menangani event duplikat, out-of-order, dan terlambat

1. Event duplikat

Kasus paling umum. Solusinya adalah deduplikasi berdasarkan event_id dan side effect yang idempotent. Duplikat tidak selalu identik byte-per-byte; yang penting identitas event-nya sama dan semantiknya mewakili kejadian yang sama.

2. Out-of-order delivery

Jangan berasumsi urutan kirim sama dengan urutan kejadian. Gunakan occurred_at atau versi resource jika tersedia. Beberapa strategi:

  • Fetch latest state dari provider saat menerima event yang sensitif terhadap urutan.
  • Simpan versi terakhir dan abaikan event yang lebih lama dari state terkini.
  • Gunakan state machine agar transisi ilegal dapat ditolak.

Contoh: Anda menerima subscription.canceled lalu beberapa detik kemudian subscription.renewed yang sebenarnya terjadi lebih dulu. Jika hanya memproses berdasarkan urutan datang, state akhir bisa salah. Dengan membandingkan occurred_at atau versi objek, Anda bisa memutuskan event mana yang lebih baru secara domain.

3. Delayed event

Event terlambat tetap bisa valid. Tantangannya adalah apakah event itu masih relevan. Misalnya event payment.failed baru tiba setelah pelanggan sudah berhasil membayar lewat percobaan berikutnya.

Strategi aman:

  • Selalu cek state saat ini sebelum menerapkan side effect.
  • Bedakan event historis dari perintah mutasi. Webhook umumnya lebih tepat diperlakukan sebagai sinyal untuk sinkronisasi, bukan sumber kebenaran tunggal.
  • Simpan event tetap untuk audit, walau aksi bisnisnya diabaikan.

4. Event referensial yang belum siap

Contoh nyata: Anda menerima invoice.paid, tetapi record pelanggan lokal belum ada karena event customer.created datang terlambat. Opsi penanganan:

  • Retry internal di worker beberapa kali.
  • Lakukan fetch ke API provider untuk melengkapi data yang hilang.
  • Masukkan ke dead-letter queue jika dependency tak kunjung tersedia.

Ini lebih aman daripada langsung menganggap event invalid.

Versioning schema dan kompatibilitas

1. Versi kontrak harus terlihat

Masukkan versi schema ke payload atau header. Jangan mengandalkan dokumentasi saja. Dengan versi eksplisit, konsumer bisa:

  • Memilih parser yang tepat.
  • Mengaktifkan migrasi bertahap.
  • Mencatat distribusi traffic per versi.

2. Bedakan perubahan kompatibel dan breaking

Perubahan yang biasanya kompatibel:

  • Menambah field baru opsional.
  • Menambah event type baru yang bisa diabaikan konsumer lama.

Perubahan yang berpotensi breaking:

  • Menghapus field lama yang dipakai konsumer.
  • Mengubah tipe data field.
  • Mengubah semantik enum atau status tanpa versi baru.

Jika Anda provider, lebih baik menambah field baru daripada mengubah arti field lama. Jika Anda konsumer, buat parser yang gagal jelas pada perubahan breaking, bukan diam-diam salah memproses.

3. Kontrak eksplisit mengurangi integrasi rapuh

Pelajaran umum dari konteks seperti kebijakan penggunaan yang eksplisit adalah: sistem bekerja lebih baik saat asumsi tidak dibiarkan implisit. Dalam webhook, itu berarti dokumentasikan dengan jelas:

  • Skema payload dan field wajib.
  • Algoritme signature.
  • Aturan retry dan timeout.
  • Jendela timestamp yang diterima.
  • Daftar status code yang diharapkan.
  • Retensi event dan cara re-delivery manual.

Observability dan debugging

1. Apa yang perlu dicatat

Minimal, log dan metric berikut sebaiknya ada:

  • event_id, event_type, dan resource.id
  • hasil verifikasi signature
  • status pemrosesan: received, processed, failed, duplicate
  • latensi endpoint dan latensi worker
  • jumlah retry internal
  • persentase 2xx, 4xx, 5xx

Hindari mencatat secret, signature mentah yang sensitif, atau payload penuh yang mengandung data pribadi tanpa kontrol akses.

2. Correlation ID dan jejak antar layanan

Jika webhook memicu pipeline ke queue, worker, database, dan API internal, pakai correlation ID yang konsisten. Sering kali event_id bisa dijadikan root correlation ID. Ini memudahkan menjawab pertanyaan seperti: event diterima kapan, diproses worker mana, gagal di langkah apa, dan apakah side effect sudah sempat terjadi.

3. Dead-letter queue dan replay internal

Event yang gagal diproses setelah beberapa percobaan internal sebaiknya dipindahkan ke dead-letter queue atau status gagal yang bisa diperiksa ulang. Simpan payload asli agar tim bisa melakukan replay setelah bug diperbaiki.

Replay internal aman hanya jika proses Anda idempotent. Tanpa itu, alat replay bisa menjadi sumber duplikasi baru.

Pola implementasi backend yang praktis

Berikut alur backend yang umum dipakai dan cukup aman:

  1. Terima request di endpoint khusus webhook.
  2. Baca raw body.
  3. Verifikasi signature dengan perbandingan waktu konstan.
  4. Validasi timestamp terhadap jendela skew.
  5. Parse JSON dan validasi field wajib.
  6. Atomic insert event berdasarkan event_id.
  7. Jika duplikat, balas 200.
  8. Jika baru, enqueue job asinkron.
  9. Balas 202 atau 200 segera.
  10. Worker memproses event dengan state machine dan guard idempotensi pada side effect.
  11. Perbarui status event menjadi processed atau failed.

Contoh pseudocode endpoint:

function handleWebhook(request):
    raw = request.rawBody
    ts = request.header("X-Webhook-Timestamp")
    sig = request.header("X-Webhook-Signature")

    if !verifySignature(raw, ts, sig, secret):
        return 401

    payload = parseJson(raw)
    if !payload.event_id or !payload.event_type:
        return 400

    inserted = insertEventIfAbsent(
        event_id=payload.event_id,
        event_type=payload.event_type,
        occurred_at=payload.occurred_at,
        payload=raw,
        status="received"
    )

    if !inserted:
        return 200

    if !enqueue(payload.event_id):
        markEventFailed(payload.event_id, "enqueue failed")
        return 500

    return 202

Contoh pseudocode worker:

function processWebhookEvent(event_id):
    event = loadEvent(event_id)
    if event.status == "processed":
        return

    markProcessing(event_id)

    switch event.event_type:
        case "payment.succeeded":
            if paymentAlreadyApplied(event.resource_id):
                markProcessed(event_id)
                return
            applyPayment(event)
            markProcessed(event_id)
            return

        case "subscription.canceled":
            current = loadSubscription(event.resource_id)
            if current and current.updated_at > event.occurred_at:
                markIgnored(event_id)
                return
            cancelSubscription(current, event)
            markProcessed(event_id)
            return

        default:
            markIgnored(event_id)
            return

Edge case nyata yang sering terlewat

1. Timeout setelah side effect berhasil

Anda memproses event, mengirim email, lalu endpoint timeout sebelum mengembalikan 2xx. Provider retry, email terkirim lagi. Ini terjadi jika side effect dilakukan sinkron sebelum deduplikasi yang kuat. Solusinya: simpan event dulu, balas cepat, proses asinkron, dan buat email idempotent.

2. Signature gagal karena body sudah diparse

Framework mem-parsing JSON otomatis lalu Anda menghitung HMAC dari hasil serialisasi ulang. Signature selalu mismatch. Solusinya: ambil raw body sebelum middleware yang memodifikasi request.

3. Event lama menimpa state baru

Tanpa pembanding waktu atau versi, event tertunda bisa meng-overwrite status terbaru. Gunakan occurred_at, nomor versi, atau fetch state terbaru dari provider.

4. Provider menambah enum baru

Jika kode Anda mengasumsikan daftar enum final dan melempar error untuk nilai baru, event valid bisa gagal terus. Lebih aman memberi jalur unknown-but-recorded: simpan, log, tandai butuh review, tetapi jangan sampai seluruh pipeline runtuh.

5. Secret rotation

Ketika provider mengganti secret signature, request dapat gagal selama masa transisi. Praktik yang umum adalah mendukung lebih dari satu secret aktif untuk jangka pendek, lalu mencatat secret mana yang dipakai agar rotasi bisa dimonitor.

Checklist implementasi backend

  • Endpoint membaca raw body untuk verifikasi signature.
  • Signature diverifikasi dengan constant-time compare.
  • Ada timestamp header dan batas skew untuk replay protection.
  • Payload memiliki event_id, event_type, occurred_at, dan schema_version.
  • Ada unique constraint pada event ID.
  • Duplikasi dibalas 2xx, bukan dianggap error.
  • Pemrosesan berat dilakukan di queue/worker, bukan request thread.
  • Side effect domain juga idempotent, bukan hanya penerimaan event.
  • Event out-of-order ditangani dengan timestamp, versi, atau fetch state terbaru.
  • Event gagal dapat masuk ke dead-letter queue atau status gagal yang bisa direplay.
  • Ada logging, metrics, tracing, dan correlation ID berbasis event ID.
  • Skema dan aturan retry terdokumentasi jelas sebagai kontrak integrasi.

Penutup

Merancang webhook aman berarti menerima kenyataan bahwa jaringan tidak andal, provider akan retry, dan kontrak bisa berkembang. Fondasi teknisnya adalah signature yang benar, replay protection, idempotensi di level event dan side effect, retry yang terukur, serta versioning dan observability yang jelas.

Jika Anda hanya mengambil satu prinsip dari artikel ini, ambil ini: perlakukan webhook sebagai sistem terdistribusi, bukan callback sederhana. Begitu kontrak, deduplikasi, dan pemrosesan asinkron dirancang dengan eksplisit, integrasi Anda akan jauh lebih tahan terhadap perubahan dan gangguan operasional.