Desain webhook game event yang idempoten dan mudah di-retry perlu menjawab satu masalah utama: delivery di dunia nyata tidak pernah benar-benar sekali kirim dan pasti urut. Saat producer mengirim event seperti match.finished, inventory.updated, atau achievement.unlocked, consumer harus siap menerima event yang sama lebih dari sekali, datang terlambat, atau bahkan datang dalam urutan terbalik.

Karena itu, desain webhook yang baik tidak cukup hanya mengirim JSON ke sebuah URL. Anda perlu kontrak payload yang stabil dan versioned, verifikasi keaslian request, mekanisme deduplikasi, retry dengan backoff, serta observability minimum agar kegagalan bisa dilacak. Artikel ini berfokus pada implementasi backend nyata untuk sistem event-driven, termasuk konteks game simulation atau engine event seperti pada proyek youre-the-os, tetapi diterapkan ke integrasi produksi.

Mengapa webhook game event harus idempoten

Pada arsitektur event-driven, webhook hampir selalu dikirim dengan model at-least-once delivery. Artinya, sistem pengirim memilih untuk lebih aman mengirim ulang daripada berisiko kehilangan event. Konsekuensinya, consumer bisa menerima event yang sama dua kali atau lebih.

Ini bukan bug, melainkan sifat dasar integrasi jaringan dan job queue. Penyebab duplikasi bisa berupa:

  • timeout saat producer tidak menerima respons tepat waktu, lalu mengirim ulang,
  • worker crash setelah request terkirim tetapi sebelum status sukses tersimpan,
  • consumer memproses event sukses namun membalas 5xx atau koneksi terputus,
  • mekanisme retry otomatis dari queue atau scheduler.

Webhook disebut idempoten jika pemrosesan event yang sama berkali-kali tetap menghasilkan efek akhir yang sama. Misalnya, event currency.credited dengan ID tertentu tidak boleh menambah saldo dua kali hanya karena webhook di-retry.

Prinsip penting: producer boleh mengirim ulang event, tetapi consumer tidak boleh menerapkan efek bisnis yang sama lebih dari sekali.

Kontrak payload yang versioned

Webhook yang baik dimulai dari payload yang jelas, stabil, dan bisa berevolusi. Hindari payload yang terlalu bergantung pada struktur internal service pengirim. Buat kontrak yang eksplisit dan beri versi pada level event envelope.

Struktur payload yang disarankan

Pisahkan metadata pengiriman dari data domain. Metadata dipakai untuk keamanan, deduplikasi, debugging, dan kompatibilitas. Data domain berisi isi event bisnis.

{
  "spec_version": "2025-01",
  "event_id": "evt_01J9Y7M6J8Q4T8N2K6M1P4R5S6",
  "event_type": "match.finished",
  "occurred_at": "2026-08-09T10:15:30Z",
  "produced_at": "2026-08-09T10:15:31Z",
  "delivery_attempt": 3,
  "source": "game-simulator",
  "tenant_id": "studio_abc",
  "idempotency_key": "match:match_8921:finished:v1",
  "ordering_key": "match_8921",
  "data": {
    "match_id": "match_8921",
    "winner_player_id": "player_42",
    "duration_seconds": 913,
    "rewards": [
      {
        "type": "gold",
        "amount": 150
      }
    ]
  }
}

Field yang sebaiknya ada

  • spec_version: versi kontrak payload, bukan versi aplikasi.
  • event_id: ID unik untuk satu event yang dibuat producer sekali saja.
  • event_type: nama event yang stabil, misalnya quest.completed.
  • occurred_at: kapan kejadian bisnis benar-benar terjadi.
  • produced_at: kapan event dibuat atau dipublikasikan.
  • delivery_attempt: jumlah percobaan kirim untuk keperluan debugging.
  • source: service asal event.
  • tenant_id: penting pada sistem multi-tenant.
  • idempotency_key: kunci logis untuk mencegah efek bisnis ganda.
  • ordering_key: entitas yang dipakai untuk mempertimbangkan urutan, misalnya match_id atau player_id.
  • data: payload domain yang relevan dengan event.

Kapan menambah versi payload

Naikkan spec_version jika ada perubahan kontrak yang bisa memengaruhi parser atau semantik consumer, misalnya:

  • rename field,
  • mengubah tipe data,
  • menghapus field lama,
  • mengubah makna bisnis field yang sudah ada.

Menambah field opsional biasanya bisa tetap kompatibel tanpa versi mayor baru, asalkan consumer diharapkan toleran terhadap field tambahan.

Pilihan umum:

  • Versi di payload, misalnya spec_version.
  • Versi di header, misalnya X-Webhook-Version.
  • Versi di path endpoint, misalnya /webhooks/v2/events.

Untuk webhook, versi di payload sering paling mudah diaudit karena ikut tersimpan di log dan dead-letter queue.

At-least-once delivery dan implikasinya

Model at-least-once adalah pilihan praktis karena lebih sederhana dan lebih aman daripada berusaha menjamin exactly-once delivery lintas jaringan. Dalam sistem terdistribusi, exactly-once biasanya mahal, sempit cakupannya, atau hanya benar pada layer tertentu.

Karena itu, desain yang realistis adalah:

  1. Producer menjamin event penting akan dicoba kirim sampai batas retry tertentu.
  2. Consumer wajib idempoten.
  3. Efek samping bisnis dikendalikan melalui deduplikasi dan transaksi.

Untuk event game, ini penting pada kasus seperti:

  • pemberian reward setelah match selesai,
  • sinkronisasi inventory ke service lain,
  • pencatatan telemetry penting ke backend analitik,
  • pemicu notifikasi atau achievement.

Kesalahan umum adalah menganggap respons HTTP 200 berarti sistem hilir benar-benar sudah memproses event. Padahal bisa saja consumer membalas cepat lalu gagal memproses async di belakang. Karena itu, kontrak keberhasilan harus jelas: apakah endpoint hanya menerima untuk diproses nanti, atau benar-benar menyelesaikan efek bisnis sinkron.

Idempotency key dan deduplikasi event

Bedakan event ID dan idempotency key

Banyak implementasi hanya menyimpan event_id. Itu berguna, tetapi belum selalu cukup. Ada dua konsep berbeda:

  • event_id: identitas unik satu publikasi event.
  • idempotency_key: identitas operasi bisnis yang tidak boleh dieksekusi dua kali.

Contoh: producer mungkin membuat ulang event karena bug atau replay operasional sehingga event_id berbeda, tetapi secara bisnis itu masih operasi yang sama. Dalam kasus seperti itu, idempotency_key memberi perlindungan tambahan.

Pola penyimpanan deduplikasi

Consumer sebaiknya memiliki tabel atau storage deduplikasi. Skema minimal dapat berupa:

CREATE TABLE processed_webhook_events (
  consumer_name      VARCHAR(100) NOT NULL,
  tenant_id          VARCHAR(100) NOT NULL,
  event_id           VARCHAR(100) NOT NULL,
  idempotency_key    VARCHAR(200),
  event_type         VARCHAR(100) NOT NULL,
  processed_at       TIMESTAMP NOT NULL,
  status             VARCHAR(20) NOT NULL,
  PRIMARY KEY (consumer_name, tenant_id, event_id)
);

Jika operasi bisnis memang harus unik berdasarkan idempotency_key, tambahkan constraint unik yang sesuai, misalnya pada kombinasi (consumer_name, tenant_id, idempotency_key). Bentuk tepatnya tergantung domain Anda.

Alur consumer yang aman

  1. Terima request.
  2. Verifikasi signature dan timestamp.
  3. Parse payload dan validasi field wajib.
  4. Cek apakah event_id sudah pernah diproses.
  5. Jika belum, jalankan efek bisnis dalam transaksi.
  6. Simpan catatan processed/deduplicated.
  7. Balas 2xx hanya jika event sudah diterima sesuai kontrak.

Untuk operasi kritis seperti kredit saldo, lebih aman jika pencatatan dedupe dan perubahan state bisnis terjadi dalam transaksi database yang sama. Ini mencegah kondisi di mana reward sudah diberikan, tetapi marker dedupe gagal ditulis.

Contoh pseudo-code consumer

verifySignature(request)
verifyTimestamp(request)
payload = parseJson(request.body)
validate(payload)

begin transaction
  if exists(processed_webhook_events where consumer_name=? and tenant_id=? and event_id=?):
      commit
      return 200

  if payload.idempotency_key is not null:
      if exists(business_effects where tenant_id=? and idempotency_key=?):
          insert processed_webhook_events(status='deduplicated')
          commit
          return 200

  applyBusinessEffect(payload)
  insert processed_webhook_events(status='processed')
commit

return 200

Respons 200 pada event duplikat adalah perilaku yang benar. Jangan membalas 409 hanya karena event sudah pernah diproses, karena itu justru bisa memicu retry tak perlu dari producer.

Signature verification dan timestamp tolerance

Webhook tanpa verifikasi kriptografis mudah dipalsukan. Header rahasia statis saja tidak cukup jika tidak disertai penandatanganan payload. Praktik yang umum adalah producer menghitung HMAC dari body mentah menggunakan shared secret, lalu consumer memverifikasinya.

Contoh header

X-Webhook-Id: evt_01J9Y7M6J8Q4T8N2K6M1P4R5S6
X-Webhook-Timestamp: 1723198531
X-Webhook-Signature: v1=4f7c2d...

Apa yang ditandatangani

Praktik aman adalah menandatangani gabungan timestamp dan raw body, misalnya:

signed_payload = timestamp + "." + raw_body
signature = HMAC_SHA256(secret, signed_payload)

Mengapa memakai raw body, bukan JSON hasil parsing? Karena serialisasi ulang bisa mengubah spasi, urutan key, atau representasi karakter sehingga verifikasi jadi rapuh.

Timestamp tolerance

Tambahkan toleransi waktu untuk mengurangi risiko replay attack. Misalnya, consumer menolak request jika selisih timestamp dengan waktu server melebihi beberapa menit. Nilai tepatnya tergantung karakteristik jaringan dan SLA Anda, tetapi jangan dibuat terlalu longgar.

Yang perlu diperhatikan:

  • Jam server producer dan consumer harus tersinkronisasi dengan baik.
  • Toleransi terlalu kecil bisa memicu false reject jika ada delay jaringan atau queue.
  • Toleransi terlalu besar memperlebar jendela replay.

Jika event bisa transit cukup lama karena antrian internal, bedakan antara timestamp signature dan occurred_at. Signature timestamp dipakai untuk validasi keamanan request; occurred_at dipakai untuk logika bisnis.

Contoh pseudo-code verifikasi

timestamp = request.header["X-Webhook-Timestamp"]
signature = request.header["X-Webhook-Signature"]
rawBody = request.rawBody

if abs(nowEpochSeconds() - timestamp) > toleranceSeconds:
    reject 401

expected = hmac_sha256(secret, timestamp + "." + rawBody)
if !constantTimeEquals(expected, signatureValue(signature)):
    reject 401

Gunakan perbandingan constant-time untuk menghindari kebocoran timing sederhana pada pemeriksaan signature.

Retry dengan backoff yang realistis

Jika producer gagal mengirim event, retry adalah mekanisme wajib. Namun retry tanpa strategi justru bisa memperburuk outage di sisi consumer. Gunakan exponential backoff, idealnya dengan jitter, agar beban tersebar dan tidak memukul endpoint sekaligus.

Pola retry yang umum

  • Retry untuk kegagalan jaringan, timeout, dan respons 5xx.
  • Biasanya jangan retry untuk 2xx.
  • 4xx perlu dibedakan: 401 bisa berarti konfigurasi salah, 404 endpoint tidak ada, 429 bisa layak retry jika consumer memang melakukan rate limit.

Jangan menyamakan semua 4xx sebagai final failure tanpa analisis. Misalnya, 408 atau 409 pada desain tertentu mungkin masih bisa ditangani berbeda. Tetapi untuk webhook, kontrak yang paling sederhana adalah: consumer mengembalikan 2xx bila event diterima, dan 5xx bila producer perlu retry.

Backoff dengan jitter

Daripada delay tetap seperti 5 detik terus-menerus, gunakan pendekatan seperti:

attempt 1: ~1s
attempt 2: ~2s
attempt 3: ~4s
attempt 4: ~8s
attempt 5: ~16s
... + random jitter

Jitter mencegah ribuan event gagal melakukan retry pada detik yang sama. Ini relevan pada sistem game yang menghasilkan lonjakan event saat turnamen selesai atau batch simulasi berakhir.

Kapan berhenti retry

Tetapkan batas retry dan kirim event yang gagal terus-menerus ke dead-letter queue atau tabel gagal kirim. Tanpa ini, Anda hanya memindahkan masalah ke antrean tak berujung.

Data minimum yang perlu disimpan untuk event gagal permanen:

  • event_id, event_type, tenant_id,
  • payload asli,
  • jumlah attempt,
  • respons terakhir atau error terakhir,
  • waktu gagal terakhir.

Dengan begitu tim operasi bisa melakukan replay terkontrol setelah perbaikan.

Urutan event bisa terbalik

Salah satu sumber bug paling sering pada webhook game event adalah asumsi bahwa event akan diterima sesuai urutan kejadian. Dalam praktiknya, event A bisa dibuat lebih dulu tetapi diterima setelah event B karena perbedaan jalur antrean, retry, atau latensi.

Contoh:

  1. match.started
  2. match.finished

Secara logis urutannya jelas, tetapi consumer bisa menerima match.finished lebih dulu. Jika desain consumer mengharuskan match.started sudah ada, pemrosesan bisa gagal atau menciptakan state rusak.

Strategi menghadapi out-of-order event

  • Buat operasi berbasis state final: jika memungkinkan, event membawa cukup data untuk membentuk state akhir tanpa bergantung pada event sebelumnya.
  • Gunakan ordering_key: minimal untuk observability dan pengelompokan replay per entitas.
  • Simpan sequence number per entitas jika producer memang punya sumber urutan yang andal.
  • Terapkan compare-and-set berdasarkan versi state atau timestamp domain.
  • Parkir event sementara jika prasyarat belum ada, lalu coba lagi.

Contoh pola sequence

Jika event untuk satu match_id bisa diberi nomor urut dari producer, consumer dapat menolak atau menunda event dengan sequence yang melompat terlalu jauh. Namun ini menambah kompleksitas dan butuh sumber kebenaran urutan yang konsisten. Jangan menambah sequence jika producer sendiri tidak bisa menjaminnya.

Sering kali pendekatan yang lebih praktis adalah mendesain handler agar toleran terhadap urutan terbalik. Misalnya, saat menerima match.finished, consumer dapat membuat record match jika belum ada, lalu menandainya selesai. Event match.started yang datang belakangan cukup diabaikan atau dipakai untuk melengkapi metadata yang belum ada.

Observability minimum yang wajib ada

Webhook sulit di-debug jika Anda hanya punya log teks acak. Minimal, producer dan consumer harus memiliki jejak observability yang sama-sama bisa dicari dengan event_id.

Log terstruktur

Setidaknya log field berikut:

  • event_id
  • event_type
  • tenant_id
  • delivery_attempt
  • http_status
  • target_endpoint atau consumer_name
  • latency_ms
  • dedupe_hit
  • signature_valid

Metric minimum

  • jumlah event terkirim per tipe,
  • success rate per endpoint,
  • retry count,
  • dead-letter count,
  • deduplication hit rate,
  • p95 atau p99 latency pengiriman,
  • jumlah event ditolak karena signature/timestamp.

Tracing jika tersedia

Jika stack Anda mendukung distributed tracing, teruskan correlation ID dari producer ke consumer. Ini sangat membantu saat event diproduksi oleh engine simulasi, masuk ke queue, dikirim lewat webhook, lalu memicu job lanjutan di service lain.

Praktik sederhana yang sering menyelesaikan banyak masalah: jadikan event_id sebagai field wajib di semua log, metric label utama secukupnya, dan kunci pencarian pada dashboard operasi.

Tabel failure mode vs mitigasi

Failure modeDampakMitigasi yang disarankan
Request timeout saat producer mengirimProducer tidak tahu event sudah diproses atau belumGunakan at-least-once delivery, retry dengan backoff, dan consumer idempoten
Event terkirim dua kaliEfek bisnis ganda, misalnya reward dobelSimpan event_id, gunakan idempotency_key, buat constraint unik di storage bisnis
Payload dipalsukanState sistem bisa dimanipulasi pihak luarVerifikasi HMAC signature atas raw body, secret rotation, constant-time compare
Replay request lamaEvent valid diputar ulang di luar konteks waktunyaPeriksa timestamp signature dengan tolerance terbatas dan cache nonce/event bila perlu
Event datang tidak berurutanState sementara tidak valid atau update ditolakDesain handler toleran out-of-order, gunakan ordering_key, sequence jika tersedia
Consumer lambat atau downRetry menumpuk dan antrean membesarBackoff + jitter, batas retry, dead-letter queue, alerting
Schema berubah tanpa kompatibilitasConsumer gagal parse atau salah tafsir eventVersioned payload, validasi kontrak, rollout bertahap, dukung backward compatibility
Dedupe marker tersimpan tetapi efek bisnis gagalEvent dianggap selesai padahal state belum berubahSatukan dedupe dan efek bisnis dalam satu transaksi bila memungkinkan
Efek bisnis sukses tetapi dedupe marker gagalRetry berikutnya mengeksekusi ulang efek bisnisGunakan transaksi atomik, atau constraint unik langsung pada tabel efek bisnis
Clock skew antar serverRequest valid ditolak oleh timestamp checkSinkronisasi waktu server, tolerance wajar, monitor reject karena skew

Checklist implementasi producer-consumer

Checklist producer

  • Definisikan envelope payload yang stabil dan versioned.
  • Generate event_id unik untuk setiap event.
  • Tentukan idempotency_key untuk operasi bisnis yang sensitif.
  • Sertakan occurred_at, produced_at, dan delivery_attempt.
  • Tandatangani request menggunakan HMAC atas timestamp dan raw body.
  • Terapkan retry dengan exponential backoff dan jitter.
  • Batasi total retry dan kirim ke dead-letter queue jika gagal permanen.
  • Simpan payload asli dan respons terakhir untuk kebutuhan replay/debugging.
  • Catat metric keberhasilan, retry, dan dead-letter per endpoint.

Checklist consumer

  • Terima dan simpan raw body sebelum parsing.
  • Verifikasi signature dan timestamp sebelum memproses payload.
  • Validasi schema dan field wajib.
  • Simpan marker deduplikasi berdasarkan event_id dan/atau idempotency_key.
  • Pastikan handler idempoten terhadap event yang sama.
  • Gabungkan dedupe dan efek bisnis dalam transaksi bila memungkinkan.
  • Kembalikan 2xx untuk event yang sudah pernah diproses.
  • Tangani out-of-order event dengan strategi yang disepakati.
  • Log semua langkah penting dengan event_id sebagai korelasi utama.
  • Siapkan dashboard dan alert untuk reject, retry, latency, dan dedupe hit.

Contoh keputusan desain yang masuk akal

Jika Anda sedang membangun backend untuk simulasi game atau event engine yang memicu banyak perubahan state, pendekatan berikut biasanya seimbang antara sederhana dan aman:

  • Gunakan payload envelope tetap dengan spec_version, event_id, event_type, occurred_at, idempotency_key, dan data.
  • Producer mengirim dengan at-least-once delivery.
  • Consumer menyimpan event yang telah diproses di database relasional dengan indeks unik.
  • Untuk operasi finansial atau reward, idempotensi tidak hanya di layer webhook, tetapi juga di tabel domain yang menerima efek bisnis.
  • Semua request ditandatangani HMAC dengan validasi timestamp.
  • Retry dilakukan dengan exponential backoff plus jitter, lalu gagal permanen masuk dead-letter queue.
  • Event tidak diasumsikan urut; handler didesain tahan terhadap event terlambat atau terbalik.

Ini bukan satu-satunya desain yang mungkin, tetapi cukup kuat untuk banyak integrasi backend nyata tanpa mengejar kompleksitas exactly-once yang sulit dibenarkan.

Penutup

Desain webhook game event yang idempoten dan mudah di-retry pada dasarnya adalah soal menerima kenyataan sistem terdistribusi: event bisa duplikat, bisa terlambat, bisa gagal diverifikasi, dan bisa datang tidak urut. Solusi praktisnya adalah menggabungkan kontrak payload yang versioned, at-least-once delivery, idempotency key, deduplikasi yang konsisten, verifikasi signature, timestamp tolerance, retry dengan backoff, dan observability minimum.

Jika Anda hanya mengambil satu pelajaran dari artikel ini, ambil yang ini: anggap setiap webhook bisa dikirim ulang kapan saja, lalu desain consumer agar tetap aman. Dengan prinsip itu, integrasi event-driven untuk game atau sistem simulasi akan jauh lebih tahan terhadap kegagalan nyata di produksi.