Webhook — Panduan Integrasi
Inti integrasi Vivoldi Webhook adalah verifikasi tanda tangan pada header HTTP.
 Setiap permintaan Webhook menyertakan X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature, dan lainnya,
 dan dengan memverifikasinya Anda dapat memproses acara Tautan, Kupon, dan Stempel dengan aman. 
Panduan ini menjelaskan peran tiap header dan langkah-demi-langkah prosedur verifikasi tanda tangan, serta menyediakan contoh kode agar Anda dapat mengintegrasikan permintaan Webhook dengan cepat dan aman.
HTTP Header
Webhook mengirimkan permintaan POST ke URL callback yang ditentukan, memungkinkan Anda memverifikasi integritas dan keaslian setiap permintaan melalui header seperti X-Vivoldi-Signature dan X-Vivoldi-Timestamp.
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: NONE
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-Idstring
 - ID unik untuk setiap permintaan. Dihasilkan ulang pada setiap permintaan baru dan digunakan untuk mengidentifikasi transaksi secara individual.
 - X-Vivoldi-Event-Idstring
 -  ID unik untuk setiap peristiwa.
Jika permintaan pertama gagal dan dikirim ulang, Event-Id yang sama dipertahankan untuk mencegah pemrosesan ganda pada peristiwa yang sama. - X-Vivoldi-Webhook-Typestring
 - Default:GLOBAL
 - Enum:GLOBALGROUP
 -  Jika Webhook bertipe GROUP diaktifkan, nilai ini akan disetel menjadi GROUP.
Acara stempel selalu menggunakan GROUP karena beroperasi berdasarkan kartu stempel.
Acara tautan dan kupon akan dikirim sebagai GLOBAL jika tidak ada Webhook grup yang dikonfigurasi. - X-Vivoldi-Resource-Typestring
 - Enum:URLCOUPONSTAMP
 - URL: tautan singkat, COUPON: kupon, STAMP: stempel.
 - X-Vivoldi-Action-Typestring
 - Enum:NONEADDREMOVEUSE
 NONE: digunakan untuk acara klik tautan atau penggunaan kupon, tanpa tindakan tambahan.
ADD: menambahkan stempel
REMOVE: menghapus stempel
USE: menggunakan hadiah stempelJika di masa depan tindakan tambahan ditambahkan ke acara tautan atau kupon, nilai header (
X-Vivoldi-Action-Type) ini dapat diperluas.- X-Vivoldi-Comp-Idxinteger
 -  IDX unik organisasi.
Dapat dilihat di halaman [Pengaturan → Pengaturan Organisasi]. - X-Vivoldi-Timestampinteger
 - Waktu permintaan (detik UNIX epoch). Disarankan toleransi ±5 menit.
 - X-Content-SHA256string
 - Nilai hash SHA-256 dari payload permintaan.
 - X-Vivoldi-Signaturestring
 - Informasi tanda tangan permintaan. Format: t=timestamp, v1=nilai tanda tangan, alg=algoritme.
 
Kebijakan Transmisi · Respons · Percobaan Ulang
Kriteria Sukses
- Dianggap berhasil jika server penerima mengembalikan respons HTTP 2xx (misalnya, 200).
 - Setelah memverifikasi tanda tangan, segera kembalikan 200 OK. Timeout adalah 5 detik, jadi proses panjang harus dijalankan secara asinkron setelah merespons.
 
Percobaan Ulang & Penonaktifan
- Maksimal 5 kali percobaan ulang jika terjadi kesalahan jaringan atau respons bukan 2xx.
 - Jika gagal 5 kali berturut-turut, Webhook akan dinonaktifkan secara otomatis dan email peringatan akan dikirim ke administrator.
 - Pencegahan duplikat: Periksa duplikasi menggunakan nilai 
X-Vivoldi-Event-Id. 
Kebijakan dapat disesuaikan sesuai dengan lingkungan operasi.
Apakah boleh memproses Webhook tanpa validasi header?
 Secara teknis, memproses hanya POST body (payload) sudah cukup, tetapi di lingkungan produksi Anda harus selalu melakukan verifikasi header.
 Mengabaikan verifikasi header membuka risiko keamanan serius seperti permintaan palsu, manipulasi payload, pemrosesan ganda, dan hilangnya jejak audit. 
Risiko utama:
- Pemalsuan permintaan (spoofing): Penyerang dapat menyamar sebagai Vivoldi dan mengirim permintaan palsu.
Tanpa verifikasi header, sistem dapat menerima permintaan tersebut sebagai sah. - Manipulasi data: Jika payload diubah selama pengiriman, tanpa verifikasi tanda tangan perubahan tidak akan terdeteksi.
 - Pemrosesan ganda: Serangan replay dapat menyebabkan event yang sama diterima berulang kali, menghasilkan pemrosesan duplikat atau penambahan ganda.
 - Hilangnya jejak audit: Tanpa header Request-Id atau Event-Id, pelacakan permintaan, analisis kesalahan, atau reproduksi insiden menjadi tidak mungkin.
 
Payload
{
    "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
- linkIdstring
 - ID tautan.
 - domainstring
 - Domain tautan.
 - redirectTypeinteger
 - Enum:200301302
 - Jenis pengalihan. Detail lebih lanjut dapat dilihat di halaman istilah utama.
 - urlstring
 - URL asli.
 - ttlstring
 - Judul tautan.
 - descriptionstring
 -  Mengatur nilai meta tag description saat 
redirectTypeadalah200. - metaImgstring
 -  Mengatur nilai meta tag image saat 
redirectTypeadalah200. - memostring
 - Catatan untuk pengelolaan tautan.
 - grpIdxinteger
 - IDX grup. Jika grup ditentukan, Webhook grup dipanggil sebagai gantinya.
 - grpNmstring
 - Nama grup.
 - strtYmdtdatetime
 - Tanggal/waktu mulai berlaku tautan.
 - ednYmdtdatetime
 - Tanggal/waktu berakhirnya masa berlaku tautan.
 - expireYnstring
 - Default:N
 - Enum:YN
 -  Dikirim sebagai 
Yketika tautan sudah kedaluwarsa. - expireUrlstring
 - URL tujuan setelah kedaluwarsa.
 - acesCntinteger
 - Jumlah total klik.
 - pernCntinteger
 - Jumlah klik unik (pengguna unik).
 - acesMaxCntinteger
 - Jumlah maksimum klik yang diperbolehkan. Akses diblokir setelah melebihi batas.
 - refererstring
 - URL halaman tempat permintaan berasal.
 - queryStringstring
 - Query string yang disertakan saat mengakses tautan pendek.
 - countrystring
 - Kode negara pengguna (ISO-3166).
 - languagestring
 - Kode bahasa pengguna (ISO-639).
 - regYmdtdatetime
 - Tanggal/waktu pembuatan tautan.
 - modYmdtdatetime
 - Tanggal/waktu modifikasi tautan.
 - payloadVersionstring
 - Versi payload. Akan bertambah ketika ada perubahan setelahnya.
 
{
    "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
- cpnNostring
 - Nomor kupon.
 - domainstring
 - Domain kupon.
 - nmstring
 - Nama kupon.
 - grpIdxinteger
 - Indeks grup. Jika ada grup yang ditentukan, Webhook grup akan dipanggil sebagai ganti Webhook global.
 - grpNmstring
 - Nama grup.
 - discTypeIdxinteger
 - Default:457
 - Enum:457458
 - Jenis diskon. (457: Diskon persentase %, 458: Diskon nominal)
 - discCurrencystring
 - Default:KRW
 - Enum:KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
 - Mata uang. Wajib diisi jika menggunakan diskon nominal (discTypeIdx:458).
 - formatDiscCurrencystring
 - Simbol mata uang.
 - discdouble
 - Default:0
 - Jika diskon persentase (457), masukkan nilai 1–100%. Jika diskon nominal (458), masukkan jumlah uang.
 - imgUrlstring
 - URL gambar kupon.
 - onsiteYnstring
 - Default:N
 - Enum:YN
 -  Status kupon di lokasi. Menentukan apakah tombol 
“Gunakan Kupon”ditampilkan di halaman kupon.
Diperlukan saat kupon digunakan di toko offline oleh staf. - onsitePwdstring
 - Kata sandi kupon di lokasi. Diperlukan saat menggunakan kupon.
 - memostring
 - Catatan untuk referensi internal.
 - urlstring
 -  Jika URL dimasukkan, tombol 
“Gunakan Kupon”akan ditampilkan di halaman kupon.
Saat tombol atau gambar kupon diklik, pengguna akan diarahkan ke URL tersebut. - userIdstring
 -  Digunakan untuk mengelola penerima kupon.
Wajib diisi jika batas penggunaan kupon disetel ke 2–5 kali, biasanya diisi dengan ID login atau nama pengguna. - userNmstring
 - Nama pengguna kupon. Untuk manajemen internal.
 - userPhnnostring
 - Nomor kontak pengguna kupon. Untuk manajemen internal.
 - userEmlstring
 - Email pengguna kupon. Untuk manajemen internal.
 - userEtc1string
 - Bidang tambahan untuk manajemen internal.
 - userEtc2string
 - Bidang tambahan untuk manajemen internal.
 - useCntinteger
 - Jumlah penggunaan kupon.
 - regYmdtdatetime
 - Tanggal pembuatan kupon. Contoh: 2025-07-21 11:50:20
 
{
    "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,
    "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
- stampIdxinteger
 - Stamp IDX.
 - domainstring
 - Domain cap.
 - cardIdxinteger
 - Card IDX.
 - cardNmstring
 - Nama kartu.
 - cardTtlstring
 - Judul kartu.
 - stampsinteger
 - Jumlah cap yang telah dikumpulkan sejauh ini.
 - maxStampsinteger
 - Jumlah maksimum cap pada kartu.
 - stampUrlstring
 - URL halaman cap.
 - urlstring
 - URL tujuan yang akan dibuka saat tombol di halaman cap diklik.
 - strtYmddate
 - Tanggal mulai masa berlaku cap.
 - endYmddate
 - Tanggal berakhirnya masa berlaku cap.
 - onsiteYnstring
 - Enum:YN
 -  Menunjukkan apakah penambahan cap di lokasi diaktifkan.
Jika nilainyaY, staf toko dapat menambahkan cap langsung di tempat. - onsitePwdstring
 -  Kata sandi untuk penambahan cap di lokasi.
Diperlukan saat menggunakan API penggunaan manfaat jika opsi di lokasi diaktifkan (Y). - memostring
 - Catatan internal untuk referensi.
 - activeYnstring
 - Enum:YN
 -  Menunjukkan apakah cap aktif.
Jika dinonaktifkan, pelanggan tidak dapat menggunakan cap tersebut. - userIdstring
 -  ID pengguna. Digunakan untuk mengelola penerima cap.
Biasanya ini adalah ID login anggota situs web.
Jika tidak diatur, sistem akan membuat ID pengguna secara otomatis. - userNmstring
 - Nama pengguna. Hanya untuk keperluan internal.
 - userPhnnostring
 - Nomor telepon pengguna. Hanya untuk keperluan internal.
 - userEmlstring
 - Alamat email pengguna. Hanya untuk keperluan internal.
 - userEtc1string
 - Kolom tambahan untuk manajemen internal.
 - userEtc2string
 - Kolom tambahan untuk manajemen internal.
 - stampImgUrlstring
 - URL gambar cap.
 - regYmdtdatetime
 - Tanggal pembuatan cap. Contoh: 2025-07-21 11:50:20
 
Verifikasi Tanda Tangan — Contoh Kode
 Permintaan Webhook harus diverifikasi menggunakan header X-Vivoldi-Signature dan Webhook Secret Key yang diberikan.
 Tanda tangan dihasilkan dengan menggabungkan timestamp (t), ID acara (X-Vivoldi-Event-Id), dan hash SHA-256 dari body permintaan dalam format berikut: 
timestamp.eventId.payloadSha256
 Hasil hashing string ini dengan Secret Key menggunakan HMAC-SHA256 menjadi nilai v1, yang harus cocok dengan nilai header X-Vivoldi-Signature agar permintaan dianggap valid. 
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;
            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 Verification (±60 seconds)
        if (abs(time() - (int)$timestamp) > 60) {
            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 check (±180s)
        if (Math.abs(Date.now()/1000 - Number(timestamp)) > 180) 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
Webhook menghubungkan acara Tautan, Kupon, dan Stempel secara real-time ke sistem CRM, pembayaran, dan analitik Anda.
Dengan infrastruktur ketersediaan tinggi, mekanisme antrean & upaya ulang yang stabil, serta keamanan berbasis HMAC, Webhook memberikan keandalan penuh di lingkungan Enterprise.