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: 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-Id string
ID unik untuk setiap permintaan. Dihasilkan ulang pada setiap permintaan baru dan digunakan untuk mengidentifikasi transaksi secara individual.
X-Vivoldi-Event-Id string
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-Type string
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-Type string
Enum:
URLCOUPONSTAMP
URL: tautan singkat, COUPON: kupon, STAMP: stempel.
X-Vivoldi-Action-Type string
Enum:
NONEADDREMOVEUSE

NONE: digunakan untuk acara klik tautan atau penggunaan kupon, tanpa tindakan tambahan.
ADD: menambahkan stempel
REMOVE: menghapus stempel
USE: menggunakan hadiah stempel

Jika di masa depan tindakan tambahan ditambahkan ke acara tautan atau kupon, nilai header (X-Vivoldi-Action-Type) ini dapat diperluas.

X-Vivoldi-Comp-Idx integer
IDX unik organisasi.
Dapat dilihat di halaman [Pengaturan → Pengaturan Organisasi].
X-Vivoldi-Timestamp integer
Waktu permintaan (detik UNIX epoch). Disarankan toleransi ±5 menit.
X-Content-SHA256 string
Nilai hash SHA-256 dari payload permintaan.
X-Vivoldi-Signature string
Informasi tanda tangan permintaan. Format: t=timestamp, v1=nilai tanda tangan, alg=algoritme.

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

Ini adalah standar yang digunakan Vivoldi untuk menentukan apakah server Anda berhasil menerima request Webhook.

  • Request dianggap berhasil jika server penerima mengembalikan respons HTTP 2xx (misalnya 200).
  • Setelah verifikasi signature selesai, segera kembalikan 200 OK. Karena timeout Webhook adalah 5 detik, proses yang memerlukan waktu lama sebaiknya dijalankan secara asynchronous setelah mengirim 200 OK.
Dalam lingkungan dengan traffic tinggi, keterlambatan respons dapat memicu retry dan menyebabkan event terkirim lebih dari sekali.

Percobaan Ulang & Penonaktifan

Jika pengiriman gagal, Vivoldi akan otomatis melakukan retry dan dapat menonaktifkan Webhook setelah kegagalan berulang untuk mencegah traffic yang tidak diperlukan.

  • Retry otomatis hingga 5 kali jika terjadi error jaringan atau respons non-2xx.
  • Webhook akan otomatis dinonaktifkan setelah 5 kegagalan berturut-turut, dan email notifikasi dikirim ke administrator.
  • Mencegah event duplikat: gunakan nilai X-Vivoldi-Event-Id untuk memeriksa event yang sudah diterima.

Kebijakan dapat disesuaikan sesuai lingkungan operasional.

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

{
    "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 kupon.
nm string
Nama kupon.
grpIdx integer
Indeks grup. Jika ada grup yang ditentukan, Webhook grup akan dipanggil sebagai ganti Webhook global.
grpNm string
Nama grup.
discTypeIdx integer
Default:457
Enum:
457458
Jenis diskon. (457: Diskon persentase %, 458: Diskon nominal)
discCurrency string
Default:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Mata uang. Wajib diisi jika menggunakan diskon nominal (discTypeIdx:458).
formatDiscCurrency string
Simbol mata uang.
disc double
Default:0
Jika diskon persentase (457), masukkan nilai 1–100%. Jika diskon nominal (458), masukkan jumlah uang.
imgUrl string
URL gambar kupon.
onsiteYn string
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.
onsitePwd string
Kata sandi kupon di lokasi. Diperlukan saat menggunakan kupon.
memo string
Catatan untuk referensi internal.
url string
Jika URL dimasukkan, tombol “Gunakan Kupon” akan ditampilkan di halaman kupon.
Saat tombol atau gambar kupon diklik, pengguna akan diarahkan ke URL tersebut.
userId string
Digunakan untuk mengelola penerima kupon.
Wajib diisi jika batas penggunaan kupon disetel ke 2–5 kali, biasanya diisi dengan ID login atau nama pengguna.
userNm string
Nama pengguna kupon. Untuk manajemen internal.
userPhnno string
Nomor kontak pengguna kupon. Untuk manajemen internal.
userEml string
Email pengguna kupon. Untuk manajemen internal.
userEtc1 string
Bidang tambahan untuk manajemen internal.
userEtc2 string
Bidang tambahan untuk manajemen internal.
useCnt integer
Jumlah penggunaan kupon.
regYmdt datetime
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

stampIdx integer
Stamp IDX.
domain string
Domain cap.
cardIdx integer
Card IDX.
cardNm string
Nama kartu.
cardTtl string
Judul kartu.
stamps integer
Jumlah cap yang telah dikumpulkan sejauh ini.
maxStamps integer
Jumlah maksimum cap pada kartu.
stampUrl string
URL halaman cap.
url string
URL tujuan yang akan dibuka saat tombol di halaman cap diklik.
strtYmd date
Tanggal mulai masa berlaku cap.
endYmd date
Tanggal berakhirnya masa berlaku cap.
onsiteYn string
Enum:
YN
Menunjukkan apakah penambahan cap di lokasi diaktifkan.
Jika nilainya Y, staf toko dapat menambahkan cap langsung di tempat.
onsitePwd string
Kata sandi untuk penambahan cap di lokasi.
Diperlukan saat menggunakan API penggunaan manfaat jika opsi di lokasi diaktifkan (Y).
memo string
Catatan internal untuk referensi.
activeYn string
Enum:
YN
Menunjukkan apakah cap aktif.
Jika dinonaktifkan, pelanggan tidak dapat menggunakan cap tersebut.
userId string
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.
userNm string
Nama pengguna. Hanya untuk keperluan internal.
userPhnno string
Nomor telepon pengguna. Hanya untuk keperluan internal.
userEml string
Alamat email pengguna. Hanya untuk keperluan internal.
userEtc1 string
Kolom tambahan untuk manajemen internal.
userEtc2 string
Kolom tambahan untuk manajemen internal.
stampImgUrl string
URL gambar cap.
regYmdt datetime
Tanggal pembuatan cap. Contoh: 2025-07-21 11:50:20

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;

            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

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