Layanan pembayaran seperti Midtrans atau Xendit tidak menunggu server kamu bertanya status transaksi. Begitu pembayaran lolos, mereka langsung POST payload JSON ke satu URL, dan server kamu harus siap menerimanya kapan saja — termasuk jam 3 pagi. Itu webhook: HTTP request yang dikirim satu aplikasi ke aplikasi lain saat suatu event terjadi. Commit baru di GitHub, pembayaran berhasil, email bounce, semuanya bisa jadi pemicu.

Repotnya, siapa pun yang tahu URL webhook kamu bisa mengirim request palsu. Tanpa verifikasi, server kamu bakal memproses data bohong — misalnya transaksi yang tidak pernah dibayar, atau perintah deploy dari orang asing. Di sini saya pakai FastAPI untuk membangun endpoint webhook yang cuma menerima payload dari sumber terpercaya, lengkap dengan verifikasi signature HMAC SHA-256 ala GitHub.

Siapkan Project

Buat folder baru dan virtual environment, lalu pasang FastAPI dan uvicorn. Semua kode nanti disimpan di satu file main.py.

bash
mkdir webhook-demo && cd webhook-demo
python -m venv .venv
source .venv/bin/activate
pip install fastapi "uvicorn[standard]"

Endpoint Webhook Paling Sederhana

Mulai dari versi polos dulu. Endpoint ini cuma menerima POST, membaca JSON-nya, lalu membalas 200.

from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request):
    payload = await request.json()
    print("Payload masuk:", payload)
    return {"status": "ok"}

Jalankan dengan uvicorn, lalu kirim request uji dari terminal lain:

bash
uvicorn main:app --reload
bash
curl -X POST http://localhost:8000/webhook \
  -H "Content-Type: application/json" \
  -d '{"event": "payment.success", "amount": 150000}'

Terminal uvicorn mencetak payload, dan curl menerima {"status": "ok"}. Endpoint jalan. Tapi ada satu lubang besar: request tadi bisa dikirim siapa saja, dan kamu tidak punya cara membedakan payload asli dari Midtrans dengan payload bohong bikinan orang iseng.

Endpoint tanpa verifikasi berbahaya untuk webhook yang memicu aksi nyata — update status order, kirim barang, atau deploy. Jangan pernah expose endpoint seperti ini ke internet tanpa cek signature.

Verifikasi Signature HMAC SHA-256

Penyedia webhook macam GitHub, Stripe, dan Midtrans menandatangani tiap request. Mereka menghitung HMAC SHA-256 dari body mentah memakai secret yang hanya kamu dan mereka yang tahu, lalu menaruh hasilnya di header X-Hub-Signature-256. Server kamu tinggal menghitung ulang dan membandingkan.

Secret adalah kuncinya. Simpan di environment variable, jangan hardcode di kode, apalagi di-commit ke git.

python
import hashlib
import hmac
import os

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()

def verify_signature(raw_body: bytes, signature: str | None) -> bool:
    if not signature:
        return False
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", signature)

compare_digest dipakai supaya perbandingannya constant-time, jadi attacker tidak bisa menebak byte demi byte lewat timing. Sekarang pasang logika itu ke endpoint:

python
import json

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request):
    raw = await request.body()
    signature = request.headers.get("x-hub-signature-256")

    if not verify_signature(raw, signature):
        raise HTTPException(status_code=401, detail="Signature tidak valid")

    payload = json.loads(raw)
    event = request.headers.get("x-github-event")
    print(f"Event {event} diterima")
    return {"received": event}

Baca body lewat request.body() (bytes mentah), bukan request.json(). Signature dihitung dari byte persis seperti yang dikirim — kalau kamu parse dulu ke dict lalu encode ulang, urutan key bisa berubah dan hash-nya jadi beda.

Handle Event dan Balas Cepat

Penyedia webhook umumnya memberi timeout sekitar 5–10 detik. Kalau semua logika — update database, kirim email, panggil API lain — dikerjakan di dalam endpoint, request bisa hang, penyedia menganggapnya gagal, lalu retry. Balas 200 atau 202 cepat, sisanya kerjakan di background.

python
from fastapi import BackgroundTasks

@app.post("/webhook")
async def webhook(request: Request, background: BackgroundTasks):
    raw = await request.body()
    signature = request.headers.get("x-hub-signature-256")

    if not verify_signature(raw, signature):
        raise HTTPException(status_code=401, detail="Signature tidak valid")

    event = request.headers.get("x-github-event")
    payload = json.loads(raw)

    background.add_task(process_event, event, payload)
    return {"received": event}


def process_event(event: str, payload: dict) -> None:
    if event == "push":
        branch = payload["ref"].split("/")[-1]
        print(f"Push ke {branch} oleh {payload['pusher']['name']}")
    elif event == "issues":
        print(f"Issue #{payload['issue']['number']} di-{payload['action']}")

BackgroundTasks cukup untuk pekerjaan ringan. Kalau task-nya berat atau butuh antrean yang tahan restart — misalnya kirim ribuan notifikasi — pindah ke Celery. Saya sudah bahas lengkap di artikel terpisah.

Jangan pernah meneruskan payload ke tempat lain sebelum diverifikasi. Isinya bisa berupa input yang sengaja dibuat jahat, jadi perlakukan seperti input pengguna biasa — pola yang sama dengan menangani SQL injection dan XSS.

Testing Lokal Tanpa Server Publik

Untuk menguji webhook dari laptop, kamu tidak perlu deploy dulu. Kirim request lokal dengan signature yang dihitung memakai openssl, persis seperti yang dilakukan server GitHub.

bash
export WEBHOOK_SECRET="rahasia-ubah-ini"
uvicorn main:app --reload
bash
BODY='{"ref":"refs/heads/main","pusher":{"name":"budi"}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "rahasia-ubah-ini" | awk '{print $2}')

curl -X POST http://localhost:8000/webhook \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Event: push" \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  -d "$BODY"

Kalau secret-nya cocok, endpoint membalas {"received":"push"}. Coba ubah satu karakter di BODY tanpa menghitung ulang signature-nya — endpoint langsung menolak dengan 401. Untuk tes dari penyedia asli (GitHub, Stripe) yang butuh URL publik, pakai ngrok:

bash
ngrok http 8000

Salin URL https dari ngrok, tempel ke dashboard penyedia webhook, dan request asli akan diteruskan ke localhost:8000. Sekarang kamu bisa debugging payload sungguhan sambil memantau log uvicorn.

Pertanyaan yang Sering Muncul

Apa bedanya webhook dengan polling?

Polling berarti server kamu menanyakan "ada data baru?" berulang kali ke penyedia, memboroskan request dan sering terlambat. Webhook sebaliknya: penyedia yang aktif mendorong data begitu event terjadi. Webhook lebih hemat dan real-time, tapi kamu butuh endpoint publik yang selalu standby.

Bagaimana kalau penyedia webhook saya tidak mengirim signature?

Beberapa layanan kecil tidak menandatangani payload. Minimal pasang IP allowlist kalau penyedia punya rentang IP tetap, atau terapkan token rahasia di header. Kalau keduanya tidak ada, jangan pakai webhook itu untuk aksi sensitif — anggap payload-nya tidak terpercaya.