Webhook API & Verifikasi HMAC Vivoldi
Integrasi Webhook yang aman dimulai dari verifikasi signature melalui HTTP Header.
Setiap request Webhook dari Vivoldi menyertakan header seperti X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature.
Validasi header ini membantu mencegah request palsu dan memastikan event tautan, kupon, serta stamp dapat diproses dengan aman.
Panduan ini menjelaskan fungsi setiap header, alur verifikasi HMAC, serta contoh implementasi menggunakan Java, PHP, dan Node.js.
HTTP Header
Webhook Vivoldi mengirimkan request HTTP POST ke Callback URL yang telah terdaftar.
Setiap request menyertakan header khusus berisi signature, timestamp, dan event identifier untuk membantu memverifikasi asal request serta memastikan integritas payload.
HTTP Header
X-Vivoldi-Request-Id: e2ea0405b7ba4f0b9b75797179731ae0
X-Vivoldi-Event-Id: 89365c75dae740ac8500dfc48c5014b5
X-Vivoldi-Webhook-Type: GLOBAL
X-Vivoldi-Resource-Type: URL
X-Vivoldi-Action-Type: CLICK
X-Vivoldi-Comp-Idx: 50742
X-Vivoldi-Timestamp: 1758184391752
X-Content-SHA256: e040abf9ac2826bc108fce0117e49290086743733ad9db2fa379602b4db9792c
X-Vivoldi-Signature: t=1758184391752,v1=b610f699d4e7964cdb7612111f5765576920b680e7c33c649e20608406807aaf,alg=hmac-sha256
Request Parameters
- X-Vivoldi-Request-Id string
- ID unik untuk mengidentifikasi permintaan. ID baru dibuat untuk setiap permintaan HTTP dan dapat digunakan untuk melacak permintaan tertentu.
- X-Vivoldi-Event-Id string
- ID unik untuk mengidentifikasi event. Event ID yang sama tetap digunakan saat event dikirim ulang, sehingga sistem penerima dapat mencegah pemrosesan event duplikat.
- X-Vivoldi-Webhook-Type string
- Default:GLOBAL
-
Enum:
GLOBALGROUP
-
Menunjukkan cakupan penerapan Webhook.
GROUP: Digunakan ketika Webhook grup diterapkan.
Event stamp hanya mendukung Webhook grup, sehingga selalu dikirim sebagaiGROUP.
Event tautan dan kupon dikirim sebagaiGLOBALjika Webhook grup tidak dikonfigurasi. - X-Vivoldi-Resource-Type string
-
Enum:
URLCOUPONSTAMP
-
Jenis resource yang terkait dengan event.
URL: URL pendek
COUPON: Kupon
STAMP: Stempel - X-Vivoldi-Action-Type string
-
Enum:
CLICKUSEADDREMOVE
-
Jenis tindakan yang memicu event.
CLICK: Klik tautan
USE: Penggunaan kupon, penggunaan reward stamp
ADD: Stempel ditambahkan
REMOVE: Stempel dihapusGunakan bersama
Resource-Typeuntuk mengidentifikasi jenis event dengan tepat. - X-Vivoldi-Comp-Idx integer
- IDX identitas organisasi. Dapat ditemukan pada halaman [Pengaturan → Pengaturan Organisasi].
- X-Vivoldi-Timestamp integer
- Waktu pembuatan permintaan. Dikirim dalam format UNIX epoch seconds. Disarankan toleransi waktu dalam ±5 menit untuk memperhitungkan perbedaan waktu antarserver.
- X-Content-SHA256 string
- Nilai hash SHA-256 dari payload permintaan. Dapat digunakan untuk memverifikasi integritas payload.
- X-Vivoldi-Signature string
-
Informasi signature untuk memverifikasi permintaan.
Berisi
t: timestamp,v1: nilai signature, danalg: algoritma signature.
Pengiriman Webhook, Respons & Kebijakan Retry
Webhook Vivoldi memiliki aturan yang jelas terkait respons sukses, retry otomatis, dan penonaktifan endpoint untuk memastikan pengiriman event yang andal.
Memahami kebijakan ini membantu mencegah pemrosesan duplikat dan mengurangi risiko kehilangan event.
Kriteria Sukses
Keberhasilan permintaan Webhook ditentukan berdasarkan kode status HTTP yang dikembalikan oleh server penerima.
-
Respons HTTP 2xx dianggap berhasil.
Semua respons 2xx, termasuk200,202, dan204, diterima. Isi body respons tidak akan diverifikasi. -
Batas waktu respons adalah 5 detik.
Setelah memverifikasi signature, kami menyarankan untuk segera mengembalikan respons2xxdan menangani proses sebenarnya secara asynchronous. -
Redirect HTTP tidak akan diikuti.
Respons seperti
301dan302akan dianggap gagal, sehingga Anda harus mendaftarkan Callback URL terakhir.
Retry & Penonaktifan
Saat pengiriman gagal, Webhook akan melakukan retry secara otomatis. Jika terjadi kegagalan berulang, status Webhook akan diubah menjadi Dinonaktifkan oleh sistem untuk mencegah pengiriman ulang yang tidak diperlukan.
-
Retry dilakukan untuk semua kode respons HTTP.
Respons seperti
400,404, dan401mengikuti kebijakan retry yang sama. -
Selama proses retry,
X-Vivoldi-Event-Idtetap sama. Server penerima harus menggunakan nilai ini untuk mencegah pemrosesan event duplikat. - Meskipun 5 kali percobaan retry gagal, Webhook tidak langsung dinonaktifkan. Notifikasi email akan dikirim terlebih dahulu, kemudian diberikan masa tenggang selama 60 menit. Jika pemulihan tidak terjadi selama periode tersebut, status Webhook akan diubah menjadi Dinonaktifkan oleh sistem.
Webhook dengan status Dinonaktifkan oleh sistem dapat ditemukan melalui filter Dinonaktifkan oleh sistem pada daftar dashboard dan diaktifkan kembali.
| Tahap | Waktu | Tindakan |
|---|---|---|
| Percobaan 1–3 | Segera · Setelah 1 dtk · Setelah 2 dtk | Percobaan ulang segera dilakukan untuk menangani gangguan jaringan sementara. |
| Percobaan ke-4 | Setelah 10 mnt | Percobaan ulang dilakukan dengan mempertimbangkan waktu pemulihan setelah server penerima dimulai ulang atau mengalami gangguan sementara. |
| Percobaan ke-5 | Setelah 30 mnt | Percobaan pengiriman terakhir dilakukan. Jika gagal, percobaan ulang otomatis dihentikan. |
| Email peringatan | Segera setelah 5 kegagalan | Webhook tidak langsung dinonaktifkan. Masa tenggang 60 menit dimulai bersamaan dengan kegagalan ke-5, dan notifikasi email dikirim. Bergantung pada siklus pemrosesan notifikasi, email dapat terlambat hingga sekitar 10 menit. |
| Masa tenggang | 30 mnt–90 mnt | Jika server pulih dalam masa tenggang 60 menit, pengiriman Webhook akan dilanjutkan tanpa penonaktifan sistem. |
| Dinonaktifkan oleh sistem | Setelah 90 mnt | Jika percobaan pengiriman pertama setelah masa tenggang juga gagal, status Webhook akan diubah menjadi Dinonaktifkan oleh sistem. |
Jika terjadi kegagalan berulang dari Callback URL yang sama, pengiriman akan dibatasi sementara untuk mencegah permintaan terus menumpuk hingga server penerima pulih.
Gangguan singkat seperti deployment atau masalah sementara akan dilanjutkan secara otomatis setelah pemulihan.
Event penggunaan kupon dan stempel tidak akan pernah hilang.
Karena merupakan event penting yang hanya terjadi satu kali, event tersebut disimpan dalam antrean selama percobaan ulang dan masa tenggang, lalu dikirim secara berurutan.
Event klik tautan terjadi secara berulang dan data analitik disimpan di Vivoldi. Oleh karena itu, event tersebut tidak disimpan secara terpisah atau dikirim ulang ketika pengiriman Webhook gagal.
Panduan implementasi server penerima Webhook
-
Event yang sama dapat dikirim lebih dari satu kali.
Event yang sama dapat diterima beberapa kali karena proses retry atau kondisi jaringan. SimpanX-Vivoldi-Event-Iddan kembalikan200 OKtanpa proses tambahan jika event tersebut sudah pernah diproses.
Hal ini sangat penting untuk operasi yang tidak boleh diproses secara duplikat, seperti penggunaan kupon atau penambahan stempel. -
Urutan event tidak dijamin.
Event yang dikirim ulang dapat diterima setelah event lain yang terjadi berikutnya.
Jika pemrosesan berdasarkan urutan diperlukan, gunakan nilairegYmdtdanmodYmdtdari Payload sebagai referensi. -
Sebaiknya pisahkan penanganan respons dari proses pemrosesan sebenarnya.
Menjalankan operasi database atau pemanggilan API eksternal sebelum mengirim respons dapat melebihi batas waktu 5 detik.
Kami menyarankan alur implementasi berikut: verifikasi signature → respons200 OK→ pemrosesan melalui internal queue. -
Verifikasi signature menggunakan request body asli.
Parsing JSON kemudian melakukan serialisasi ulang dapat mengubah nilai hash karena perubahan spasi atau urutan key.
Jika framework Anda secara otomatis mengubah request body, simpan raw body secara terpisah. -
Abaikan field yang tidak dikenal.
Field baru dapat ditambahkan ke Payload di masa mendatang. Pastikan implementasi Anda mengabaikan field yang tidak dikenali. -
Secret Key bergantung pada target Webhook.
JikaX-Vivoldi-Webhook-TypebernilaiGLOBAL, lakukan verifikasi signature menggunakan global Secret Key. Jika bernilaiGROUP, gunakan Secret Key yang dikonfigurasi pada grup atau kartu stempel terkait.
Apakah Aman Memproses Webhook Tanpa Verifikasi Signature Header?
Secara teknis, Webhook tetap dapat diproses hanya dengan menerima POST Body (Payload). Namun, pada lingkungan produksi, verifikasi header wajib diterapkan.
Mengabaikan validasi header dapat menimbulkan risiko keamanan serius seperti request palsu, manipulasi payload, pemrosesan duplikat, dan hilangnya kemampuan pelacakan.
Risiko utama:
-
Request palsu (Spoofing): Penyerang dapat menyamar sebagai server Vivoldi dan mengirim request Webhook palsu.
Tanpa verifikasi header, sistem dapat salah menganggap request tersebut sebagai request yang valid. - Manipulasi data: Jika Payload dimodifikasi selama transmisi jaringan, perubahan tersebut tidak dapat dideteksi tanpa validasi signature.
- Pemrosesan duplikat: Replay attack dapat menyebabkan event yang sama diterima berulang kali sehingga memicu pemrosesan ganda atau pemberian reward dua kali.
- Tidak dapat dilacak: Tanpa header Request-Id atau Event-Id, pelacakan request, analisis error, dan reproduksi masalah menjadi jauh lebih sulit.
Payload
Waktu pemicu event
Link Webhook mengirimkan informasi event ke Callback URL yang dikonfigurasi ketika terjadi event klik pada URL pendek.
Webhook dapat dikonfigurasi pada link individual atau grup link.
Jika keduanya dikonfigurasi, pengaturan grup link akan diprioritaskan,
dan kriteria serta interval pengiriman mengikuti pengaturan grup. Event yang sama tidak akan dikirim lebih dari satu kali.
Nilai X-Vivoldi-Action-Type adalah CLICK.
Link Webhook untuk grup link merupakan fitur khusus paket Enterprise.
Anda dapat memilih jumlah klik atau jumlah pengunjung sebagai kriteria pengiriman, dan Webhook akan dikirim setiap kali batas kumulatif yang telah ditentukan tercapai.
Contohnya, jika kriteria pengiriman diatur berdasarkan jumlah klik dengan interval pengiriman setiap 100 klik, Webhook akan dikirim ketika jumlah klik kumulatif mencapai 100, 200, 300, dan seterusnya.
{
"linkId": "202509-event",
"domain": "https://event.com",
"compIdx": 50142,
"redirectType": 200,
"url": "https://my-event.com/books/event/202509",
"ttl": "September 2025 Event",
"description": "The 2025 National Book Festival will be held in the nation's capital at the Walter E.",
"metaImg": "https://my-event.com/storage-services/media/webcasts/2025/2509_thumbnail_00145901.jpg",
"memo": "",
"grpIdx": 0,
"grpNm": "",
"strtYmdt": "2025-09-01 00:00:00",
"endYmdt": "2025-09-30 23:59:59",
"expireYn": "Y",
"expireUrl": "https://my-event.com/books/event/closed",
"acesCnt": 17502,
"pernCnt": 16491,
"acesMaxCnt": 20000,
"referer": "https://www.google.com",
"queryString": "",
"country": "US",
"language": "en",
"regYmdt": "2025-08-31 18:10:22",
"modYmdt": "2025-08-31 18:10:22",
"payloadVersion": "v1"
}
Payload Parameters
- linkId string
- ID identifikasi link.
- domain string
- Domain link.
- compIdx integer
-
IDX organisasi.
Nilai ini sama dengan nilai header
X-Vivoldi-Comp-Idx. - redirectType integer
-
Enum:
200301302
-
Metode pengalihan link.
200: Mode tampilan halaman
301: Pengalihan permanen
302: Pengalihan sementara
Untuk informasi lebih lanjut, lihat halaman Terminologi. - url string
- URL asli.
- ttl string
- Judul link.
- description string
-
Nilai meta tag description yang digunakan ketika
redirectTypebernilai200. - metaImg string
-
URL gambar meta tag yang digunakan ketika
redirectTypebernilai200. - memo string
- Catatan untuk pengelolaan link.
- grpIdx integer
-
IDX grup link.
Jika Webhook dikonfigurasi pada grup link, Webhook grup akan diprioritaskan dibandingkan pengaturan link individual. - grpNm string
- Nama grup link.
- strtYmdt datetime
- Tanggal dan waktu mulai masa berlaku link.
- endYmdt datetime
- Tanggal dan waktu berakhirnya masa berlaku link.
- expireYn string
-
Enum:
YN
-
Menunjukkan apakah masa berlaku link telah berakhir.
Nilai
Ydikirimkan jika link sudah kedaluwarsa. - expireUrl string
- URL tujuan setelah link kedaluwarsa.
- acesCnt integer
-
Jumlah klik kumulatif.
Nilai ini sudah termasuk event klik saat ini.
Kondisi pengiriman Webhook juga ditentukan berdasarkan nilai ini. Contohnya, jika interval pengiriman diatur setiap 100 klik, Webhook akan dikirim setiap kali jumlah klik kumulatif mencapai 100, 200, 300, dan seterusnya. - pernCnt integer
- Jumlah pengunjung kumulatif (jumlah pengguna unik). Nilai ini sudah termasuk event klik saat ini.
- acesMaxCnt integer
-
Jumlah maksimum klik yang diizinkan.
Jika nilainya
0, tidak ada batasan. Akses ke link akan diblokir jika melebihi batas yang ditentukan. - referer string
- URL halaman sebelumnya tempat permintaan berasal.
- queryString string
- Query String yang dikirim saat mengakses URL pendek.
- country string
- Kode negara pengguna yang mengakses (ISO-3166).
- language string
- Kode bahasa pengguna yang mengakses (ISO-639).
- regYmdt datetime
- Tanggal dan waktu pembuatan link.
- modYmdt datetime
- Tanggal dan waktu perubahan link.
- payloadVersion string
- Versi spesifikasi Payload. Meskipun field baru ditambahkan, arti dan perilaku field yang sudah ada tetap dipertahankan hingga nilai ini diperbarui.
Waktu pemicu event
Coupon Webhook mengirimkan informasi event ke Callback URL yang dikonfigurasi ketika terjadi event penggunaan kupon.
Webhook dapat dikonfigurasi pada kupon individual atau grup kupon.
Jika keduanya dikonfigurasi, pengaturan grup kupon akan diprioritaskan,
dan event yang sama tidak akan dikirim lebih dari satu kali.
Coupon Webhook untuk grup kupon tersedia pada paket Business atau lebih tinggi.
Event dikirim segera setelah proses penggunaan kupon selesai, dan nilai X-Vivoldi-Action-Type adalah USE.
Event dikirim dengan cara yang sama, baik kupon digunakan melalui dashboard, API, maupun proses offline.
Jika batas pemanggilan terlampaui atau sedang menunggu proses retry, event akan disimpan dalam antrean dan dikirim secara berurutan.
Jika beberapa kupon diproses sekaligus melalui API, pengiriman event dapat dilakukan secara bertahap dalam beberapa proses.
{
"cpnNo": "ZJLF0399WQBEQZJM",
"domain": "https://vvd.bz",
"nm": "$10 off cake coupon",
"grpIdx": 574,
"grpNm": "Event coupons",
"discTypeIdx": 457,
"discCurrency": "USD",
"formatDiscCurrency": "$10"
"disc": 10.0,
"strtYmd": "2025-01-01",
"endYmd": "2025-12-31",
"useLimit": 1,
"imgUrl": "https://file.vivoldi.com/coupon/2024/11/08/lmTFkqLQdCzeBuPdONKG.webp",
"onsiteYn": "Y",
"onsitePwd": "123456",
"memo": "$10 off cake with coupon at the venue",
"url": "",
"userId": "user08",
"userNm": "Emily",
"userPhnno": "202-555-0173",
"userEml": "test@gmail.com",
"userEtc1": "",
"userEtc2": "",
"useCnt": 0,
"regYmdt": "2025-08-31 18:10:22",
"payloadVersion": "v1"
}
Payload Parameters
- cpnNo string
- Nomor kupon.
- domain string
- Domain halaman kupon.
- nm string
- Nama kupon.
- grpIdx integer
-
IDX grup tempat kupon berada.
Jika kupon tidak memiliki grup, nilainya adalah
0.
Jika Webhook grup dikonfigurasi, pengaturan grup akan diprioritaskan, dan nilaiX-Vivoldi-Webhook-Typeakan dikirim sebagaiGROUP.
Jika Webhook grup tidak dikonfigurasi, pengiriman akan mengikuti pengaturan kupon individual. - grpNm string
- Nama grup kupon.
- discTypeIdx integer
-
Enum:
457458
-
Jenis diskon.
457: Diskon persentase (%)
458: Diskon nominal tetap - discCurrency string
- Default:KRW
-
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
-
Mata uang yang digunakan untuk nilai diskon.
Wajib diisi saat menggunakan diskon nominal tetap (
discTypeIdx=458). - formatDiscCurrency string
- Format tampilan mata uang.
- disc double
- Default:0
-
Nilai diskon.
Diskon persentase (457) berada dalam rentang1~100%, sedangkan diskon nominal tetap (458) berisi jumlah potongan harga. - strtYmd date
- Tanggal mulai berlaku kupon.
- endYmd date
- Tanggal berakhirnya masa berlaku kupon.
- useLimit integer
- Default:1
-
Enum:
012345
-
Jumlah penggunaan kupon yang diizinkan.
0: Tanpa batas
1~5: Dapat digunakan sesuai jumlah yang ditentukan - imgUrl string
- URL gambar kupon.
- onsiteYn string
- Default:N
-
Enum:
YN
-
Menunjukkan apakah penggunaan kupon di toko didukung.
Jika nilainya
Y, tombolGunakan Kuponakan ditampilkan pada halaman kupon, dan kupon dapat digunakan di toko fisik setelah verifikasi oleh staf. - onsitePwd string
-
Password untuk verifikasi penggunaan kupon di toko.
Karena disertakan dalam bentuk teks biasa di dalamPayload, jangan menyimpannya dalam log server penerima. - memo string
- Catatan internal.
- url string
-
Jika dikonfigurasi, tombol
Buka Penggunaan Kuponakan ditampilkan pada halaman kupon.
Pengguna akan diarahkan ke URL tersebut saat tombol atau gambar kupon diklik. - userId string
-
ID untuk mengidentifikasi pengguna kupon.
Wajib diisi jika batas penggunaan kupon diatur antara2~5. Biasanya menggunakan ID anggota layanan atau nilai identifikasi pelanggan. - userNm string
- Nama pengguna kupon. Digunakan untuk pengelolaan internal dan identifikasi.
- userPhnno string
- Informasi kontak pengguna kupon. Digunakan untuk pengelolaan internal dan identifikasi.
- userEml string
- Email pengguna kupon. Digunakan untuk pengelolaan internal dan identifikasi.
- userEtc1 string
- Field tambahan untuk pengelolaan internal.
- userEtc2 string
- Field tambahan untuk pengelolaan internal.
- useCnt integer
-
Jumlah penggunaan kupon saat ini.
Event penggunaan saat ini belum termasuk dalam nilai ini.
Jika ingin menghitung penggunaan hingga transaksi saat ini, gunakan perhitunganuseCnt + 1. - regYmdt datetime
- Tanggal dan waktu pembuatan kupon. Contoh: 2025-07-21 11:50:20
- payloadVersion string
- Versi spesifikasi Payload. Meskipun field baru ditambahkan, arti dan perilaku field yang sudah ada tetap dipertahankan hingga nilai ini diubah.
Waktu pemicu event
Webhook dikonfigurasi pada stamp card. Semua event stamp yang dihasilkan dari kartu tersebut akan dikirim.
Event dikirim ketika terjadi penambahan, penghapusan, atau penggunaan reward stamp.
Jenis event ditentukan melalui nilai header X-Vivoldi-Action-Type.
ADD— Stamp ditambahkanREMOVE— Stamp dihapusUSE— Reward stamp digunakan
Terlepas dari apakah perubahan dilakukan melalui dashboard, API, halaman pengelolaan stamp, atau metode lainnya, event akan dikirim dengan jenis event yang sama.
changedStamps menunjukkan jumlah stamp yang terdampak.
Penambahan atau pengurangan stamp ditentukan berdasarkan nilai X-Vivoldi-Action-Type.
Penggunaan reward (USE) tidak mengubah jumlah stamp, sehingga nilainya dikirim sebagai 0.
stamps bergantung pada cara event dibuat.Pada event penambahan, penghapusan, dan penggunaan reward melalui API,
stamps menunjukkan jumlah stamp sebelum perubahan.
Nilai setelah perubahan dapat dihitung dengan stamps + changedStamps.
Untuk REMOVE, kurangi dengan changedStamps.Jika perubahan dilakukan melalui halaman pengelolaan stamp di dashboard,
stamps menunjukkan jumlah stamp setelah perubahan.Untuk menghitung jumlah stamp saat ini secara akurat, gunakan nilai sebelum event dan
changedStamps untuk menghitung nilai setelah perubahan.
{
"stampIdx": 16,
"domain": "https://vvd.bz",
"cardIdx": 1,
"cardNm": "Accumulate 10 Americanos",
"cardTtl": "Collect 10 stamps to get one free Americano.",
"stamps": 10,
"maxStamps": 12,
"changedStamps": 2,
"stampUrl": "https://vvd.bz/stamp/274",
"url": "https://myshopping.com",
"strtYmd": "2025-01-01",
"endYmd": "2026-12-31",
"onsiteYn": "Y",
"onsitePwd": "123456",
"memo": null,
"activeYn": "Y",
"userId": "NKkDu9X4p4mQ",
"userNm": null,
"userPhnno": null,
"userEml": null,
"userEtc1": null,
"userEtc2": null,
"stampImgUrl": "https://cdn.vivoldi.com/www/image/icon/stamp/icon.stamp.1.webp",
"regYmdt": "2025-10-30 05:11:35",
"payloadVersion": "v1"
}
Payload Parameters
- stampIdx integer
- IDX identifikasi stamp.
- domain string
- Domain halaman stamp.
- cardIdx integer
- IDX identifikasi stamp card.
- cardNm string
- Nama stamp card.
- cardTtl string
- Judul stamp card.
- stamps integer
-
Jumlah stamp saat ini. Namun, titik referensi dapat berbeda tergantung cara event dibuat.
Untuk event penambahan, penghapusan, dan penggunaan reward melalui API, nilai ini menunjukkan jumlah stamp sebelum perubahan. Nilai setelah perubahan dapat dihitung menggunakanstampsdanchangedStamps.
(ADD: Stamp ditambahkan,REMOVE: Stamp dihapus)
Jika perubahan dilakukan langsung melalui halaman pengelolaan stamp di dashboard, nilai ini menunjukkan jumlah stamp setelah perubahan. - maxStamps integer
- Jumlah maksimum stamp pada stamp card.
- changedStamps integer
-
Jumlah stamp yang berubah pada event ini.
Penambahan atau pengurangan ditentukan berdasarkan nilai
X-Vivoldi-Action-Type.
Penggunaan reward (USE) tidak mengubah jumlah stamp, sehingga nilainya adalah0. - stampUrl string
- URL halaman stamp.
- url string
- URL tujuan ketika tombol diklik pada halaman stamp.
- strtYmd date
- Tanggal mulai berlaku stamp.
- endYmd date
- Tanggal berakhirnya masa berlaku stamp.
- onsiteYn string
-
Enum:
YN
-
Menunjukkan apakah pengumpulan stamp di toko didukung.
Jika nilainya
Y, karyawan dapat melakukan verifikasi pelanggan dan menambahkan stamp di lokasi toko. - onsitePwd string
-
Password untuk verifikasi pengumpulan stamp di toko atau penggunaan reward.
Diperlukan saat memanggil API terkait jika pengumpulan stamp di toko diaktifkan (onsiteYn=Y). - memo string
- Catatan internal.
- activeYn string
-
Enum:
YN
- Menunjukkan apakah stamp card aktif. Jika dinonaktifkan, pelanggan tidak dapat menggunakan stamp card.
- userId string
-
ID pengguna untuk mengidentifikasi pengguna stamp.
Umumnya menggunakan ID anggota layanan atau nilai identifikasi pelanggan.
Jika tidak diatur, Vivoldi akan membuatnya secara otomatis. - userNm string
- Nama pengguna stamp. Digunakan untuk pengelolaan internal dan identifikasi.
- userPhnno string
- Informasi kontak pengguna stamp. Digunakan untuk pengelolaan internal dan identifikasi.
- userEml string
- Alamat email pengguna stamp. Digunakan untuk pengelolaan internal dan identifikasi.
- userEtc1 string
- Field tambahan untuk pengelolaan internal.
- userEtc2 string
- Field tambahan untuk pengelolaan internal.
- stampImgUrl string
- URL gambar stamp.
- regYmdt datetime
- Tanggal dan waktu pembuatan stamp. Contoh: 2025-07-21 11:50:20
- payloadVersion string
- Versi spesifikasi Payload. Meskipun field baru ditambahkan, arti dan perilaku field yang sudah ada tetap sama hingga nilai ini diperbarui.
Verifikasi Signature Webhook & Contoh Kode
Keaslian request Webhook diverifikasi menggunakan header X-Vivoldi-Signature dan Secret Key yang telah diterbitkan.
Signature dibuat dengan menggabungkan timestamp (t), event ID (X-Vivoldi-Event-Id), dan nilai hash SHA-256 dari request body menjadi string yang dipisahkan dengan titik (.), lalu diproses menggunakan HMAC-SHA256 dengan Secret Key.
timestamp.eventId.payloadSha256
Jika hasil hash (v1) cocok dengan nilai pada header X-Vivoldi-Signature, request dianggap valid.
Jika tidak cocok, segera tolak request tersebut dan simpan log untuk keperluan audit.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.stereotype.Controller;
import org.apache.commons.codec.binary.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Map;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private final Logger log = LoggerFactory.getLogger(getClass());
@Value("${vivoldi.webhook.secret}")
private String globalSecretKey; // global secret key
@PostMapping("/vivoldi")
public ResponseEntity<String> handleWebhook(@RequestBody String payload, @RequestHeader Map<String, String> headers) {
// Extracting the Vivoldi header
String requestId = headers.get("x-vivoldi-request-id");
String eventId = headers.get("x-vivoldi-event-id");
String webhookType = headers.get("x-vivoldi-webhook-type");
String resourceType = headers.get("x-vivoldi-resource-type");
String actionType = headers.get("x-vivoldi-action-type");
String signature = headers.get("x-vivoldi-signature");
// Signature Verification
if (!verifySignature(payload, signature, webhookType, resourceType, eventId)) {
return ResponseEntity.status(401).body("Invalid signature");
}
// Processing by Resource Type
switch (resourceType) {
case "URL":
handleLink(payload);
break;
case "COUPON":
handleCoupon(payload);
break;
case "STAMP":
handleStamp(payload, actionType);
break;
default:
log.warn("Unknown resourceType type: {}", resourceType);
}
return ResponseEntity.ok("success");
}
private String sha256(String data) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) sb.append(String.format("%02x", b));
return sb.toString();
}
private boolean verifySignature(String payload, String signature, String webhookType, String resourceType, String eventId) {
try {
String timestamp = null;
String sig = null;
for (String part : signature.split(",")) {
part = part.trim();
if (part.startsWith("t=")) timestamp = part.substring(2);
if (part.startsWith("v1=")) sig = part.substring(3);
}
if (timestamp == null || sig == null || eventId == null) return false;
// Timestamp tolerance (±5 minutes)
// X-Vivoldi-Timestamp is in MILLISECONDS, so compare against System.currentTimeMillis().
if (Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)) > 300_000L) {
log.warn("Webhook timestamp out of tolerance: {}", timestamp);
return false;
}
String payloadSha256 = null;
try {
payloadSha256 = sha256(payload);
} catch (Exception e) {
log.error(e.getMessage(), e);
return false;
}
String signedPayload = timestamp + "." + eventId + "." + payloadSha256;
String secretKey = webhookType.equals("GLOBAL") ? globalSecretKey : "";
if (secretKey.isEmpty()) {
JSONObject jsonObj = new JSONObject(payload);
if (resourceType.equals("STAMP")) {
long cardIdx = jsonObj.optLong("cardIdx", -1);
secretKey = loadStampCardSecretKey(cardIdx);
} else {
int grpIdx = jsonObj.optInt("grpIdx", -1);
secretKey = loadGroupSecretKey(grpIdx); // In actual production environments, database integration
}
}
if (secretKey == null || secretKey.isEmpty()) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
String computedSig = Hex.encodeHexString(hash);
return MessageDigest.isEqual(
sig.toLowerCase().getBytes(StandardCharsets.UTF_8),
computedSig.toLowerCase().getBytes(StandardCharsets.UTF_8)
);
} catch (Exception e) {
log.error("Signature verification failed", e);
return false;
}
}
private String loadStampCardSecretKey(long cardIdx) {
switch (cardIdx) {
case 147: return "your-stamp-card-secret-key-147";
case 523: return "your-stamp-card-secret-key-523";
default: return "";
}
}
private String loadGroupSecretKey(int grpIdx) {
switch (grpIdx) {
case 3570: return "your-group-secret-key-3570";
case 4178: return "your-group-secret-key-4178";
default: return "";
}
}
private void handleLink(String payload) {
// Link Click Event Handling Logic
log.info("Link clicked: {}", payload);
}
private void handleCoupon(String payload) {
// Coupon Usage Event Handling Logic
log.info("Coupon redeemed: {}", payload);
}
private void handleStamp(String payload, String actionType) {
// Stamp Usage Event Handling Logic
if (actionType.equals("ADD")) {
log.info("Stamp added: {}", payload);
} else if (actionType.equals("RMEOVE")) {
log.info("Stamp removed: {}", payload);
} else if (actionType.equals("USE")) {
log.info("Stamp redeemed: {}", payload);
}
}
}
<?php
// Environment Settings
$globalSecretKey = $_ENV['VIVOLDI_WEBHOOK_SECRET'] ?? 'your-global-secret-key';
/**
* Main Webhook Handler Function
*/
function handleWebhook($payload) {
// Header Information Extraction
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$requestId = $headers['x-vivoldi-request-id'] ?? '';
$eventId = $headers['x-vivoldi-event-id'] ?? '';
$webhookType = $headers['x-vivoldi-webhook-type'] ?? '';
$resourceType = $headers['x-vivoldi-resource-type'] ?? '';
$actionType = $headers['x-vivoldi-action-type'] ?? '';
$signature = $headers['x-vivoldi-signature'] ?? '';
// Signature Verification
if (!verifySignature($payload, $signature, $webhookType, $resourceType, $eventId)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
return;
}
// Processing by Resource Type
switch ($resourceType) {
case 'URL':
handleLink($payload);
break;
case 'COUPON':
handleCoupon($payload);
break;
case 'STAMP':
handleStamp($payload, $actionType);
break;
default:
error_log('Unknown resourceType: ' . $resourceType);
}
http_response_code(200);
echo json_encode(['status' => 'success']);
}
function sha256($data) {
return hash('sha256', $data);
}
/**
* HMAC-SHA256 Signature Verification Function
*/
function verifySignature($payload, $signature, $webhookType, $resourceType, $eventId) {
try {
$timestamp = null;
$sig = null;
foreach (explode(',', $signature) as $part) {
$part = trim($part);
if (strpos($part, 't=') === 0) $timestamp = substr($part, 2);
if (strpos($part, 'v1=') === 0) $sig = substr($part, 3);
}
if (!$timestamp || !$sig || !$eventId) return false;
// Timestamp tolerance (±5 minutes)
// X-Vivoldi-Timestamp is in MILLISECONDS, so compare against time() * 1000.
if (abs(time() * 1000 - (int)$timestamp) > 300000) {
return false;
}
// Payload SHA256
$payloadSha256 = sha256($payload);
$signedPayload = $timestamp . '.' . $eventId . '.' . $payloadSha256;
$secretKey = getSecretKey($webhookType, $resourceType, $payload);
if (empty($secretKey)) return false;
$computedSig = hash_hmac('sha256', $signedPayload, $secretKey);
// Safety Comparison (lowercase throughout)
return hash_equals(strtolower($sig), strtolower($computedSig));
} catch (Exception $e) {
error_log('Signature verification failed: ' . $e->getMessage());
return false;
}
}
/**
* Secret Key Return Based on Webhook Type and Group
*/
function getSecretKey($webhookType, $resourceType, $payload) {
global $globalSecretKey;
if ($webhookType === 'GLOBAL') {
return $globalSecretKey;
}
// Group-Specific Secret Key Configuration
$jsonData = json_decode($payload, true);
if ($resourceType === 'STAMP') {
if (!isset($jsonData['cardIdx'])) {
return '';
}
// Stamp cardIdx
$cardIdx = $jsonData['cardIdx'];
switch ($cardIdx) {
case 617:
return 'your stamp card secret key for 617';
case 3304:
return 'your stamp card secret key for 3304';
default:
return '';
}
} else {
if (!isset($jsonData['grpIdx'])) {
return '';
}
$grpIdx = $jsonData['grpIdx'];
if ($resourceType === 'LINK') {
// Link grpIdx
switch ($grpIdx) {
case 17584:
return 'your group secret key for 17584';
case 9158:
return 'your group secret key for 9158';
default:
return '';
}
} else {
// Coupon grpIdx
switch ($grpIdx) {
case 3570:
return 'your group secret key for 3570';
case 4178:
return 'your group secret key for 4178';
default:
return '';
}
}
}
}
/**
* Link Event Handler Function
*/
function handleLink($payload) {
error_log('Link clicked: ' . $payload);
// Processing link information by parsing JSON
$linkData = json_decode($payload, true);
if ($linkData) {
// Link Click Statistics Update
$linkId = $linkData['linkId'] ?? '';
$clickTime = $linkData['timestamp'] ?? time();
$userAgent = $linkData['userAgent'] ?? '';
// Storing click information in the database
saveClickEvent($linkId, $clickTime, $userAgent);
error_log("Link {$linkId} clicked at {$clickTime}");
}
}
/**
* Coupon Event Handling Function
*/
function handleCoupon($payload) {
error_log('Coupon redeemed: ' . $payload);
// Parsing JSON to process coupon information
$couponData = json_decode($payload, true);
if ($couponData) {
// Coupon Usage Information Processing
$couponCode = $couponData['couponCode'] ?? '';
$redeemTime = $couponData['timestamp'] ?? time();
$userId = $couponData['userId'] ?? '';
// Storing coupon usage information in the database
saveCouponRedemption($couponCode, $userId, $redeemTime);
error_log("Coupon {$couponCode} redeemed by user {$userId}");
}
}
/**
* Stamp Event Handling Function
*/
function handleStamp($payload, $actionType) {
error_log('Stamp payload: ' . $payload);
// Parsing JSON to process coupon information
$stampData = json_decode($payload, true);
if ($stampData) {
$stampIdx = $stampData['stampIdx'] ?? 0;
switch ($actionType) {
case "ADD":
// Stamp added
break;
case "REMOVE":
// Stamp removed
break;
case "USE":
// Stamp benefit used
break;
default:
return '';
}
}
}
/**
* Store click events in the database
*/
function saveClickEvent($linkId, $clickTime, $userAgent) {
// Implementation of actual database integration logic
// Example: Stored in MySQL, PostgreSQL, etc.
error_log("Saving click event - Link: {$linkId}, Time: {$clickTime}");
}
/**
* Store coupon usage information in the database
*/
function saveCouponRedemption($couponCode, $userId, $redeemTime) {
// Implementation of actual database integration logic
// Example: Updating coupon status, storing usage history, etc.
error_log("Saving coupon redemption - Code: {$couponCode}, User: {$userId}");
}
/**
* Log recording function
*/
function logWebhookEvent($eventType, $data) {
$timestamp = date('Y-m-d H:i:s');
$logMessage = "[{$timestamp}] {$eventType}: " . json_encode($data);
error_log($logMessage);
}
// ===========================================
// Webhook Endpoint Execution Unit
// ===========================================
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$payload = file_get_contents('php://input');
handleWebhook($payload);
} else {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
?>
const express = require('express');
const crypto = require('crypto');
const app = express();
// Environment Settings
const globalSecretKey = process.env.VIVOLDI_WEBHOOK_SECRET || 'your-global-secret-key';
// Form data parser for webhook payloads
app.use(express.raw({ type: '*/*' }));
/**
* Main Webhook Handler Function
*/
function handleWebhook(headers, res, payload) {
const requestId = headers['x-vivoldi-request-id'] || '';
const eventId = headers['x-vivoldi-event-id'] || '';
const webhookType = headers['x-vivoldi-webhook-type'] || '';
const resourceType = headers['x-vivoldi-resource-type'] || '';
const actionType = headers['x-vivoldi-action-type'] || '';
const signature = headers['x-vivoldi-signature'] || '';
// Signature Verification
if (!verifySignature(payload, signature, webhookType, resourceType, eventId)) {
res.status(401).json({ error: 'Invalid signature' });
return;
}
// Processing by Resource Type
switch (resourceType) {
case 'URL':
handleLink(payload);
break;
case 'COUPON':
handleCoupon(payload);
break;
case 'STAMP':
handleStamp(payload);
break;
default:
console.error('Unknown resourceType: ' + resourceType);
}
res.status(200).json({ status: 'success' });
}
/**
* SHA256(hex)
*/
function sha256Hex(data) {
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
}
/**
* HMAC-SHA256 Signature Verification Function
*/
function verifySignature(payload, signature, webhookType, resourceType, eventId) {
try {
let timestamp, sig;
for (const part of signature.split(',')) {
const p = part.trim();
if (p.startsWith('t=')) timestamp = p.slice(2);
if (p.startsWith('v1=')) sig = p.slice(3);
}
if (!timestamp || !sig || !eventId) return false;
// Timestamp tolerance (±5 minutes)
// X-Vivoldi-Timestamp is in MILLISECONDS, so compare against Date.now() directly.
if (Math.abs(Date.now() - Number(timestamp)) > 300000) return false;
const signedPayload = `${timestamp}.${eventId}.${sha256Hex(payload)}`;
// Secret Key Determination
const secretKey = getSecretKey(webhookType, resourceType, payload);
if (!secretKey) return false;
// HMAC-SHA256 Signature Calculation
const computedSig = crypto
.createHmac('sha256', secretKey)
.update(signedPayload)
.digest('hex');
// Timing-Safe Comparison
return crypto.timingSafeEqual(
Buffer.from(sig.toLowerCase(), 'hex'),
Buffer.from(computedSig.toLowerCase(), 'hex')
);
} catch (e) {
console.error('Signature verification failed: ' + e.message);
return false;
}
}
/**
* Secret Key Return Based on Webhook Type and Group
*/
function getSecretKey(webhookType, resourceType, payload) {
if (webhookType === 'GLOBAL') {
return globalSecretKey;
}
// Group-Specific Secret Key Configuration
let jsonData;
try {
jsonData = JSON.parse(payload);
} catch (error) {
return '';
}
if (resourceType === 'STAMP') {
if (!jsonData.cardIdx) {
return '';
}
const cardIdx = jsonData.cardIdx;
switch (cardIdx) {
case 3570:
return 'your stamp card secret key for 3570';
case 4178:
return 'your stamp card secret key for 4178';
default:
return '';
}
} else {
if (!jsonData.grpIdx) {
return '';
}
const grpIdx = jsonData.grpIdx;
if (resourceType === 'LINK') {
// Link grpIdx
switch (grpIdx) {
case 17584:
return 'your group secret key for 17584';
case 9158:
return 'your group secret key for 9158';
default:
return '';
}
} else {
// Coupon grpIdx
switch (grpIdx) {
case 6350:
return 'your group secret key for 6350';
case 17884:
return 'your group secret key for 17884';
default:
return '';
}
}
}
}
/**
* Link Event Handler Function
*/
function handleLink(payload) {
console.error('Link clicked: ' + payload);
// Processing link information by parsing JSON
let linkData;
try {
linkData = JSON.parse(payload);
} catch (error) {
return;
}
if (linkData) {
// Link Click Statistics Update
const linkId = linkData.linkId || '';
const clickTime = linkData.timestamp || Math.floor(Date.now() / 1000);
const userAgent = linkData.userAgent || '';
// Storing click information in the database
saveClickEvent(linkId, clickTime, userAgent);
console.error(`Link ${linkId} clicked at ${clickTime}`);
}
}
/**
* Coupon Event Handling Function
*/
function handleCoupon(payload) {
console.error('Coupon redeemed: ' + payload);
// Parsing JSON to process coupon information
let couponData;
try {
couponData = JSON.parse(payload);
} catch (error) {
return;
}
if (couponData) {
// Coupon Usage Information Processing
const couponCode = couponData.couponCode || '';
const redeemTime = couponData.timestamp || Math.floor(Date.now() / 1000);
const userId = couponData.userId || '';
// Storing coupon usage information in the database
saveCouponRedemption(couponCode, userId, redeemTime);
console.error(`Coupon ${couponCode} redeemed by user ${userId}`);
}
}
/**
* Stamp Event Handling Function
*/
function handleStamp(payload, actionType) {
console.error('Stamp payload: ' + payload);
// Parsing JSON to process coupon information
let stampData;
try {
stampData = JSON.parse(payload);
} catch (error) {
return;
}
if (stampData) {
const stampIdx = stampData.stampIdx || 0;
switch (actionType) {
case "ADD":
// Stamp added
break;
case "REMOVE":
// Stamp removed
break;
case "USE":
// Stamp benefit used
break;
}
}
}
/**
* Store click events in the database
*/
function saveClickEvent(linkId, clickTime, userAgent) {
// Implementation of actual database integration logic
// Example: Stored in MongoDB, MySQL, PostgreSQL, etc.
console.error(`Saving click event - Link: ${linkId}, Time: ${clickTime}`);
}
/**
* Store coupon usage information in the database
*/
function saveCouponRedemption(couponCode, userId, redeemTime) {
// Implementation of actual database integration logic
// Example: Updating coupon status, storing usage history, etc.
console.error(`Saving coupon redemption - Code: ${couponCode}, User: ${userId}`);
}
/**
* Log recording function
*/
function logWebhookEvent(eventType, data) {
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
const logMessage = `[${timestamp}] ${eventType}: ${JSON.stringify(data)}`;
console.error(logMessage);
}
// ===========================================
// Webhook Endpoint Execution Unit
// ===========================================
app.post('/webhook/vivoldi', (req, res) => {
const payload = req.body.toString('utf8');
const headers = req.headers;
if (!verifySignature(payload, headers['x-vivoldi-signature'], headers['x-vivoldi-webhook-type'], headers['x-vivoldi-event-id'])) {
return res.status(401).json({ error: 'Invalid signature' });
}
handleWebhook(req.headers, res, payload);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server running on port ${PORT}`);
});
✨ Integrasi Real-time Tingkat Enterprise
Dioptimalkan untuk lingkungan enterprise yang menangani pemrosesan event tautan, kupon, dan stamp dalam skala besar.
Dibangun di atas infrastruktur high-availability dan sistem queueing yang andal, Vivoldi memastikan integrasi stabil dengan platform CRM, pembayaran, dan analitik tanpa kehilangan event, bahkan saat terjadi lonjakan traffic mendadak.