- Kenapa Webhook Butuh Verifikasi Signature
- Pola 1: HMAC Signature dengan Timestamp
- Pola 2: Retry dengan Exponential Backoff di Sisi Pengirim
- Pola 3: Idempotensi di Sisi Penerima — Bug yang Paling Sering Terlewat
- Endpoint FastAPI Lengkap, Diuji dengan Request HTTP Asli
- Alur Lengkap dari Job Selesai sampai Diproses Penerima
- Kapan In-Memory Set Cukup, Kapan Butuh Tabel Database
- Pertanyaan yang Sering Muncul
- Kesimpulan
Webhook untuk Hasil AI Async: Signature, Retry, Idempotensi
Kalau tugas AI dikerjakan lewat job queue, hasilnya perlu sampai ke sistem lain — bisa ke backend Anda sendiri, bisa ke server pihak ketiga. Polling terus-menerus boros; webhook lebih efisien: begitu job selesai, sistem Anda yang mengirim notifikasi ke URL tujuan. Tapi webhook yang dikirim asal-asalan membuka tiga lubang nyata: siapa saja bisa memalsukan payload, jaringan yang tidak stabil bikin penerima bisa memproses hal yang sama dua kali, dan pengirim yang tidak retry berarti hasil job bisa hilang begitu saja. Tiga pola di bawah diuji langsung sampai bug yang sebenarnya kelihatan.
Artikel job queue async membahas cara memproses tugas AI yang lama lewat submit-worker-status. Begitu worker selesai, langkah berikutnya sering berupa memberi tahu sistem lain bahwa hasilnya sudah siap — dan di situlah webhook masuk. Tiga hal yang dibahas di sini: memverifikasi bahwa webhook benar-benar berasal dari pengirim yang sah, mengirim ulang webhook yang gagal terkirim tanpa membuat penerima memproses dua kali, dan kode yang sudah diuji untuk ketiganya.
Kenapa Webhook Butuh Verifikasi Signature
Endpoint publik = bisa dipalsukan
URL webhook biasanya adalah endpoint HTTP publik. Tanpa verifikasi, siapa pun yang tahu URL-nya bisa mengirim payload palsu yang membuat penerima memproses “hasil job” yang sebenarnya tidak pernah ada.
Payload bisa diubah di tengah jalan
Meski jarang, request HTTP bisa dimodifikasi sebelum sampai tujuan kalau tidak lewat TLS yang benar. Signature memastikan isi payload yang diterima persis sama dengan yang dikirim.
Replay attack
Payload lama yang sah bisa direkam dan dikirim ulang nanti. Menyertakan timestamp dalam signature dan menolak timestamp yang terlalu tua menutup celah ini.
Pola 1: HMAC Signature dengan Timestamp
Pengirim dan penerima berbagi satu secret key. Pengirim menghitung HMAC-SHA256 dari gabungan timestamp dan payload, lalu mengirimkannya sebagai header. Penerima menghitung ulang dengan secret yang sama dan membandingkan — kalau cocok dan timestamp masih segar, payload dianggap sah.
import hmac, hashlib, time
def buat_signature(payload_bytes: bytes, secret: str, timestamp: str) -> str:
pesan = f"{timestamp}.{payload_bytes.decode()}".encode()
return hmac.new(secret.encode(), pesan, hashlib.sha256).hexdigest()
def verifikasi_signature(payload_bytes, secret, timestamp, signature_diterima,
toleransi_detik=300):
now = int(time.time())
if abs(now - int(timestamp)) > toleransi_detik:
return False, "timestamp kadaluarsa"
signature_diharapkan = buat_signature(payload_bytes, secret, timestamp)
if not hmac.compare_digest(signature_diharapkan, signature_diterima):
return False, "signature tidak cocok"
return True, "valid"
Dua detail yang sengaja dijaga: hmac.compare_digest dipakai alih-alih operator == biasa, supaya waktu perbandingan tidak bocor informasi tentang seberapa banyak karakter yang sudah cocok (timing attack). Timestamp masuk ke dalam pesan yang di-hash, bukan sekadar dikirim terpisah — kalau signature hanya menghitung hash dari payload saja, penyerang bisa mengambil signature valid yang lama dan memakainya lagi kapan pun tanpa terdeteksi.
hasil) langsung ditolak dengan “signature tidak cocok”; secret yang salah ditolak; dan payload lama yang di-replay dengan timestamp 999 detik yang lalu ditolak dengan “timestamp kadaluarsa” meski signature-nya sendiri sebenarnya valid untuk payload itu.
Pola 2: Retry dengan Exponential Backoff di Sisi Pengirim
Penerima webhook bisa saja sedang down, lambat, atau timeout sesaat. Pengirim yang baik tidak menyerah di percobaan pertama, tapi juga tidak boleh spam retry tanpa jeda — exponential backoff menaikkan jeda antar percobaan secara bertahap.
def kirim_webhook_dgn_retry(fungsi_kirim, max_percobaan=5, base_delay=1.0):
riwayat = []
for percobaan in range(1, max_percobaan + 1):
try:
hasil = fungsi_kirim()
riwayat.append((percobaan, "sukses"))
return hasil, riwayat
except GagalSementara as e:
riwayat.append((percobaan, f"gagal: {e}"))
if percobaan == max_percobaan:
raise
delay = base_delay * (2 ** (percobaan - 1))
time.sleep(delay)
Pola 3: Idempotensi di Sisi Penerima — Bug yang Paling Sering Terlewat
Konsekuensi dari retry di Pola 2: penerima bisa menerima webhook yang sama persis lebih dari sekali. Ini bisa terjadi bahkan tanpa error sungguhan — penerima berhasil memproses dan mengembalikan 200 OK, tapi response itu hilang di jaringan sebelum sampai ke pengirim, sehingga pengirim mengira gagal dan mengirim ulang. Kalau efek samping webhook itu menambah saldo, mengirim notifikasi, atau memicu pembayaran, dikirim dua kali berarti dijalankan dua kali.
class PenerimaWebhook:
def __init__(self):
self.event_terproses = set() # produksi: UNIQUE(event_id) di DB
self.saldo_user = {}
def proses_event(self, event_id, user_id, jumlah_kredit):
if event_id in self.event_terproses:
return "diabaikan_duplikat"
self.saldo_user[user_id] = self.saldo_user.get(user_id, 0) + jumlah_kredit
self.event_terproses.add(event_id)
return "diproses"
event_id diuji berdampingan dengan versi yang punya idempotensi. Webhook yang sama dikirim 3 kali (mensimulasikan retry pengirim) ke kredit 1000 untuk satu user. Versi dengan idempotensi: saldo akhir 1000, efek samping benar-benar dijalankan cuma 1 kali. Versi tanpa idempotensi: saldo akhir jadi 3000 — bug double-credit yang persis seperti yang terjadi di sistem produksi nyata kalau dedup ini dilewatkan.
Endpoint FastAPI Lengkap, Diuji dengan Request HTTP Asli
Menggabungkan signature dan idempotensi jadi satu endpoint, lalu diuji lewat TestClient — yang berarti request benar-benar lewat lapisan ASGI, bukan cuma memanggil fungsi Python secara langsung.
@app.post("/webhook/hasil-ai")
async def terima_webhook(request: Request):
payload_bytes = await request.body()
timestamp = request.headers.get("X-Webhook-Timestamp", "")
signature = request.headers.get("X-Webhook-Signature", "")
if not verifikasi(payload_bytes, timestamp, signature):
raise HTTPException(status_code=401, detail="signature tidak valid")
data = await request.json()
event_id = data.get("event_id")
if event_id in event_terproses:
return {"status": "diabaikan_duplikat"}
event_terproses.add(event_id)
return {"status": "diproses", "job_id": data.get("job_id")}
Alur Lengkap dari Job Selesai sampai Diproses Penerima
- Worker menyelesaikan job dan menyiapkan payload berisi
event_idunik, bukan cumajob_id—event_idmewakili “notifikasi ini”, bisa beda dari job kalau satu job mengirim beberapa notifikasi (progres, lalu selesai). - Pengirim menghitung signature HMAC dari timestamp + payload, mengirim POST ke URL webhook dengan header signature dan timestamp.
- Kalau gagal (timeout, 5xx), pengirim retry dengan exponential backoff, bukan langsung menyerah atau spam tanpa jeda.
- Penerima memverifikasi signature dan timestamp dulu sebelum membaca isi payload sebagai data yang bisa dipercaya.
- Penerima mengecek
event_idterhadap daftar yang sudah diproses — kalau sudah ada, kembalikan sukses tanpa menjalankan efek samping lagi.
Kapan In-Memory Set Cukup, Kapan Butuh Tabel Database
| Aspek | Set di memori (contoh di atas) | Tabel DB dengan UNIQUE(event_id) |
|---|---|---|
| Bertahan setelah proses restart | Tidak — riwayat event hilang | Ya |
| Konsisten di banyak instance/replica | Tidak — tiap instance punya set sendiri | Ya, satu sumber kebenaran bersama |
| Race condition saat dua request bersamaan | Rawan kalau tidak pakai lock | Aman lewat constraint UNIQUE di level DB |
| Cocok untuk | Prototipe, demo, single instance | Production dengan lebih dari satu instance |
set Python supaya fokus ke logikanya, tapi di production yang berjalan di banyak instance, pengecekan idempotensi harus lewat penyimpanan bersama — tabel database dengan constraint UNIQUE(event_id) adalah pilihan paling umum karena constraint itu sendiri yang mencegah race condition saat dua request dengan event_id sama diproses nyaris bersamaan oleh dua instance berbeda.
Pertanyaan yang Sering Muncul
Apa bedanya webhook dengan polling status job?
Polling berarti klien yang berulang kali bertanya “sudah selesai belum?” — sederhana tapi boros request kalau intervalnya pendek atau job-nya banyak. Webhook membalik arahnya: sistem yang tahu job sudah selesai yang aktif memberi tahu, jadi tidak ada request sia-sia menunggu status berubah.
Kenapa timestamp perlu masuk ke dalam signature, bukan cuma payload?
Kalau signature hanya menghitung hash dari payload, signature itu tetap valid selamanya untuk payload yang sama. Penyerang yang berhasil menangkap satu payload dan signature-nya bisa memakainya lagi kapan saja. Menyertakan timestamp dalam pesan yang di-hash membuat signature hanya valid dalam jendela waktu tertentu.
Apakah perlu retry tanpa batas kalau webhook terus gagal?
Tidak disarankan. Retry tanpa batas menyembunyikan masalah nyata (endpoint tujuan yang memang sudah tidak ada atau salah konfigurasi) dan bisa memenuhi antrian retry dengan job yang tidak akan pernah berhasil. Pola umum adalah retry dengan jumlah maksimum, lalu memindahkan yang gagal permanen ke semacam dead-letter log untuk ditinjau manual.
Bagaimana kalau penerima webhook adalah sistem pihak ketiga yang saya tidak kendalikan?
Pola signature dan retry tetap di sisi Anda sebagai pengirim, tapi idempotensi jadi tanggung jawab pihak ketiga itu — Anda hanya bisa memastikan event_id yang dikirim konsisten untuk event yang sama, supaya mereka punya cara mendeteksi duplikat kalau mereka mengimplementasikannya dengan benar.
Kesimpulan
Webhook untuk hasil AI async terlihat sederhana di permukaan — kirim POST begitu job selesai — tapi tiga hal yang dibahas di sini adalah yang membedakan implementasi yang aman dari yang rapuh. Verifikasi signature dengan timestamp mencegah payload palsu dan replay attack, teruji lewat empat skenario termasuk percobaan replay yang eksplisit. Retry dengan exponential backoff di sisi pengirim mencegah hasil job hilang begitu saja karena kegagalan sesaat. Dan idempotensi di sisi penerima adalah yang paling sering terlewat justru karena konsekuensinya tidak langsung kelihatan sampai retry benar-benar terjadi — pengujian di atas membuktikan bug double-credit itu nyata, bukan sekadar risiko teoretis.
Artikel terkait: job queue async untuk tugas AI (tautan di bawah) dan skill backend untuk AI engineering.
Lihat pola submit-worker-status lengkap, termasuk pengujian ketahanan saat satu job gagal.
Baca Selengkapnya




