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 stempelJika 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.
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-Iduntuk 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
{
"linkId": "202509-event",
"domain": "https://event.com",
"compIdx": 50142,
"redirectType": 200,
"url": "https://my-event.com/books/event/202509",
"ttl": "September 2025 Event",
"description": "The 2025 National Book Festival will be held in the nation's capital at the Walter E.",
"metaImg": "https://my-event.com/storage-services/media/webcasts/2025/2509_thumbnail_00145901.jpg",
"memo": "",
"grpIdx": 0,
"grpNm": "",
"strtYmdt": "2025-09-01 00:00:00",
"endYmdt": "2025-09-30 23:59:59",
"expireYn": "Y",
"expireUrl": "https://my-event.com/books/event/closed",
"acesCnt": 17502,
"pernCnt": 16491,
"acesMaxCnt": 20000,
"referer": "https://www.google.com",
"queryString": "",
"country": "US",
"language": "en",
"regYmdt": "2025-08-31 18:10:22",
"modYmdt": "2025-08-31 18:10:22",
"payloadVersion": "v1"
}
Payload Parameters
- linkId string
- ID tautan.
- domain string
- Domain tautan.
- redirectType integer
-
Enum:
200301302
- Jenis pengalihan. Detail lebih lanjut dapat dilihat di halaman istilah utama.
- url string
- URL asli.
- ttl string
- Judul tautan.
- description string
-
Mengatur nilai meta tag description saat
redirectTypeadalah200. - metaImg string
-
Mengatur nilai meta tag image saat
redirectTypeadalah200. - memo string
- Catatan untuk pengelolaan tautan.
- grpIdx integer
- IDX grup. Jika grup ditentukan, Webhook grup dipanggil sebagai gantinya.
- grpNm string
- Nama grup.
- strtYmdt datetime
- Tanggal/waktu mulai berlaku tautan.
- ednYmdt datetime
- Tanggal/waktu berakhirnya masa berlaku tautan.
- expireYn string
- Default:N
-
Enum:
YN
-
Dikirim sebagai
Yketika tautan sudah kedaluwarsa. - expireUrl string
- URL tujuan setelah kedaluwarsa.
- acesCnt integer
- Jumlah total klik.
- pernCnt integer
- Jumlah klik unik (pengguna unik).
- acesMaxCnt integer
- Jumlah maksimum klik yang diperbolehkan. Akses diblokir setelah melebihi batas.
- referer string
- URL halaman tempat permintaan berasal.
- queryString string
- Query string yang disertakan saat mengakses tautan pendek.
- country string
- Kode negara pengguna (ISO-3166).
- language string
- Kode bahasa pengguna (ISO-639).
- regYmdt datetime
- Tanggal/waktu pembuatan tautan.
- modYmdt datetime
- Tanggal/waktu modifikasi tautan.
- payloadVersion string
- 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
- 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 nilainyaY, 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.