Webhook aman saat payload terenkripsi menuntut perubahan cara kita mendesain kontrak API. Jika sebagian instruksi atau data bisnis di dalam body tidak lagi dapat diinspeksi dengan mudah, maka verifikasi, idempotency, retry, dan debugging harus bertumpu pada field metadata yang stabil, bukan pada isi payload yang berubah-ubah atau tersembunyi.

Masalah utamanya sederhana: webhook hampir pasti akan mengalami retry, timeout, duplikasi, atau urutan kirim yang tidak ideal. Ketika payload terenkripsi, consumer tidak bisa lagi mengandalkan pencocokan isi body untuk deduplikasi atau analisis insiden. Solusinya adalah membedakan dengan tegas antara envelope yang stabil untuk transport dan verifikasi, dan payload yang boleh terenkripsi, opaque, atau berubah implementasinya tanpa merusak integrasi.

Kenapa payload terenkripsi mengubah desain webhook

Pada webhook biasa, tim sering memakai isi payload untuk banyak hal sekaligus: validasi, deduplikasi, logging, dan debugging. Pendekatan itu rapuh. Begitu sebagian payload dienkripsi, beberapa asumsi lama langsung gagal:

  • Body tidak lagi mudah dibaca manusia, sehingga log mentah tidak membantu investigasi.
  • Struktur internal bisa berubah tanpa terlihat oleh consumer, terutama jika field sensitif atau instruksi internal dibungkus dalam blob terenkripsi.
  • Signature berbasis body mentah tetap bisa valid, tetapi verifikasi bisnis tidak boleh bergantung pada field yang tak lagi stabil.
  • Retry dan deduplikasi jadi lebih sulit jika sistem lama mengandalkan hash dari payload terdekripsi atau subset field bisnis.

Karena itu, desain yang sehat memisahkan dua lapisan:

  • Transport envelope: metadata yang stabil, dapat diverifikasi, dan aman untuk diproses tanpa perlu membuka payload.
  • Protected payload: bagian isi yang terenkripsi atau opaque, diproses hanya oleh pihak yang memiliki kunci/decryptor yang tepat.

Prinsip kontrak API: field mana yang harus stabil

Field stabil adalah fondasi integrasi. Producer dan consumer harus sepakat bahwa field-field ini dapat dipakai untuk verifikasi, retry, observability, dan kompatibilitas jangka panjang.

Field minimum yang sebaiknya ada di envelope

  • event_id: ID unik per event untuk deduplikasi global.
  • event_type: tipe event yang stabil, misalnya task.updated atau message.completed.
  • occurred_at: waktu kejadian bisnis, bukan waktu pengiriman semata.
  • delivery_id: ID unik per upaya pengiriman. Satu event bisa punya banyak delivery.
  • schema_version: versi kontrak envelope.
  • idempotency_key: kunci untuk memastikan pemrosesan aman terhadap retry.
  • timestamp: waktu penandatanganan atau pengiriman untuk mitigasi replay attack.
  • signature: tanda tangan kriptografis atas komponen yang disepakati.
  • key_id: identitas kunci publik/secret yang dipakai untuk verifikasi signature.
  • payload_ref atau payload: isi protected payload, bisa inline atau berupa referensi.
  • payload_encoding: misalnya json, base64, atau format lain yang relevan.
  • payload_encryption: metadata enkripsi yang tidak membocorkan isi, misalnya algoritma dan key identifier.

Yang penting: consumer harus bisa memutuskan terima/tolak/tunda/retry hanya dari envelope dan hasil verifikasi, tanpa harus selalu memahami isi terenkripsi.

Field yang jangan dijadikan dasar kontrak stabil

  • Urutan field JSON dalam body.
  • Representasi string hasil serialisasi dari objek internal.
  • Hash dari payload terdekripsi jika tidak semua consumer punya akses dekripsi.
  • Field bisnis sensitif yang bisa dipindah ke blob terenkripsi di masa depan.

Contoh kontrak request webhook yang lebih tahan perubahan

Berikut contoh request yang memisahkan metadata stabil dari payload yang dilindungi.

POST /webhooks/events HTTP/1.1
Host: consumer.example.com
Content-Type: application/json
User-Agent: producer-webhooks
X-Webhook-Event-Id: evt_01JABCXYZ
X-Webhook-Delivery-Id: del_01JABCXYZ_03
X-Webhook-Timestamp: 2026-07-14T10:15:30Z
X-Webhook-Signature: v1,t=2026-07-14T10:15:30Z,sig=BASE64_SIGNATURE
X-Webhook-Key-Id: key_2026_07
X-Webhook-Schema-Version: 2026-07

{
  "event_id": "evt_01JABCXYZ",
  "delivery_id": "del_01JABCXYZ_03",
  "event_type": "agent.prompt.updated",
  "occurred_at": "2026-07-14T10:15:10Z",
  "timestamp": "2026-07-14T10:15:30Z",
  "schema_version": "2026-07",
  "idempotency_key": "evt_01JABCXYZ",
  "subject": {
    "type": "agent_session",
    "id": "as_789"
  },
  "payload_encoding": "base64",
  "payload_encryption": {
    "alg": "declared-out-of-band",
    "key_id": "enc_key_12"
  },
  "payload": "BASE64_OPAQUE_BLOB",
  "trace": {
    "trace_id": "7d1c1b7c2b7f4b6e",
    "correlation_id": "req_123456"
  }
}

Poin penting dari contoh di atas:

  • event_id dipakai untuk deduplikasi event lintas retry.
  • delivery_id membedakan setiap upaya kirim sehingga observability lebih akurat.
  • schema_version merujuk pada envelope, bukan isi payload terenkripsi.
  • subject memberi konteks minimum tanpa membuka isi sensitif.
  • trace membantu debugging end-to-end.

Apa yang sebaiknya ditandatangani

Signature harus dihitung dari representasi yang jelas dan konsisten. Jangan menandatangani hasil serialisasi JSON yang ambigu jika canonicalization tidak didefinisikan dengan ketat. Lebih aman menandatangani string kanonik yang dibangun dari field-field envelope dan body mentah dalam bentuk yang telah disepakati.

Contoh konsep string yang ditandatangani:

{timestamp}\n{event_id}\n{delivery_id}\n{schema_version}\n{raw_body_sha256}

Dengan pola ini:

  • Consumer dapat memverifikasi keaslian request walau tidak bisa membaca payload.
  • Perubahan urutan field JSON tidak otomatis merusak verifikasi, selama raw_body_sha256 dihitung dari body persis yang diterima.
  • Replay attack dapat dibatasi dengan validasi timestamp.

Jika body melewati proxy atau middleware yang memodifikasi whitespace, line ending, atau encoding, verifikasi berbasis body mentah bisa gagal. Simpan dan verifikasi raw request body sebelum parsing JSON.

Idempotency, retry, dan deduplikasi saat payload tidak bisa diinspeksi

Bedakan event dan delivery

Kesalahan umum adalah memakai satu ID untuk dua hal sekaligus. Dalam sistem webhook yang matang:

  • event_id mengidentifikasi fakta bisnis yang sama.
  • delivery_id mengidentifikasi percobaan kirim tertentu.

Jika event yang sama dikirim ulang karena timeout, event_id tetap sama, tetapi delivery_id berubah. Ini penting untuk:

  • deduplikasi pemrosesan bisnis berdasarkan event_id, dan
  • analisis reliabilitas transport berdasarkan delivery_id.

Strategi idempotency di consumer

Consumer sebaiknya menyimpan status pemrosesan per idempotency_key atau event_id dalam storage yang mendukung operasi atomik. Contoh sederhana dengan tabel database:

CREATE TABLE webhook_inbox (
  event_id VARCHAR(64) PRIMARY KEY,
  event_type VARCHAR(128) NOT NULL,
  first_delivery_id VARCHAR(64) NOT NULL,
  status VARCHAR(32) NOT NULL,
  received_at TIMESTAMP NOT NULL,
  processed_at TIMESTAMP NULL,
  last_error TEXT NULL
);

Alur amannya:

  1. Verifikasi signature dan timestamp.
  2. Coba insert event_id ke inbox secara atomik.
  3. Jika insert berhasil, lanjut proses.
  4. Jika sudah ada, anggap duplikat dan kembalikan respons sukses yang sesuai.

Pola ini lebih aman daripada membaca dulu lalu menulis, karena race condition bisa terjadi saat dua retry masuk hampir bersamaan.

Kapan memakai idempotency_key terpisah dari event_id

Jika satu event logis bisa menghasilkan beberapa variasi transport, event_id saja sering cukup. Namun ada kasus di mana producer ingin menyatukan beberapa representasi sebagai operasi yang setara. Di situ idempotency_key terpisah berguna. Tetapkan aturan yang sederhana dan terdokumentasi, misalnya:

  • event_id unik secara global untuk satu event.
  • idempotency_key opsional, tetapi jika ada, consumer harus memprioritaskannya untuk semantik deduplikasi bisnis.

Retry policy: producer dan consumer harus selaras

Retry webhook tidak bisa dihindari. Timeout jaringan, deploy rolling, dan gangguan sementara akan terjadi. Karena itu:

  • Producer harus menganggap timeout atau respons 5xx sebagai kandidat retry.
  • Consumer harus menganggap request bisa datang lebih dari sekali.
  • 4xx sebaiknya dipakai hanya untuk kesalahan permanen, misalnya signature invalid atau schema tidak didukung.
  • 202 Accepted cocok jika consumer hanya mengakui penerimaan dan memproses asinkron di belakang.

Hindari kebijakan retry yang tidak jelas. Dokumentasikan minimal:

  • respons apa yang dianggap sukses,
  • respons apa yang memicu retry,
  • batas waktu tanda tangan/timestamp,
  • berapa lama event ID disimpan untuk deduplikasi.

Response contract yang membantu producer mengambil keputusan

Response webhook sebaiknya sederhana, konsisten, dan mudah dipakai untuk automasi.

Contoh respons sukses sinkron

HTTP/1.1 200 OK
Content-Type: application/json

{
  "received": true,
  "event_id": "evt_01JABCXYZ",
  "status": "processed"
}

Contoh respons sukses asinkron

HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "received": true,
  "event_id": "evt_01JABCXYZ",
  "status": "queued"
}

Contoh respons duplikat yang tetap dianggap sukses

HTTP/1.1 200 OK
Content-Type: application/json

{
  "received": true,
  "event_id": "evt_01JABCXYZ",
  "status": "duplicate_ignored"
}

Contoh respons kesalahan permanen

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "received": false,
  "error_code": "invalid_signature",
  "message": "Signature verification failed"
}

Producer akan lebih mudah bertindak jika error_code stabil dan terdokumentasi. Jangan bergantung pada string pesan bebas sebagai kontrak.

Observability dan debugging ketika isi payload opaque

Saat payload tidak bisa dibaca penuh, observability harus dirancang sejak awal. Tanpa itu, tim operasi akan kesulitan membedakan masalah keamanan, kontrak, atau infrastruktur.

Apa yang wajib dicatat di log

  • event_id
  • delivery_id
  • event_type
  • schema_version
  • timestamp dan hasil validasinya
  • key_id untuk signature
  • hasil verifikasi signature
  • status deduplikasi: baru atau duplikat
  • latensi penerimaan dan latensi pemrosesan
  • trace_id atau correlation_id

Yang tidak perlu dicatat:

  • payload terenkripsi mentah jika ukurannya besar dan sensitif, kecuali benar-benar diperlukan dan ada kontrol akses ketat,
  • hasil dekripsi penuh ke log aplikasi umum,
  • secret, private key, atau material kriptografi.

Teknik debugging yang lebih aman

  • Gunakan fingerprint payload, misalnya hash body mentah, untuk menghubungkan insiden tanpa membuka isi.
  • Simpan sampel terenkripsi di storage terpisah dengan retensi singkat jika kebutuhan forensik ada.
  • Tambahkan endpoint status internal untuk melacak event_id sampai tahap pemrosesan tertentu.
  • Korelasikan dengan trace dari producer sampai worker consumer.

Jika dekripsi dilakukan di service lain, pisahkan log transport dan log dekripsi. Ini membantu mengetahui apakah kegagalan terjadi sebelum atau sesudah pembukaan payload.

Metrik yang paling berguna

  • jumlah event diterima per event_type,
  • rasio verifikasi signature gagal,
  • rasio event duplikat,
  • rasio retry per producer endpoint,
  • umur event saat diproses,
  • latensi queue dan pemrosesan akhir,
  • jumlah event yang gagal didekripsi.

Checklist implementasi producer dan consumer

Checklist producer

  • Tentukan envelope yang stabil dan dokumentasikan field wajibnya.
  • Pastikan event_id stabil lintas retry.
  • Buat delivery_id baru untuk setiap percobaan kirim.
  • Tandatangani request dengan mekanisme yang konsisten dan dapat diverifikasi dari raw body.
  • Sertakan timestamp dan batas toleransinya.
  • Gunakan schema_version untuk envelope.
  • Jangan memindahkan field yang selama ini dipakai untuk deduplikasi ke payload terenkripsi tanpa metadata pengganti.
  • Dokumentasikan retry policy dan arti HTTP status code.
  • Sediakan dokumentasi error code yang stabil.
  • Siapkan rotasi kunci dengan key_id.

Checklist consumer

  • Ambil raw request body sebelum parsing.
  • Verifikasi signature sebelum memproses.
  • Tolak request dengan timestamp terlalu lama atau terlalu jauh dari waktu server jika kebijakan replay protection mengharuskan itu.
  • Simpan event_id atau idempotency_key secara atomik untuk deduplikasi.
  • Proses event secara asinkron jika pekerjaan bisnis berat.
  • Kembalikan 2xx untuk duplikat yang aman diabaikan.
  • Pisahkan error permanen dan sementara.
  • Catat metadata penting tanpa membocorkan payload sensitif.
  • Uji skenario retry, timeout, duplikasi, dan out-of-order delivery.

Jebakan umum yang sering muncul

1. Menganggap encrypt berarti signature tidak perlu

Enkripsi dan signature memecahkan masalah berbeda. Enkripsi menjaga kerahasiaan, sedangkan signature memastikan integritas dan autentikasi pengirim. Webhook dengan payload terenkripsi tetap perlu signature.

2. Memakai payload hash sebagai satu-satunya kunci deduplikasi

Ini bermasalah jika payload bisa berubah bentuk serialisasinya, dikompresi, dibungkus ulang, atau tidak dapat didekripsi di semua consumer. Gunakan event_id yang eksplisit.

3. Mengembalikan 500 untuk kesalahan permanen

Jika signature salah atau schema tidak didukung, respons 5xx hanya memicu retry yang sia-sia. Gunakan 4xx yang tepat dengan error_code stabil.

4. Tidak membedakan schema envelope dan schema payload

Envelope bisa tetap kompatibel walau isi payload terenkripsi berkembang. Jika dua hal ini dicampur, migrasi akan lebih sulit dan breaking change lebih mudah terjadi.

5. Logging payload sensitif saat debugging darurat

Ini sering terjadi ketika tim frustrasi dengan blob terenkripsi. Siapkan prosedur debugging yang aman sebelum insiden nyata terjadi.

Strategi migrasi tanpa breaking change

Perubahan ke payload terenkripsi sering dilakukan pada sistem yang sudah berjalan. Kuncinya adalah mempertahankan kontrak metadata yang lama tetap hidup selama masa transisi.

Pendekatan migrasi bertahap

  1. Tambahkan envelope metadata lebih dulu jika sebelumnya belum lengkap, misalnya event_id, delivery_id, schema_version, dan key_id.
  2. Pertahankan field lama yang dipakai consumer selama masa kompatibilitas.
  3. Perkenalkan payload terenkripsi sebagai field baru, misalnya payload atau protected_payload, tanpa langsung menghapus field lama.
  4. Tambahkan header signature versi baru jika format signing berubah.
  5. Biarkan consumer mengadopsi verifikasi baru terlebih dahulu sebelum field plaintext lama dihentikan.
  6. Gunakan schema_version untuk menandai fase migrasi secara eksplisit.

Contoh transisi kompatibel

Fase awal:

{
  "event_id": "evt_01",
  "event_type": "agent.prompt.updated",
  "schema_version": "2026-07",
  "prompt_summary": "sanitized summary",
  "payload": "BASE64_OPAQUE_BLOB"
}

Di fase ini, prompt_summary masih tersedia sebagai metadata non-sensitif untuk consumer lama, sementara data detail sudah dipindah ke payload terenkripsi. Setelah semua consumer selesai migrasi, field lama dapat ditandai deprecated lalu dihapus pada versi schema berikutnya.

Kapan perlu endpoint atau topik baru

Jika semantik webhook berubah drastis, misalnya dari event-level menjadi command-level, atau jika proses dekripsi membutuhkan alur penerimaan berbeda, mempertahankan endpoint lama bisa lebih berisiko daripada menambah endpoint baru. Migrasi kompatibel tidak berarti semua perubahan harus dipaksakan ke kontrak lama.

Contoh alur pemrosesan end-to-end

  1. Producer membuat event bisnis dan menetapkan event_id.
  2. Producer membungkus data sensitif ke protected payload.
  3. Producer membangun envelope stabil, menghitung signature, lalu mengirim request.
  4. Consumer menerima raw body dan header, lalu memverifikasi signature dan timestamp.
  5. Consumer menyimpan event_id di inbox deduplikasi secara atomik.
  6. Jika baru, consumer mengantrekan pekerjaan untuk dekripsi/pemrosesan lanjutan.
  7. Jika duplikat, consumer mengembalikan 200 atau 202 tanpa memproses ulang efek bisnis.
  8. Worker melakukan dekripsi jika berwenang, lalu memproses logika bisnis.
  9. Semua tahap mencatat event_id, delivery_id, dan trace_id untuk observability.

Penutup

Desain webhook aman saat payload terenkripsi bukan soal menaruh ciphertext ke body dan selesai. Kontrak API harus sengaja dirancang agar tetap dapat diverifikasi, di-retry, dideduplikasi, dan di-debug tanpa bergantung pada kemampuan membaca isi payload penuh. Cara paling aman adalah menjaga envelope tetap stabil: event_id, delivery_id, signature, timestamp, schema_version, dan metadata minimum lain yang cukup untuk operasi.

Jika producer dan consumer sepakat pada prinsip ini, enkripsi dapat ditambahkan tanpa membuat integrasi rapuh. Hasilnya bukan hanya lebih aman, tetapi juga lebih mudah dioperasikan ketika gangguan jaringan, retry, dan duplikasi benar-benar terjadi.