Webhook receiver yang baik tidak cukup hanya bisa menerima HTTP POST. Dalam integrasi nyata, provider sering melakukan retry, mengirim event duplikat, atau mengirim event dalam urutan yang tidak sesuai. Jika endpoint Anda tidak dirancang dengan kontrak yang jelas, hasilnya bisa berupa data ganda, status bisnis yang mundur, atau celah keamanan seperti replay attack.
Pada artikel ini, kita akan merancang kontrak webhook aman di Go Fiber dengan beberapa komponen inti: verifikasi signature HMAC, validasi timestamp untuk membatasi replay, payload berskema dan berversi, event ID unik untuk deduplikasi, strategi respons 2xx vs 4xx/5xx, serta pola ack cepat lalu proses async. Fokusnya adalah receiver backend, bukan OAuth atau dashboard provider.
Mengapa kontrak webhook harus ketat
Webhook pada dasarnya adalah API yang dipanggil oleh sistem lain. Bedanya, kontrol atas pola kirim ada di pihak provider. Karena itu, receiver harus mengasumsikan beberapa hal berikut:
- Request bisa datang lebih dari sekali untuk event yang sama.
- Event B bisa tiba sebelum event A.
- Provider bisa mengirim ulang jika menerima 5xx, timeout, atau bahkan koneksi terputus setelah Anda sebenarnya sudah memproses event.
- Request bisa dipalsukan jika Anda hanya mengandalkan IP atau header sederhana.
Solusinya bukan sekadar menulis handler yang membaca JSON. Anda perlu kontrak yang eksplisit antara provider dan consumer, lalu menerapkannya secara konsisten di kode.
Kontrak minimum untuk webhook yang aman dan tahan gangguan
1. Header signature dan timestamp
Gunakan HMAC atas raw request body dan sertakan timestamp pada header. Contoh header yang umum:
X-Webhook-Id: evt_01J123ABC
X-Webhook-Timestamp: 1719739200
X-Webhook-Signature: sha256=ab12cd34...Prinsipnya:
- Signature mencegah request palsu.
- Timestamp membatasi validitas request untuk mencegah replay attack.
- Event ID memungkinkan deduplikasi.
HMAC harus dihitung dari data yang stabil. Pilihan paling aman adalah menggabungkan timestamp dan raw body, misalnya:
signed_payload = timestamp + "." + raw_bodyLalu provider dan consumer sama-sama menghitung:
HMAC-SHA256(secret, signed_payload)Jangan menghitung signature dari objek JSON yang sudah diparse lalu di-serialize ulang. Urutan field, spasi, dan format angka bisa berubah sehingga signature tidak cocok.
2. Payload versioned
Webhook cenderung hidup lama. Jika struktur payload berubah tanpa versi, integrasi mudah rusak. Minimal, sertakan versi skema di body:
{
"spec_version": "2024-06-01",
"event_id": "evt_01J123ABC",
"event_type": "payment.succeeded",
"occurred_at": "2024-06-30T10:00:00Z",
"resource_id": "pay_123",
"data": {
"payment_id": "pay_123",
"amount": 150000,
"currency": "IDR",
"status": "succeeded"
}
}Beberapa catatan penting:
- spec_version: versi kontrak payload, bukan versi aplikasi internal Anda.
- event_id: unik secara global per event.
- event_type: tipe domain yang stabil, misalnya
invoice.paidataushipment.delivered. - occurred_at: waktu event terjadi di sisi sumber, bukan waktu dikirim.
- resource_id: identitas entitas utama untuk membantu korelasi dan ordering logis.
3. Respons 2xx, 4xx, dan 5xx yang benar
Respons HTTP menentukan apakah provider akan mengulang kiriman. Aturan praktisnya:
- 2xx: request valid dan sudah diterima. Gunakan ini setelah event lolos verifikasi dan berhasil dicatat untuk diproses, walaupun pemrosesan bisnis belum selesai.
- 4xx: request salah dan tidak akan berhasil jika diulang. Contoh: signature invalid, timestamp terlalu lama, JSON rusak, header wajib hilang.
- 5xx: receiver sedang gagal sementara, misalnya database atau queue tidak tersedia. Provider umumnya akan retry.
Kesalahan umum adalah mengembalikan 200 terlalu cepat sebelum event tersimpan secara andal. Jika proses crash sesudah itu, event hilang dan provider tidak akan retry. Sebaliknya, menunggu seluruh logika bisnis selesai sebelum 200 juga buruk karena meningkatkan timeout dan retry palsu.
Implementasi Go Fiber: verifikasi signature dan ack cepat
Struktur data payload
package main
import (
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
)
type WebhookEvent struct {
SpecVersion string `json:"spec_version"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
OccurredAt time.Time `json:"occurred_at"`
ResourceID string `json:"resource_id"`
Data json.RawMessage `json:"data"`
}
type App struct {
DB *sql.DB
WebhookSecret string
ReplayWindow time.Duration
}
Middleware verifikasi signature HMAC
Middleware berikut memverifikasi header, memastikan timestamp masih dalam toleransi, lalu membandingkan HMAC dengan constant-time comparison. Setelah lolos, raw body disimpan di context untuk dipakai handler berikutnya.
func (a *App) VerifyWebhookSignature(c *fiber.Ctx) error {
eventID := c.Get("X-Webhook-Id")
tsHeader := c.Get("X-Webhook-Timestamp")
sigHeader := c.Get("X-Webhook-Signature")
if eventID == "" || tsHeader == "" || sigHeader == "" {
return fiber.NewError(fiber.StatusBadRequest, "missing webhook headers")
}
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid timestamp header")
}
now := time.Now().Unix()
if abs64(now-ts) > int64(a.ReplayWindow.Seconds()) {
return fiber.NewError(fiber.StatusUnauthorized, "timestamp outside allowed window")
}
rawBody := c.Body()
signedPayload := fmt.Sprintf("%s.%s", tsHeader, rawBody)
expectedMAC := computeHMACSHA256(signedPayload, a.WebhookSecret)
providedMAC, err := parseSignature(sigHeader)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid signature format")
}
if !hmac.Equal(expectedMAC, providedMAC) {
return fiber.NewError(fiber.StatusUnauthorized, "invalid signature")
}
c.Locals("webhook_event_id", eventID)
c.Locals("webhook_raw_body", rawBody)
return c.Next()
}
func computeHMACSHA256(payload, secret string) []byte {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return mac.Sum(nil)
}
func parseSignature(h string) ([]byte, error) {
const prefix = "sha256="
if !strings.HasPrefix(h, prefix) {
return nil, errors.New("unsupported signature scheme")
}
return hex.DecodeString(strings.TrimPrefix(h, prefix))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
Mengapa cara ini penting:
- Raw body dipakai apa adanya agar signature konsisten.
- Timestamp tolerance mengurangi risiko replay dari request lama yang berhasil disadap.
- hmac.Equal menghindari perbandingan string biasa yang kurang aman.
Handler Fiber untuk deduplikasi dan enqueue
Setelah request lolos verifikasi, handler harus melakukan tiga hal secepat mungkin: parse payload, cek duplikasi, simpan event secara andal, lalu enqueue untuk diproses asynchronous.
func (a *App) HandleWebhook(c *fiber.Ctx) error {
rawBody, ok := c.Locals("webhook_raw_body").([]byte)
if !ok {
return fiber.NewError(fiber.StatusInternalServerError, "raw body not available")
}
var evt WebhookEvent
if err := json.Unmarshal(rawBody, &evt); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "invalid JSON payload")
}
if evt.SpecVersion == "" || evt.EventID == "" || evt.EventType == "" || evt.ResourceID == "" {
return fiber.NewError(fiber.StatusBadRequest, "missing required fields")
}
inserted, err := a.insertIncomingEvent(evt, rawBody)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to store event")
}
if !inserted {
return c.SendStatus(fiber.StatusOK)
}
if err := a.enqueueEvent(evt.EventID); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to enqueue event")
}
return c.SendStatus(fiber.StatusAccepted)
}
Pada contoh di atas, event duplikat tetap dibalas 200 agar provider berhenti retry. Ini penting karena dari perspektif provider, event tersebut memang sudah diterima sebelumnya.
Penyimpanan event untuk deduplikasi dan audit
Webhook yang andal hampir selalu membutuhkan tabel penerimaan event. Tujuannya bukan hanya deduplikasi, tetapi juga audit, debugging, dan replay internal.
Skema tabel yang disarankan
CREATE TABLE webhook_inbox (
event_id TEXT PRIMARY KEY,
spec_version TEXT NOT NULL,
event_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
occurred_at TIMESTAMP WITH TIME ZONE NULL,
received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
status TEXT NOT NULL,
raw_payload BYTEA NOT NULL,
last_error TEXT NULL
);
CREATE INDEX idx_webhook_inbox_resource_id ON webhook_inbox(resource_id);
CREATE INDEX idx_webhook_inbox_status ON webhook_inbox(status);
Kolom pentingnya:
- event_id PRIMARY KEY: inti deduplikasi.
- raw_payload: simpan payload mentah untuk investigasi dan replay.
- status: misalnya
received,processing,processed,failed. - last_error: membantu troubleshooting worker.
Insert idempoten
Idealnya, gunakan operasi insert yang aman terhadap race condition, misalnya INSERT ... ON CONFLICT DO NOTHING pada database yang mendukung konsep tersebut. Intinya, jika dua request yang sama datang bersamaan, hanya satu yang benar-benar tersimpan.
func (a *App) insertIncomingEvent(evt WebhookEvent, raw []byte) (bool, error) {
query := `
INSERT INTO webhook_inbox
(event_id, spec_version, event_type, resource_id, occurred_at, status, raw_payload)
VALUES
($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (event_id) DO NOTHING
`
res, err := a.DB.Exec(query,
evt.EventID,
evt.SpecVersion,
evt.EventType,
evt.ResourceID,
evt.OccurredAt,
"received",
raw,
)
if err != nil {
return false, err
}
affected, err := res.RowsAffected()
if err != nil {
return false, err
}
return affected == 1, nil
}
Jika database Anda tidak mendukung sintaks yang sama, pertahankan prinsipnya: deduplikasi harus terjadi di penyimpanan yang memiliki jaminan konkurensi, bukan hanya di memori proses.
Pola queue async setelah ack cepat
Setelah event tersimpan, jangan jalankan seluruh logika bisnis di request thread jika bisa dihindari. Pola yang lebih aman adalah:
- Verifikasi request.
- Simpan event ke inbox.
- Masukkan event ke queue internal.
- Kembalikan 2xx dengan cepat.
- Worker memproses event dari queue.
Keuntungan pola ini:
- Mengurangi timeout dan retry dari provider.
- Memisahkan reliabilitas penerimaan dari reliabilitas pemrosesan bisnis.
- Memudahkan retry internal jika worker gagal.
Contoh enqueue sederhana
Implementasi queue bisa memakai Redis, message broker, atau tabel database. Berikut contoh fungsi konseptual yang mengantrekan event ID, bukan payload penuh, agar worker membaca sumber kebenaran dari inbox:
func (a *App) enqueueEvent(eventID string) error {
// Kirim eventID ke queue internal.
// Implementasi konkret bisa memakai Redis list/stream,
// broker message, atau job table di database.
log.Printf("enqueue event_id=%s", eventID)
return nil
}
Mengapa cukup event ID? Karena payload sudah tersimpan di database. Ini menghindari perbedaan data antara request handler dan worker.
Worker harus idempoten
Deduplikasi di inbox belum cukup. Consumer bisnis juga harus idempoten. Misalnya event payment.succeeded bisa terkirim dua kali, atau worker internal Anda retry setelah crash. Logika bisnis harus aman jika dieksekusi berulang.
Strategi idempoten yang umum:
- Update status hanya jika transisi masih valid.
- Simpan jejak
last_processed_event_idpada resource terkait. - Gunakan unique constraint untuk efek samping seperti pembuatan invoice, ledger entry, atau fulfillment job.
- Lakukan pengecekan status target sebelum menulis.
Contoh praktis: jika order sudah berstatus paid, menerima lagi payment.succeeded seharusnya tidak membuat transaksi akuntansi kedua.
Menangani ordering yang tidak selalu benar
Salah satu kesalahan desain paling umum adalah mengasumsikan event datang berurutan. Pada kenyataannya, jaringan, retry, dan partisi worker bisa membuat event lama tiba belakangan.
Pendekatan yang lebih aman
- Gunakan state transition yang defensif: jangan izinkan status mundur tanpa alasan jelas.
- Bandingkan occurred_at atau sequence jika provider menyediakannya.
- Lakukan fetch ke source of truth bila event hanya sinyal perubahan, bukan sumber data final.
- Proses per resource jika ordering penting, misalnya serialisasi job berdasarkan
resource_id.
Contoh: Anda menerima invoice.paid lalu beberapa detik kemudian invoice.created. Jika sistem Anda naif, status invoice bisa kembali ke created. Solusinya, definisikan aturan bahwa status tidak boleh mundur, atau gunakan ranking transisi:
created < pending < paid < settledJika event baru merepresentasikan status dengan ranking lebih rendah dari status saat ini, abaikan atau tandai untuk investigasi.
Kapan perlu sequence number
Jika Anda juga mengontrol sisi pengirim webhook, menambahkan sequence per resource sangat membantu. Namun jika integrasi dengan pihak ketiga tidak menyediakan sequence, jangan menebak ordering. Lebih aman menggunakan aturan idempoten dan validasi transisi state.
Edge case umum integrasi pihak ketiga
1. Signature gagal karena body berubah
Penyebab umum:
- Memverifikasi setelah body dimodifikasi middleware lain.
- Menghitung HMAC dari JSON yang sudah diparse ulang.
- Header signature memakai format berbeda dari asumsi Anda.
Solusi: verifikasi sedini mungkin dan log format header yang diterima tanpa membocorkan secret.
2. Timestamp valid di server provider, invalid di sisi Anda
Biasanya disebabkan clock server Anda meleset. Sinkronisasi waktu dengan NTP adalah syarat operasional yang sering terlupakan.
3. Provider retry walau Anda merasa sudah sukses
Ini bisa terjadi jika:
- Anda merespons terlalu lambat sehingga provider timeout.
- Koneksi putus sebelum provider menerima response penuh.
- Anda mengembalikan 5xx karena queue atau DB gagal.
Karena itu deduplikasi berbasis event_id wajib ada, bahkan jika Anda yakin sistem sudah stabil.
4. Event type baru muncul tiba-tiba
Jangan langsung gagal total jika menerima tipe event yang belum didukung, kecuali kontrak memang mengharuskannya. Sering kali lebih aman menyimpan event, menandai sebagai ignored atau unsupported, lalu membalas 2xx agar provider tidak terus retry.
5. Payload version berubah
Jika spec_version tidak dikenali, pilih kebijakan yang jelas:
- Strict reject dengan 4xx jika perubahan versi berarti Anda tidak bisa memproses dengan aman.
- Store and quarantine jika Anda ingin menyimpan dulu untuk investigasi manual.
Pilihannya tergantung konsekuensi bisnis, tetapi jangan diam-diam memproses payload dengan asumsi versi lama.
Observabilitas minimal untuk webhook receiver
Tanpa observabilitas, masalah webhook sulit dibedakan: apakah provider salah, signature mismatch, queue macet, atau worker gagal transisi state.
Log yang perlu ada
event_idevent_typeresource_id- hasil verifikasi signature
- status deduplikasi: baru atau duplikat
- hasil enqueue dan hasil worker
- durasi handler
Hindari menulis secret, signature mentah, atau payload sensitif penuh ke log produksi kecuali benar-benar perlu dan sudah disanitasi.
Metrik dasar
- jumlah request webhook masuk
- jumlah signature invalid
- jumlah replay/timestamp out of window
- jumlah event duplikat
- jumlah enqueue gagal
- jumlah worker gagal per event type
- usia event tertua yang belum diproses
Metrik terakhir penting untuk mendeteksi backlog queue sebelum menjadi insiden bisnis.
Contoh pemasangan route Fiber
func main() {
appState := &App{
DB: nil, // inisialisasi DB Anda
WebhookSecret: "replace-with-real-secret",
ReplayWindow: 5 * time.Minute,
}
f := fiber.New()
f.Post("/webhooks/provider-x", appState.VerifyWebhookSignature, appState.HandleWebhook)
log.Fatal(f.Listen(":3000"))
}
Pada produksi, secret sebaiknya diambil dari environment variable atau secret manager, bukan di-hardcode.
Checklist produksi untuk Go Fiber webhook receiver
- Verifikasi HMAC menggunakan raw body, bukan JSON yang di-serialize ulang.
- Gunakan timestamp tolerance untuk membatasi replay.
- Simpan
event_iddengan unique constraint. - Balas 2xx hanya setelah event tersimpan andal atau berhasil masuk mekanisme pemrosesan yang durable.
- Bedakan 4xx untuk request invalid dan 5xx untuk kegagalan sementara.
- Jalankan pemrosesan bisnis secara async setelah ack cepat.
- Pastikan worker idempoten terhadap retry internal maupun event duplikat.
- Definisikan aturan state transition agar event out-of-order tidak merusak data.
- Simpan payload mentah untuk audit dan replay internal.
- Tambahkan log terstruktur dan metrik minimum.
- Pastikan sinkronisasi waktu server berjalan baik.
- Siapkan prosedur replay internal dari inbox untuk recovery insiden.
Penutup
Membangun Go Fiber webhook receiver yang aman bukan soal menulis endpoint POST sederhana. Intinya adalah kontrak: signature HMAC untuk otentikasi, timestamp untuk mencegah replay, payload versioned agar evolusi skema terkendali, event ID unik untuk deduplikasi, serta semantik respons HTTP yang membuat retry bekerja sesuai harapan.
Di atas itu, pola store, ack, then process async memberi fondasi yang jauh lebih tahan terhadap timeout, duplikasi, dan gangguan layanan. Jika consumer Anda juga idempoten dan tidak mengasumsikan ordering sempurna, integrasi webhook akan jauh lebih stabil dalam kondisi nyata.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!