Webhook — Panduan Integrasi
Inti dari integrasi Vivoldi Webhook adalah verifikasi HTTP Header.
Setiap permintaan menyertakan X-Vivoldi-Request-Id
, X-Vivoldi-Event-Id
, X-Vivoldi-Signature
, dan lainnya. Dengan memverifikasi nilai tersebut, event dapat diproses dengan aman.
Dokumen ini menyediakan penjelasan field header dan contoh kode, sehingga Anda dapat mengimplementasikan integrasi Webhook dengan cepat secara bertahap.
HTTP Header
Webhook mengirimkan permintaan POST ke Callback URL yang ditentukan, dan integritas serta keandalan permintaan dapat diverifikasi melalui header berikut.
HTTP Header
X-Vivoldi-Request-Id: e2ea0405b7ba4f0b9b75797179731ae0
X-Vivoldi-Event-Id: 89365c75dae740ac8500dfc48c5014b5
X-Vivoldi-Webhook-Type: GLOBAL
X-Vivoldi-Resource-Type: URL
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 per permintaan. Nilai baru diterbitkan untuk setiap permintaan.
- X-Vivoldi-Event-Idstring
- ID unik untuk event. Tetap sama pada permintaan awal dan permintaan ulang.
- X-Vivoldi-Webhook-Typestring
- Default:GLOBAL
- Enum:GLOBALGROUP
- Disetel ke GROUP jika Webhook grup diaktifkan.
- X-Vivoldi-Resource-Typestring
- Enum:URLCOUPON
- URL: Tautan pendek, COUPON: Kupon
- X-Vivoldi-Comp-Idxinteger
- ID unik organisasi.
- X-Vivoldi-Timestampinteger
- Waktu permintaan (UNIX epoch seconds). Disarankan toleransi ±1 menit.
- X-Content-SHA256string
- Nilai hash SHA-256 dari payload permintaan.
- X-Vivoldi-Signaturestring
- Informasi tanda tangan permintaan. t=timestamp, v1=nilai tanda tangan, alg=algoritma.
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, hanya dengan mem-parsing POST body Webhook tetap bisa berjalan, tetapi ini sama sekali tidak disarankan di lingkungan produksi. Melewati validasi header menimbulkan risiko kritis berikut:
Risiko Utama:
- Permintaan Palsu (Spoofing): Penyerang dapat mengirim permintaan palsu seolah-olah dari Vivoldi, dan sistem Anda mungkin mempercayainya.
- Manipulasi Data: Jika payload diubah saat transmisi, tanpa verifikasi tanda tangan perubahan tidak dapat dideteksi.
- Pemrosesan Duplikat: Serangan replay dapat mengirimkan event yang sama beberapa kali dan menyebabkan duplikasi.
- Tidak Dapat Dilacak: Tanpa header seperti Request/Event ID, pelacakan, investigasi, dan reproduksi masalah menjadi sangat 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
- 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
redirectType
adalah200
. - metaImgstring
- Mengatur nilai meta tag image saat
redirectType
adalah200
. - 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
Y
ketika 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.
Coupon Webhook will be available soon.
Verifikasi Tanda Tangan — Contoh Kode
Permintaan Webhook harus diverifikasi menggunakan header X-Vivoldi-Signature
dan Secret Key Webhook yang diterbitkan.
Tanda tangan dihitung dengan HMAC-SHA256 berdasarkan timestamp + body permintaan, dan dianggap valid hanya jika sesuai dengan nilai header.
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 static final Logger log = LoggerFactory.getLogger(WebhookController.class);
@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 signature = headers.get("x-vivoldi-signature");
// Signature Verification
if (!verifySignature(payload, signature, webhookType)) {
return ResponseEntity.status(401).body("Invalid signature");
}
// Processing by Resource Type
switch (resourceType) {
case "URL":
handleLink(payload);
break;
case "COUPON":
handleCoupon(payload);
break;
default:
log.warn("Unknown resourceType type: {}", resourceType);
}
return ResponseEntity.ok();
}
private boolean verifySignature(String payload, String signature, String webhookType) {
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) return false;
String signedPayload = timestamp + "." + payload;
String secretKey = webhookType.equals("GLOBAL") ? globalSecretKey : "";
if (secretKey.isEmpty()) {
JSONObject jsonObj = new JSONObject(payload);
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 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);
}
}
<?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'] ?? '';
$signature = $headers['x-vivoldi-signature'] ?? '';
// Signature Verification
if (!verifySignature($payload, $signature, $webhookType)) {
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;
default:
error_log('Unknown resourceType: ' . $resourceType);
}
http_response_code(200);
echo json_encode(['status' => 'success']);
}
/**
* HMAC-SHA256 Signature Verification Function
*/
function verifySignature($payload, $signature, $webhookType) {
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) return false;
// Timestamp Tolerance Verification (±60 seconds)
if (abs(time() - (int)$timestamp) > 60) {
return false;
}
$signedPayload = $timestamp . '.' . $payload;
$secretKey = getSecretKey($webhookType, $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, $payload) {
global $globalSecretKey;
if ($webhookType === 'GLOBAL') {
return $globalSecretKey;
}
// Group-Specific Secret Key Configuration
$jsonData = json_decode($payload, true);
if (!isset($jsonData['grpIdx'])) {
return '';
}
$grpIdx = $jsonData['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}");
}
}
/**
* 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 signature = headers['x-vivoldi-signature'] || '';
// Signature Verification
if (!verifySignature(payload, signature, webhookType)) {
res.status(401).json({ error: 'Invalid signature' });
return;
}
// Processing by Resource Type
switch (resourceType) {
case 'URL':
handleLink(payload);
break;
case 'COUPON':
handleCoupon(payload);
break;
default:
console.error('Unknown resourceType: ' + resourceType);
}
res.status(200).json({ status: 'success' });
}
/**
* HMAC-SHA256 Signature Verification Function
*/
function verifySignature(payload, signature, webhookType) {
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) return false;
// Timestamp check (±60s)
if (Math.abs(Date.now()/1000 - Number(timestamp)) > 60) return false;
const signedPayload = `${timestamp}.${payload}`;
// Secret Key Determination
const secretKey = getSecretKey(webhookType, 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, payload) {
if (webhookType === 'GLOBAL') {
return globalSecretKey;
}
// Group-Specific Secret Key Configuration
let jsonData;
try {
jsonData = JSON.parse(payload);
} catch (error) {
return '';
}
if (!jsonData.grpIdx) {
return '';
}
const grpIdx = jsonData.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) {
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}`);
}
}
/**
* 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'])) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = req.body.toString('utf8');
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 data real-time ke sistem CRM & pembayaran & analitik Anda.
Ketersediaan tinggi, antrean dan percobaan ulang berkinerja tinggi, serta fitur keamanan lanjutan tersedia dalam Paket Enterprise.