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 sebagai GROUP.
Event tautan dan kupon dikirim sebagai GLOBAL jika 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 dihapus

Gunakan bersama Resource-Type untuk 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, dan alg: 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, termasuk 200, 202, dan 204, diterima. Isi body respons tidak akan diverifikasi.
  • Batas waktu respons adalah 5 detik.
    Setelah memverifikasi signature, kami menyarankan untuk segera mengembalikan respons 2xx dan menangani proses sebenarnya secara asynchronous.
  • Redirect HTTP tidak akan diikuti. Respons seperti 301 dan 302 akan dianggap gagal, sehingga Anda harus mendaftarkan Callback URL terakhir.
Jika waktu respons melebihi 5 detik atau mengembalikan kode status selain 2xx, retry dapat terjadi dan event yang sama dapat dikirim beberapa kali.

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, dan 401 mengikuti kebijakan retry yang sama.
  • Selama proses retry, X-Vivoldi-Event-Id tetap 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. Simpan X-Vivoldi-Event-Id dan kembalikan 200 OK tanpa 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 nilai regYmdt dan modYmdt dari 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 → respons 200 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.
    Jika X-Vivoldi-Webhook-Type bernilai GLOBAL, lakukan verifikasi signature menggunakan global Secret Key. Jika bernilai GROUP, 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

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.

Penggunaan kupon adalah event satu kali untuk setiap kupon dan tidak dapat dipulihkan jika hilang.
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 nilai X-Vivoldi-Webhook-Type akan dikirim sebagai GROUP.
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 rentang 1~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, tombol Gunakan Kupon akan 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 dalam Payload, jangan menyimpannya dalam log server penerima.
memo string
Catatan internal.
url string
Jika dikonfigurasi, tombol Buka Penggunaan Kupon akan 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 antara 2~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 perhitungan useCnt + 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 ditambahkan
  • REMOVE — Stamp dihapus
  • USE — 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.

Arti nilai 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 menggunakan stamps dan changedStamps.
(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 adalah 0.
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.

Upgrade ke Enterprise