Webhook yang andal tidak cukup hanya menerima request lalu memprosesnya. Endpoint harus mampu memverifikasi keaslian payload, menahan event duplikat, aman saat provider melakukan retry, dan tetap konsisten ketika beberapa worker memproses event yang sama secara paralel. Di Rust, tantangannya bukan hanya pada implementasi HTTP, tetapi juga pada desain kontrak API, penyimpanan state dedup, dan urutan eksekusi yang aman.

Untuk kasus Rust: Webhook Idempoten dengan Signature, Retry, dan Dedup Aman, pola yang paling aman adalah: baca body mentah, verifikasi signature, cek timestamp untuk replay protection, simpan event ke mekanisme dedup atomik, beri respons cepat ke provider, lalu proses pekerjaan berat secara asynchronous. Dengan pendekatan ini, duplikasi lintas worker, timeout saat ack, dan retry dari provider dapat ditangani tanpa memproses efek samping lebih dari sekali.

Kontrak API webhook yang perlu disepakati

Sebelum menulis handler Rust, tentukan dulu kontrak minimal yang harus ada pada request webhook. Tujuannya agar verifikasi dan dedup tidak bergantung pada asumsi yang rapuh.

Field dan header yang sebaiknya tersedia

  • Raw request body: dipakai apa adanya untuk verifikasi signature. Jangan memverifikasi hasil JSON yang sudah di-normalisasi ulang.
  • Signature header: misalnya HMAC dari body menggunakan secret bersama.
  • Timestamp header: dipakai untuk membatasi usia request dan mencegah replay lama.
  • Event ID atau Idempotency-Key: identifier unik dari provider untuk dedup.
  • Event type: berguna untuk routing dan keputusan bisnis.

Jika provider tidak menyediakan event ID yang eksplisit, Anda bisa membangun kunci dedup dari kombinasi yang stabil, misalnya provider + account_id + external_reference + event_type. Hindari memakai seluruh payload sebagai identifier utama kecuali benar-benar diperlukan, karena perubahan kecil yang tidak relevan bisa menghasilkan hash berbeda.

Status code yang sebaiknya dikembalikan

  • 2xx: request valid dan sudah diterima. Untuk webhook, ini biasanya berarti provider tidak perlu retry.
  • 400: payload rusak atau field wajib tidak ada.
  • 401 atau 403: signature tidak valid atau secret salah.
  • 409: umumnya tidak perlu dipakai untuk duplikat webhook. Lebih aman tetap balas 200 atau 202 jika event sudah pernah diterima, agar provider berhenti retry.
  • 429: hanya jika Anda memang sengaja menerapkan rate limiting dan siap menerima retry.
  • 5xx: hanya jika sistem internal gagal dan Anda ingin provider mencoba lagi.

Prinsip praktis: jika event sama sudah pernah diterima dan keadaan sistem sudah konsisten, balas 2xx. Mengembalikan 4xx/5xx untuk duplikat justru mendorong retry yang tidak perlu.

Urutan handler yang aman

Kesalahan umum pada webhook adalah melakukan parsing, query, dan side effect terlalu dini. Urutan yang lebih aman adalah sebagai berikut.

  1. Baca raw body dan header penting.
  2. Verifikasi signature terhadap raw body.
  3. Validasi timestamp agar request terlalu lama ditolak.
  4. Ekstrak event ID dan metadata minimum.
  5. Lakukan dedup atomik di database atau Redis.
  6. Jika baru pertama kali diterima, simpan event/inbox lalu enqueue pekerjaan.
  7. Balas 200 OK atau 202 Accepted secepat mungkin.
  8. Proses side effect di worker yang juga tetap idempoten.

Langkah 5 dan 6 penting karena banyak duplikasi terjadi bukan karena provider jahat, tetapi karena timeout jaringan, retry otomatis, atau race condition antar worker.

Verifikasi signature dan replay protection

Mengapa harus memakai raw body

Signature webhook umumnya dihitung dari payload mentah. Jika Anda mem-parse JSON lalu men-serialize kembali, urutan key, spasi, atau format angka bisa berubah sehingga signature tidak cocok. Di Rust, pastikan body dibaca sebagai bytes dan digunakan langsung dalam perhitungan HMAC atau skema signature lain yang disyaratkan provider.

Batas waktu verifikasi timestamp

Replay protection biasanya dilakukan dengan membandingkan timestamp di header dengan waktu server. Jendela toleransi umum adalah beberapa menit, misalnya 5 menit, tetapi angka pastinya harus disesuaikan dengan provider dan sinkronisasi waktu sistem Anda. Jangan membuat jendela terlalu lebar karena memperbesar risiko replay, tetapi jangan terlalu sempit jika jam antar sistem bisa sedikit bergeser.

Hal yang perlu diperhatikan:

  • Gunakan sinkronisasi waktu server yang baik.
  • Tolak timestamp yang terlalu tua atau terlalu jauh di masa depan.
  • Catat perbedaan waktu untuk debugging jika banyak request valid tertolak.

Contoh fungsi verifikasi di Rust

Contoh berikut sengaja netral terhadap framework. Fokusnya pada alur verifikasi, bukan detail extractor.

use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

type HmacSha256 = Hmac<Sha256>;

fn verify_signature(
    secret: &[u8],
    raw_body: &[u8],
    signature_header: &str,
    timestamp_header: &str,
    max_age_secs: i64,
) -> Result<(), String> {
    let ts: i64 = timestamp_header.parse().map_err(|_| "timestamp tidak valid")?;

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|_| "waktu sistem invalid")?
        .as_secs() as i64;

    if (now - ts).abs() > max_age_secs {
        return Err("timestamp di luar jendela replay protection".into());
    }

    let signed_payload = format!("{}.{}", ts, String::from_utf8_lossy(raw_body));
    let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| "secret invalid")?;
    mac.update(signed_payload.as_bytes());
    let expected = hex::encode(mac.finalize().into_bytes());

    // Pada implementasi nyata, gunakan perbandingan constant-time jika format header mengizinkan.
    if expected != signature_header {
        return Err("signature mismatch".into());
    }

    Ok(())
}

Catatan penting:

  • Format pasti payload yang ditandatangani berbeda-beda antar provider. Ikuti dokumentasi provider; jangan mengasumsikan semua memakai timestamp.body.
  • Jika provider mengirim beberapa versi signature dalam satu header, parse semuanya lalu cocokkan salah satu yang valid.
  • Gunakan perbandingan constant-time untuk menghindari side-channel sederhana.

Idempotency key, event ID, dan strategi dedup

Pilih identifier yang stabil

Webhook idempoten biasanya bergantung pada event ID dari provider. Jika tersedia, ini pilihan terbaik. Jika tidak ada, gunakan idempotency key buatan sendiri dari atribut yang secara bisnis memang merepresentasikan kejadian yang sama.

Contoh kandidat kunci:

  • provider_event_id
  • provider + tenant_id + external_order_id + event_type
  • hash dari subset field yang memang identik untuk event yang sama

Trade-off-nya:

  • Event ID provider: paling sederhana, tetapi tergantung kualitas provider.
  • Composite business key: lebih tahan terhadap provider buruk, tetapi harus dipilih hati-hati agar tidak menggabungkan dua event berbeda menjadi satu.
  • Payload hash: mudah dibuat, tetapi rapuh terhadap perubahan field non-esensial.

Dedup berbasis database

Pendekatan yang paling mudah diaudit adalah menyimpan event ke tabel inbox atau tabel dedup dengan unique constraint. Kunci utamanya: operasi insert harus atomik, sehingga dua worker yang menerima event sama secara bersamaan tidak bisa sama-sama menang.

Skema tabel sederhana:

CREATE TABLE webhook_inbox (
    id BIGSERIAL PRIMARY KEY,
    provider VARCHAR(50) NOT NULL,
    event_id VARCHAR(191) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    received_at TIMESTAMP NOT NULL,
    signature_valid BOOLEAN NOT NULL,
    payload JSONB NOT NULL,
    status VARCHAR(30) NOT NULL,
    processed_at TIMESTAMP NULL,
    error_message TEXT NULL,
    UNIQUE (provider, event_id)
);

Alur umumnya:

  • Coba INSERT event baru.
  • Jika sukses, berarti event pertama kali diterima.
  • Jika gagal karena unique constraint, berarti event duplikat.
  • Untuk duplikat, balas 2xx dan jangan ulangi side effect.

Keuntungan database:

  • Atomicity kuat.
  • Mudah diinspeksi untuk audit.
  • Status pemrosesan bisa dilacak di tabel yang sama.

Kekurangannya:

  • Latensi sedikit lebih tinggi dibanding cache.
  • Beban write meningkat jika volume webhook besar.

Dedup berbasis Redis

Redis cocok jika throughput tinggi dan Anda ingin dedup cepat dengan TTL. Gunakan operasi atomik seperti SET key value NX EX ttl. Jika key berhasil dibuat, event dianggap baru. Jika tidak, event adalah duplikat.

Keuntungan Redis:

  • Cepat dan ringan untuk hot path.
  • Mudah diberi TTL agar state dedup tidak permanen.

Keterbatasannya:

  • Jika Redis hilang atau key kedaluwarsa terlalu cepat, dedup bisa gagal.
  • Kurang ideal sebagai satu-satunya sumber audit.
  • Perlu perhatian ekstra pada durabilitas jika event bernilai bisnis tinggi.

Pola yang sering dipakai adalah Redis untuk gerbang cepat dan database sebagai sumber kebenaran. Namun bila ingin sederhana dan kuat, database saja sudah cukup untuk banyak sistem.

Contoh alur handler di Rust

Berikut pseudocode Rust yang menggambarkan urutan aman. Detail query dan queue disederhanakan agar fokus pada ide inti.

struct WebhookMeta {
    provider: String,
    event_id: String,
    event_type: String,
    timestamp: String,
    signature: String,
}

enum InsertResult {
    Inserted,
    Duplicate,
}

async fn handle_webhook(
    headers: &HeaderMapLike,
    raw_body: Vec<u8>,
    repo: &dyn InboxRepository,
    queue: &dyn JobQueue,
    secret: &[u8],
) -> HttpResponseLike {
    let meta = match extract_meta(headers, &raw_body) {
        Ok(m) => m,
        Err(_) => return HttpResponseLike::bad_request(),
    };

    if let Err(_) = verify_signature(
        secret,
        &raw_body,
        &meta.signature,
        &meta.timestamp,
        300,
    ) {
        return HttpResponseLike::unauthorized();
    }

    let insert = match repo.insert_inbox_if_new(
        &meta.provider,
        &meta.event_id,
        &meta.event_type,
        &raw_body,
    ).await {
        Ok(r) => r,
        Err(_) => return HttpResponseLike::server_error(),
    };

    match insert {
        InsertResult::Duplicate => {
            // Event sudah pernah diterima. Balas 200 agar provider berhenti retry.
            HttpResponseLike::ok()
        }
        InsertResult::Inserted => {
            if let Err(_) = queue.enqueue(&meta.provider, &meta.event_id).await {
                // Tergantung desain, Anda bisa balas 500 agar provider retry,
                // atau simpan status pending dan andalkan worker polling/outbox.
                return HttpResponseLike::server_error();
            }
            HttpResponseLike::accepted()
        }
    }
}

Poin penting dari alur di atas:

  • Verifikasi dilakukan sebelum insert, agar payload palsu tidak memenuhi storage dedup.
  • Insert dedup harus atomik, bukan cek lalu insert dalam dua langkah terpisah.
  • Respons 202 cocok jika pekerjaan diproses asynchronous. Jika semua yang penting sudah dicatat secara aman, 200 juga sah.

Menangani retry provider dengan aman

Kenapa provider melakukan retry

Retry biasanya terjadi karena provider tidak menerima ack tepat waktu, menerima 5xx, mengalami timeout jaringan, atau tidak bisa memastikan respons Anda tersampaikan. Ini normal, bukan anomali.

Aturan praktis penanganan retry

  • Balas cepat setelah event diverifikasi dan dicatat.
  • Jangan menunggu side effect lambat seperti panggilan ke API lain sebelum ack.
  • Pastikan worker pemroses event juga idempoten, karena enqueue bisa berhasil lebih dari sekali dalam skenario tertentu.

Contoh masalah nyata:

  • Handler sudah memproses event dan menulis ke database, tetapi respons 200 timeout di jaringan. Provider mengirim ulang event yang sama.
  • Dua instance aplikasi menerima webhook yang sama hampir bersamaan karena load balancer atau retry agresif.
  • Job queue menerima enqueue dua kali karena crash setelah publish tetapi sebelum update status.

Semua kasus ini menuntut idempoten di lebih dari satu lapisan: endpoint, inbox, dan worker.

Edge case yang sering menimbulkan bug

Event datang tidak berurutan

Jangan mengasumsikan urutan delivery selalu benar. Misalnya event payment.completed bisa tiba sebelum payment.pending. Jika logika Anda bergantung pada urutan, simpan event ke inbox lalu proses dengan aturan state transition yang tahan terhadap urutan acak.

Pendekatan yang umum:

  • Gunakan versi state atau timestamp domain bila tersedia.
  • Izinkan transisi yang aman walau event lama datang belakangan.
  • Abaikan event yang lebih tua jika status domain sudah melampauinya.

Duplikasi lintas worker atau lintas node

In-memory mutex tidak cukup jika aplikasi berjalan di banyak proses atau banyak node. Dedup harus menggunakan media bersama seperti database atau Redis. Kalau tidak, dua worker berbeda bisa sama-sama menganggap event sebagai baru.

Timeout saat ack

Jika provider timeout saat menunggu respons, mereka mungkin retry meskipun pekerjaan Anda sebenarnya sudah tercatat. Karena itu:

  • Lakukan verifikasi dan dedup secepat mungkin.
  • Hindari pekerjaan berat di jalur request.
  • Atur timeout server yang realistis, tetapi jangan mengandalkannya sebagai mekanisme utama.

Race condition cek-lalu-insert

Pola berikut rawan bug:

1. SELECT apakah event_id sudah ada?
2. Jika belum ada, INSERT
3. Proses event

Dua worker bisa menjalankan langkah 1 secara bersamaan dan sama-sama melihat data belum ada. Solusinya adalah unique constraint atau operasi atomik set-if-not-exists.

Event valid tetapi side effect gagal separuh jalan

Misalnya event berhasil membuat invoice di database, tetapi gagal mengirim email atau memperbarui sistem lain. Jika worker kemudian mengulang job, operasi sebelumnya tidak boleh merusak konsistensi. Buat side effect downstream juga idempoten, misalnya dengan menyimpan external call log atau idempotency key per integrasi keluar.

Pola penyimpanan status yang disarankan

Selain tabel inbox dasar, sering kali Anda butuh status yang lebih eksplisit.

CREATE TABLE webhook_processing (
    provider VARCHAR(50) NOT NULL,
    event_id VARCHAR(191) NOT NULL,
    status VARCHAR(30) NOT NULL,
    first_seen_at TIMESTAMP NOT NULL,
    last_seen_at TIMESTAMP NOT NULL,
    attempt_count INTEGER NOT NULL DEFAULT 1,
    lock_owner VARCHAR(100) NULL,
    lock_until TIMESTAMP NULL,
    last_error TEXT NULL,
    PRIMARY KEY (provider, event_id)
);

Status yang lazim:

  • received: payload valid dan sudah dicatat.
  • processing: sedang dikerjakan worker.
  • processed: selesai sukses.
  • failed: gagal, menunggu retry internal atau investigasi.

Tabel seperti ini membantu saat:

  • ingin retry internal tanpa bergantung penuh pada retry provider,
  • ingin melihat event mana yang macet,
  • ingin menerapkan lock berbasis database pada worker.

Logging, metrik, dan debugging

Apa yang perlu dicatat di log

  • provider, event_id, event_type
  • hasil verifikasi signature
  • status dedup: inserted atau duplicate
  • latensi handler
  • status enqueue atau status proses worker
  • alasan penolakan, tanpa membocorkan secret atau payload sensitif

Gunakan structured logging agar mudah difilter. Jangan log secret, signature penuh, atau data pribadi mentah jika tidak perlu.

Metrik yang berguna

  • jumlah webhook masuk per provider dan event type
  • rasio signature invalid
  • rasio duplicate event
  • latensi verifikasi dan latensi total handler
  • jumlah retry internal worker
  • jumlah event stuck di status received atau processing

Jika rasio duplikat tiba-tiba melonjak, biasanya ada timeout ack, gangguan jaringan, atau provider sedang retry massal.

Tips debugging cepat

  • Bandingkan raw body yang diterima dengan payload yang dipakai menghitung signature.
  • Periksa drift waktu server jika banyak timestamp dianggap kedaluwarsa.
  • Pastikan event ID yang dipakai dedup benar-benar unik per provider, bukan hanya per tenant.
  • Audit query dedup agar benar-benar atomik.
  • Simulasikan dua request paralel untuk menguji race condition.

Memilih database vs Redis untuk dedup

Kapan pilih database

  • Volume masih moderat.
  • Butuh jejak audit yang jelas.
  • Ingin konsistensi kuat dengan skema sederhana.

Kapan Redis masuk akal

  • Traffic sangat tinggi dan jalur panas harus secepat mungkin.
  • Duplikasi jangka pendek lebih penting daripada penyimpanan riwayat lengkap.
  • Anda sudah memiliki strategi persistensi atau audit terpisah.

Untuk banyak sistem backend internal, database dengan unique constraint adalah titik awal terbaik karena sederhana dan aman. Redis dapat ditambahkan kemudian jika bottleneck benar-benar terbukti.

Checklist pengujian webhook idempoten

  1. Kirim request valid dengan signature benar, pastikan masuk dan diproses sekali.
  2. Kirim request yang sama dua kali, pastikan side effect hanya sekali dan respons tetap 2xx.
  3. Kirim request paralel yang sama dari beberapa koneksi, pastikan tidak ada race condition.
  4. Kirim request dengan signature salah, pastikan ditolak 401/403.
  5. Kirim request dengan timestamp terlalu lama, pastikan replay protection aktif.
  6. Simulasikan timeout setelah event tersimpan tetapi sebelum respons sampai, lalu kirim ulang event yang sama.
  7. Simulasikan kegagalan enqueue job setelah insert inbox, pastikan ada strategi pemulihan yang jelas.
  8. Uji event datang tidak berurutan, pastikan state domain tetap benar.
  9. Uji worker crash di tengah side effect, lalu retry job, pastikan hasil akhir tetap konsisten.
  10. Pastikan log dan metrik cukup untuk membedakan invalid signature, duplicate, dan internal failure.

Rekomendasi implementasi praktis

Jika Anda ingin solusi yang tidak bertele-tele tetapi aman, gunakan pola berikut:

  1. Endpoint Rust membaca raw body.
  2. Verifikasi signature dan timestamp dalam jendela beberapa menit.
  3. Ekstrak provider, event_id, dan event_type.
  4. Simpan ke tabel inbox dengan UNIQUE(provider, event_id).
  5. Jika insert sukses, enqueue job dan balas 202.
  6. Jika duplicate, balas 200.
  7. Worker memproses event dan menandai status processed, dengan side effect downstream yang juga idempoten.

Pola ini bekerja karena setiap lapisan punya tanggung jawab jelas: signature untuk autentikasi, timestamp untuk replay protection, unique constraint atau Redis untuk dedup, dan worker idempoten untuk menjaga konsistensi saat retry tak terhindarkan.

Pada akhirnya, webhook idempoten di Rust bukan soal framework tertentu, melainkan soal urutan operasi yang tepat dan jaminan atomik pada state dedup. Jika kontrak API jelas, verifikasi dilakukan pada raw body, dan dedup disimpan di media bersama yang atomik, endpoint Anda akan jauh lebih tahan terhadap duplikasi, retry provider, dan race condition produksi.