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-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.
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
- 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 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.