Webhook publik di self-hosted stack menambah kompleksitas yang sering tidak terlihat saat masih berada di lingkungan internal. Endpoint harus bisa diakses internet, tetapi tetap membatasi apa yang dipercaya dari request masuk. Di sisi lain, sistem juga harus tahan terhadap duplicate delivery, retry agresif dari pihak ketiga, timeout jaringan, dan gangguan operasional seperti DNS salah, reverse proxy keliru, firewall memblokir, NAT tidak stabil, atau queue internal tersendat.

Artikel ini membahas desain webhook publik yang praktis untuk stack self-hosted: bagaimana menentukan trust boundary, memverifikasi keaslian request dengan HMAC signature dan timestamp, menambahkan replay protection dan idempotency, merancang retry policy, serta memasang observability yang cukup untuk debugging saat internet dan infrastruktur sedang tidak kooperatif.

Masalah utama: endpoint publik bukan berarti request bisa dipercaya

Begitu sebuah endpoint webhook dipublikasikan, ada dua fakta yang harus diterima:

  • Semua orang dapat mencoba mengaksesnya, bukan hanya vendor atau partner yang Anda integrasikan.
  • Komponen jaringan di depan aplikasi dapat mengubah konteks request, misalnya terminasi TLS di reverse proxy, rewriting header, rate limiting, atau timeout sebelum request sampai ke aplikasi.

Karena itu, desain webhook tidak boleh bergantung pada asumsi lemah seperti:

  • "Kalau datang dari internet publik perusahaan X pasti aman"
  • "Kalau IP cocok dengan dokumentasi vendor berarti valid"
  • "Kalau HTTPS aktif berarti payload asli"

Trust boundary untuk webhook publik biasanya ada di aplikasi penerima setelah request melewati lapisan jaringan minimum yang Anda kontrol: DNS, firewall, load balancer, reverse proxy, lalu aplikasi. TLS melindungi transport, tetapi bukan bukti bahwa payload dikirim oleh pengirim yang sah. Bukti itu harus datang dari mekanisme aplikasi seperti HMAC signature, timestamp, dan identifier event.

Trust boundary pada stack self-hosted

Lapisan yang umum ditemui

Dalam deployment self-hosted, request webhook sering melewati jalur seperti ini:

Internet -> DNS -> Firewall/Router -> NAT -> Reverse Proxy / Load Balancer -> App -> Queue -> Worker -> Database

Setiap lapisan punya peran dan failure mode sendiri:

  • DNS: domain salah, record belum propagasi, TTL terlalu tinggi, atau vendor masih mengirim ke IP lama.
  • Firewall: port 443 terbuka untuk browser biasa, tetapi diblok untuk sumber tertentu atau ada inspeksi yang memutus koneksi.
  • NAT: mapping tidak konsisten, hairpin NAT bermasalah, atau forwarding salah ke host internal.
  • Reverse proxy: body dibatasi terlalu kecil, timeout terlalu pendek, header penting tidak diteruskan, atau raw body berubah.
  • Aplikasi: verifikasi signature salah karena payload diubah parser, jam server meleset, atau database lambat.
  • Queue/worker: enqueue berhasil tapi worker berhenti, backlog menumpuk, retry internal bertabrakan dengan retry dari pihak ketiga.

Apa yang boleh dipercaya?

Untuk request webhook dari internet, anggap hal berikut tidak cukup sebagai bukti keaslian:

  • IP source saja
  • User-Agent
  • Header X-Forwarded-For tanpa validasi proxy tepercaya
  • Field pengirim di body JSON

Yang lebih layak dipercaya:

  • Signature kriptografis yang dihitung atas raw payload dengan secret bersama
  • Timestamp untuk membatasi jendela validitas request
  • Event ID / delivery ID untuk replay protection dan deduplikasi
  • Mutual TLS bila kedua pihak mengontrol sertifikat dan operasionalnya, meskipun ini lebih kompleks

IP allowlist masih berguna sebagai defense in depth, tetapi jangan dijadikan mekanisme autentikasi utama. Banyak integrator mengubah IP tanpa pemberitahuan sempurna, memakai CDN, atau mengirim dari beberapa region.

Desain autentikasi webhook: HMAC, timestamp, dan replay protection

Mengapa HMAC cocok untuk webhook

HMAC bekerja baik untuk webhook karena sederhana dan tidak memerlukan sesi. Pengirim dan penerima berbagi secret. Pengirim menghitung signature dari data tertentu, lalu penerima menghitung ulang signature dengan secret yang sama. Jika hasilnya sama, integritas payload terjaga dan pengirim kemungkinan sah.

Praktik penting:

  • Hitung signature dari raw request body, bukan objek JSON hasil parsing ulang.
  • Gabungkan timestamp ke data yang ditandatangani agar signature tidak valid tanpa batas waktu.
  • Bandingkan signature dengan constant-time comparison untuk mengurangi risiko timing attack.
  • Dukung secret rotation dengan menyimpan secret aktif dan secret sebelumnya selama masa transisi.

Contoh kontrak header webhook

Berikut contoh kontrak yang cukup umum dan realistis:

POST /webhooks/vendor-a HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
X-Webhook-Id: evt_01JABCXYZ789
X-Webhook-Timestamp: 1736157600
X-Webhook-Signature: v1=5f2d3b4c...
X-Webhook-Source: vendor-a
Idempotency-Key: evt_01JABCXYZ789

Contoh payload:

{
  "event_id": "evt_01JABCXYZ789",
  "event_type": "invoice.paid",
  "occurred_at": "2026-01-06T10:00:00Z",
  "data": {
    "invoice_id": "inv_12345",
    "customer_id": "cus_999",
    "amount": 250000,
    "currency": "IDR"
  }
}

String yang ditandatangani sebaiknya eksplisit, misalnya:

signed_payload = X-Webhook-Timestamp + "." + raw_request_body

Lalu signature dihitung dengan HMAC-SHA256:

signature = hex(HMAC_SHA256(secret, signed_payload))

Contoh verifikasi di server

function verifyWebhook(headers, rawBody, secrets, nowEpoch) {
  const ts = headers["x-webhook-timestamp"];
  const sigHeader = headers["x-webhook-signature"];
  const eventId = headers["x-webhook-id"];

  if (!ts || !sigHeader || !eventId) {
    return { ok: false, reason: "missing_required_headers" };
  }

  const tsInt = parseInt(ts, 10);
  if (Number.isNaN(tsInt)) {
    return { ok: false, reason: "invalid_timestamp" };
  }

  const maxSkewSeconds = 300;
  if (Math.abs(nowEpoch - tsInt) > maxSkewSeconds) {
    return { ok: false, reason: "timestamp_out_of_window" };
  }

  const expectedPayload = ts + "." + rawBody;
  const provided = extractV1Signature(sigHeader);
  if (!provided) {
    return { ok: false, reason: "invalid_signature_format" };
  }

  let matched = false;
  for (const secret of secrets) {
    const expected = hmacSha256Hex(secret, expectedPayload);
    if (constantTimeEqual(expected, provided)) {
      matched = true;
      break;
    }
  }

  if (!matched) {
    return { ok: false, reason: "signature_mismatch" };
  }

  return { ok: true, eventId, timestamp: tsInt };
}

Poin penting di contoh di atas:

  • rawBody harus benar-benar body asli sebelum dinormalisasi framework.
  • secrets berupa daftar secret aktif untuk mendukung rotasi.
  • timestamp window mencegah request lama diputar ulang tanpa batas.

Replay protection

Signature yang valid belum cukup. Jika seorang penyerang atau proxy merekam request valid lalu mengirimkannya lagi dalam jendela waktu yang masih diterima, sistem Anda bisa memproses event berulang. Karena itu, simpan kombinasi berikut untuk periode tertentu:

  • event_id atau X-Webhook-Id
  • timestamp
  • signature digest atau hash payload bila perlu

Strategi yang umum:

  • Tolak request dengan event_id yang sudah pernah diproses.
  • Atau, terima request duplikat tetapi respons sukses tanpa memproses ulang.
  • Simpan catatan replay di Redis atau database dengan TTL sesuai kebutuhan audit dan retry pihak ketiga.

Pendekatan kedua biasanya lebih ramah integrasi: vendor yang me-retry karena tidak menerima respons dapat tetap memperoleh 2xx, sementara sistem Anda tetap idempoten.

Idempotency, deduplikasi event, dan model pemrosesan yang aman

Masalah duplicate delivery adalah normal

Dalam dunia webhook, duplicate delivery bukan edge case. Vendor dapat mengirim ulang karena:

  • Timeout membaca respons
  • Koneksi terputus setelah request terkirim
  • Server Anda membalas 5xx
  • Load balancer atau proxy mengakhiri koneksi lebih awal
  • Sistem mereka sendiri melakukan retry spekulatif

Karena itu, handler webhook harus didesain sebagai at-least-once consumer.

Bedakan deduplikasi transport dan deduplikasi bisnis

Ada dua level deduplikasi:

  1. Transport-level deduplication: mencegah event delivery yang sama diproses berulang, biasanya dengan event_id.
  2. Business-level idempotency: mencegah efek samping domain terjadi dua kali, misalnya invoice ditandai lunas dua kali atau saldo dikreditkan berulang.

Keduanya penting. Menyimpan event_id saja tidak selalu cukup jika vendor bisa mengirim event berbeda yang secara bisnis merepresentasikan objek yang sama.

Pola implementasi yang disarankan

  1. Terima request.
  2. Verifikasi signature, timestamp, dan header wajib.
  3. Cek apakah event_id sudah pernah diterima.
  4. Simpan event mentah ke tabel inbox atau append-only log.
  5. Kembalikan 202 Accepted atau 200 OK dengan cepat.
  6. Proses event secara asynchronous melalui queue/worker.
  7. Di worker, terapkan idempotensi bisnis dengan transaksi database atau unique constraint.

Contoh skema tabel inbox:

webhook_inbox (
  source               text not null,
  event_id             text not null,
  received_at          timestamp not null,
  signature_valid      boolean not null,
  payload_sha256       text not null,
  payload_json         jsonb not null,
  processing_status    text not null,
  primary key (source, event_id)
)

Keuntungan pola ini:

  • Request publik tidak perlu menunggu logika bisnis selesai.
  • Ada jejak audit untuk debugging.
  • Retry dari pihak ketiga lebih mudah ditangani.
  • Gangguan worker tidak langsung membuat vendor menganggap endpoint gagal, selama event berhasil diterima dan disimpan.

Unique constraint lebih kuat daripada cek manual

Anti-pattern yang sering muncul adalah:

if (!exists(event_id)) {
  insert(event_id)
  process()
}

Di bawah concurrency, dua request dapat sama-sama lolos sebelum insert. Lebih aman gunakan unique constraint di database atau operasi atomik di Redis, lalu tangani konflik sebagai event duplikat yang sah.

Retry policy, timeout, dan respons yang benar

Kapan membalas 2xx, 4xx, atau 5xx

Status code memengaruhi perilaku retry pihak ketiga. Aturan praktis:

  • 2xx: request diterima. Gunakan bila signature valid dan event berhasil dicatat, meskipun belum selesai diproses.
  • 4xx: request buruk atau tidak sah, misalnya signature salah, timestamp invalid, header wajib hilang. Umumnya pengirim tidak perlu retry tanpa perubahan.
  • 5xx: kegagalan sementara di sisi Anda, misalnya database down atau queue tidak tersedia. Pengirim biasanya akan retry.

Hati-hati: jika Anda membalas 2xx sebelum event tercatat secara durable, Anda bisa kehilangan event tanpa kesempatan retry. Sebaliknya, jika Anda menunggu seluruh pemrosesan bisnis selesai baru membalas, Anda akan memperbesar timeout dan duplicate delivery.

Timeout yang realistis

Untuk webhook masuk, targetkan respons cepat. Praktik umum:

  • Batasi kerja sinkron di request path seminimal mungkin.
  • Jangan memanggil API lain secara sinkron kecuali benar-benar perlu.
  • Jangan melakukan query berat atau transaksi panjang sebelum mengembalikan respons.

Timeout harus konsisten lintas lapisan:

  • Reverse proxy read timeout
  • App server request timeout
  • Database timeout
  • Queue publish timeout

Jika proxy timeout 5 detik tetapi aplikasi butuh 15 detik untuk menulis ke storage yang lambat, vendor akan melihat kegagalan dan mengirim ulang, padahal aplikasi mungkin tetap melanjutkan pemrosesan. Ini sumber duplikasi klasik.

Retry internal vs retry eksternal

Jangan mencampur retry dari vendor dengan retry internal Anda tanpa kontrol. Contoh jebakan:

  • Vendor retry 10 kali.
  • Aplikasi Anda juga retry enqueue 5 kali per request.
  • Worker retry job 20 kali.

Efeknya bisa berupa ledakan beban, pemrosesan ganda, dan backlog yang sulit dipulihkan.

Pisahkan tanggung jawab:

  • Request ingress: validasi, simpan durable, respons cepat.
  • Worker: retry logika bisnis yang memang transient, dengan backoff dan batas percobaan yang jelas.
  • Dead-letter handling: jika event gagal terus, pindahkan ke status gagal untuk investigasi manual atau replay terkontrol.

Failure mode nyata pada infrastruktur self-hosted

DNS gagal atau salah arah

Masalah umum:

  • Record A/AAAA salah setelah migrasi IP
  • TTL panjang membuat vendor tetap mengirim ke alamat lama
  • Sertifikat TLS tidak cocok karena domain berubah

Gejalanya:

  • Tidak ada request masuk sama sekali
  • Vendor melaporkan connection refused atau name resolution failure
  • Akses dari browser lokal berhasil karena cache DNS berbeda dengan sisi vendor

Debugging cepat:

  • Periksa resolusi DNS publik dari beberapa lokasi.
  • Bandingkan IPv4 dan IPv6 jika keduanya aktif.
  • Pastikan sertifikat dan SNI cocok dengan hostname webhook.

Reverse proxy mengubah request

Proxy sering menjadi sumber bug verifikasi signature:

  • Body di-buffer atau diubah
  • Content-Encoding di-decompress sehingga raw body berbeda
  • Header custom dihapus atau diganti huruf besar-kecilnya secara tak terduga
  • Batas body terlalu kecil menghasilkan 413

Jika HMAC mendadak selalu gagal setelah melewati proxy baru, hal pertama yang diperiksa adalah apakah aplikasi masih menerima raw body yang identik dengan yang dikirim pengirim.

Firewall dan NAT memunculkan kegagalan intermiten

Pada self-hosted, masalah tidak selalu berupa down total. Bisa muncul sebagai:

  • Sebagian region berhasil, sebagian timeout
  • Koneksi TCP terbuka tetapi TLS handshake gagal
  • Port forwarding hanya benar untuk satu host internal
  • NAT idle timeout memutus koneksi panjang

Webhook biasanya request pendek, tetapi kegagalan intermiten seperti ini memicu retry dan duplicate delivery. Karena itu, observability harus bisa menjawab apakah request:

  • tidak pernah mencapai edge,
  • mencapai proxy tapi tidak ke aplikasi, atau
  • sudah sampai aplikasi tetapi gagal dipersist.

Queue internal bermasalah setelah ingress sukses

Ini sangat umum dan berbahaya. Aplikasi membalas 202, tetapi:

  • queue broker penuh,
  • publish timeout,
  • worker mati,
  • consumer lag sangat tinggi.

Jika event belum disimpan durable sebelum enqueue, Anda bisa kehilangan data. Solusi yang lebih aman:

  • Simpan dulu event ke database/inbox log.
  • Gunakan outbox/inbox pattern atau polling worker dari tabel durable.
  • Anggap queue sebagai akselerator pemrosesan, bukan satu-satunya tempat keberadaan event.

Observability yang wajib dipasang

Log terstruktur per delivery

Setiap request webhook sebaiknya punya correlation data minimum:

  • request_id
  • source
  • event_id
  • signature_valid
  • timestamp_skew_seconds
  • http_status
  • ingress_latency_ms
  • enqueue_result

Jangan log secret, payload sensitif penuh, atau signature mentah jika tidak perlu. Untuk payload, lebih aman log hash, ukuran body, dan field identitas utama.

Metrik yang berguna

  • Jumlah request per source dan per status code
  • Rate signature mismatch
  • Rate duplicate event
  • Latency verifikasi dan persist
  • Queue lag dan usia event tertua
  • Jumlah event di dead-letter atau gagal diproses

Tanpa metrik duplicate event, Anda sulit membedakan antara serangan replay, bug vendor, dan timeout jaringan yang menyebabkan retry.

Tracing dan audit trail

Jika memungkinkan, buat alur yang dapat ditelusuri dari edge sampai worker:

request_id -> event_id -> inbox_row_id -> queue_job_id -> business_transaction_id

Ini sangat membantu saat pihak ketiga bertanya, “Kami menerima 200, apakah event sudah diproses?” Anda dapat menjawab dengan data, bukan asumsi.

Contoh alur implementasi yang aman

  1. Endpoint publik menerima request HTTPS.
  2. Reverse proxy meneruskan raw body dan header webhook tanpa modifikasi yang mengubah signature.
  3. Aplikasi membaca raw body.
  4. Aplikasi memverifikasi timestamp, signature, dan source secret.
  5. Aplikasi melakukan operasi atomik insert-if-not-exists ke tabel inbox berdasarkan (source, event_id).
  6. Jika duplikat, aplikasi tetap mengembalikan 200 atau 202.
  7. Jika berhasil disimpan, aplikasi mengembalikan 202 dengan cepat.
  8. Worker membaca inbox, memproses logika bisnis secara idempoten, lalu menandai status sukses/gagal.
  9. Jika gagal sementara, worker retry dengan backoff.
  10. Jika gagal permanen, event dipindahkan ke antrian investigasi atau dead-letter.

Contoh respons minimal:

HTTP/1.1 202 Accepted
Content-Type: application/json

{"status":"accepted","event_id":"evt_01JABCXYZ789"}

Checklist implementasi webhook publik di self-hosted stack

  • Gunakan HTTPS end-to-end atau minimal sampai reverse proxy tepercaya.
  • Definisikan trust boundary dengan jelas: mana edge tepercaya, mana yang tidak.
  • Verifikasi request dengan HMAC signature berbasis raw body.
  • Tambahkan timestamp dan batasi skew waktu.
  • Gunakan constant-time comparison untuk signature.
  • Dukung secret rotation.
  • Simpan dan cek event_id untuk replay protection dan deduplikasi.
  • Terapkan idempotensi bisnis, bukan hanya deduplikasi delivery.
  • Persist event secara durable sebelum membalas sukses.
  • Respon cepat; pindahkan kerja berat ke queue/worker.
  • Samakan timeout di proxy, app, database, dan queue publish.
  • Pasang log terstruktur, metrik duplicate/retry, dan tracing.
  • Uji failure mode: DNS salah, proxy timeout, queue down, DB lambat, worker mati.
  • Dokumentasikan kontrak header, payload, status code, dan retry expectation.

Anti-pattern umum saat integrasi pihak ketiga

1. Mengandalkan IP allowlist sebagai autentikasi utama

Masalah: IP bisa berubah, daftar tidak lengkap, dan sulit dikelola di multi-region. Tetap berguna, tetapi hanya sebagai lapisan tambahan.

2. Menghitung HMAC dari JSON yang sudah diparse

Masalah: perbedaan whitespace, urutan field, encoding, atau normalisasi membuat signature mismatch. HMAC harus menggunakan raw body.

3. Memproses logika bisnis penuh sebelum membalas

Masalah: mudah timeout, memicu retry vendor, dan memperbesar duplikasi.

4. Mengembalikan 200 sebelum event dipersist

Masalah: jika aplikasi crash setelah respons dikirim, event hilang dan vendor tidak akan retry.

5. Tidak membedakan kegagalan permanen dan sementara

Masalah: semua error dibalas 500 atau semua dibalas 400. Akibatnya perilaku retry pengirim menjadi salah.

6. Tidak menyimpan payload mentah atau hash payload

Masalah: sulit audit dan sulit membuktikan apakah perbedaan berasal dari vendor, proxy, atau aplikasi.

7. Tidak sinkronisasi waktu server

Masalah: verifikasi timestamp gagal walau signature benar. Untuk webhook berbasis timestamp, clock drift kecil pun bisa merusak integrasi.

Penutup

Webhook publik di self-hosted stack adalah kombinasi masalah aplikasi, jaringan, dan operasi. Endpoint yang bisa diakses dari internet belum berarti aman atau andal. Desain yang baik selalu berangkat dari trust boundary yang ketat: jangan percaya IP, header, atau body tanpa verifikasi kriptografis; anggap duplicate delivery sebagai kondisi normal; persist event sebelum mengakui sukses; dan siapkan observability untuk membedakan apakah masalah ada di DNS, proxy, firewall, NAT, aplikasi, atau queue.

Jika Anda hanya mengambil satu prinsip dari artikel ini, ambil yang ini: validasi di edge aplikasi, akui penerimaan hanya setelah durable, lalu proses secara idempoten di belakang. Pola itu tidak menghilangkan semua failure mode, tetapi secara signifikan menurunkan risiko event palsu, replay, kehilangan data, dan duplikasi efek bisnis.