Webhook — Guía de integración
El núcleo de la integración de Webhook de Vivoldi es la verificación de la firma en las cabeceras HTTP.
Cada solicitud de Webhook incluye X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature, entre otros,
y al validarlos podrás procesar con seguridad los eventos de Enlaces, Cupones y Sellos.
Esta guía explica la función de cada cabecera y el procedimiento paso a paso de verificación de firma, y ofrece código de ejemplo para integrar las solicitudes de Webhook de forma rápida y segura.
HTTP Header
El Webhook envía una solicitud POST a la URL de callback designada, permitiendo verificar la integridad y autenticidad de cada solicitud mediante cabeceras como X-Vivoldi-Signature y X-Vivoldi-Timestamp.
HTTP Header
X-Vivoldi-Request-Id: e2ea0405b7ba4f0b9b75797179731ae0
X-Vivoldi-Event-Id: 89365c75dae740ac8500dfc48c5014b5
X-Vivoldi-Webhook-Type: GLOBAL
X-Vivoldi-Resource-Type: URL
X-Vivoldi-Action-Type: NONE
X-Vivoldi-Comp-Idx: 50742
X-Vivoldi-Timestamp: 1758184391752
X-Content-SHA256: e040abf9ac2826bc108fce0117e49290086743733ad9db2fa379602b4db9792c
X-Vivoldi-Signature: t=1758184391752,v1=b610f699d4e7964cdb7612111f5765576920b680e7c33c649e20608406807aaf,alg=hmac-sha256
Request Parameters
- X-Vivoldi-Request-Idstring
- ID único de la solicitud. Se genera un nuevo ID en cada petición y se utiliza para identificarla de forma individual.
- X-Vivoldi-Event-Idstring
- ID único del evento.
Si la primera solicitud falla y se reintenta, el mismo Event-Id se conserva para evitar el procesamiento duplicado del mismo evento. - X-Vivoldi-Webhook-Typestring
- Default:GLOBAL
- Enum:GLOBALGROUP
- Si el Webhook de tipo GROUP está habilitado, este valor se establece como GROUP.
Los eventos de sello (STAMP) siempre usan GROUP, ya que funcionan por tarjeta de sellos.
Los eventos de enlace y cupón se envían como GLOBAL cuando no hay un Webhook de grupo configurado. - X-Vivoldi-Resource-Typestring
- Enum:URLCOUPONSTAMP
- URL: enlace corto, COUPON: cupón, STAMP: sello.
- X-Vivoldi-Action-Typestring
- Enum:NONEADDREMOVEUSE
NONE: usado para eventos de clic en enlace o uso de cupón, sin acciones adicionales.
ADD: añadir sello
REMOVE: eliminar sello
USE: canjear beneficio del selloSi en el futuro se agregan nuevas acciones a los eventos de enlace o cupón, este valor de cabecera (
X-Vivoldi-Action-Type) podrá ampliarse.- X-Vivoldi-Comp-Idxinteger
- IDX único de la organización.
Puede consultarse en la página [Configuración → Configuración de la organización]. - X-Vivoldi-Timestampinteger
- Momento de la solicitud (segundos UNIX epoch). Se recomienda una tolerancia de ±5 minutos.
- X-Content-SHA256string
- Valor hash SHA-256 del cuerpo (payload) de la solicitud.
- X-Vivoldi-Signaturestring
- Información de la firma de la solicitud. Formato: t=timestamp, v1=valor de la firma, alg=algoritmo.
Política de transmisión · respuesta · reintentos
Criterios de éxito
- Se considera exitoso si el servidor receptor devuelve una respuesta HTTP 2xx (ejemplo: 200).
- Después de verificar la firma, devuelva 200 OK de inmediato. El tiempo de espera es de 5 segundos, por lo que los procesos largos deben ejecutarse de forma asíncrona después de responder.
Reintentos y desactivación
- Hasta 5 reintentos en caso de errores de red o respuestas que no sean 2xx.
- Si hay 5 fallos consecutivos, el Webhook se desactiva automáticamente y se envía un correo de alerta al administrador.
- Prevención de duplicados: verifique duplicados usando el valor de
X-Vivoldi-Event-Id.
Las políticas pueden ajustarse según el entorno de operación.
¿Se puede procesar un Webhook sin validación de encabezados?
Técnicamente basta con procesar solo el cuerpo POST (payload), pero en producción siempre debe realizarse la verificación de las cabeceras.
Omitir la verificación de cabeceras expone a riesgos de seguridad graves como solicitudes falsificadas, manipulación del payload, procesamiento duplicado y falta de trazabilidad.
Riesgos principales:
- Falsificación de solicitudes (spoofing): Un atacante puede suplantar a Vivoldi y enviar solicitudes falsificadas.
Sin verificación de cabeceras, el sistema puede tomar estas peticiones como legítimas. - Manipulación de datos: Si el payload se altera en tránsito, la falta de verificación de la firma impide detectar la manipulación.
- Procesamiento duplicado: Los ataques de repetición pueden provocar que el mismo evento se reciba varias veces, causando duplicados o acumulación doble.
- Falta de trazabilidad: Sin Request-Id o Event-Id en las cabeceras, resulta imposible rastrear solicitudes, analizar errores o reproducir incidentes.
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 del enlace.
- domainstring
- Dominio del enlace.
- redirectTypeinteger
- Enum:200301302
- Tipo de redirección. Para más detalles, consulte la página de términos clave.
- urlstring
- URL original.
- ttlstring
- Título del enlace.
- descriptionstring
- Establece la metaetiqueta description cuando
redirectTypees200. - metaImgstring
- Establece la metaetiqueta image cuando
redirectTypees200. - memostring
- Nota para la gestión del enlace.
- grpIdxinteger
- IDX del grupo. Si se especifica un grupo, se activa el Webhook del grupo en lugar del global.
- grpNmstring
- Nombre del grupo.
- strtYmdtdatetime
- Fecha/hora de inicio de validez del enlace.
- ednYmdtdatetime
- Fecha/hora de expiración del enlace.
- expireYnstring
- Default:N
- Enum:YN
- Se devuelve como
Ycuando el enlace ha expirado. - expireUrlstring
- URL a la que se redirige tras la expiración.
- acesCntinteger
- Número total de clics.
- pernCntinteger
- Número de clics únicos (usuarios únicos).
- acesMaxCntinteger
- Número máximo de clics permitidos. El acceso se bloquea una vez superado.
- refererstring
- URL de la página donde se originó la solicitud.
- queryStringstring
- Cadena de consulta incluida al acceder al enlace corto.
- countrystring
- Código de país del usuario (ISO-3166).
- languagestring
- Código de idioma del usuario (ISO-639).
- regYmdtdatetime
- Fecha/hora de creación del enlace.
- modYmdtdatetime
- Fecha/hora de modificación del enlace.
- payloadVersionstring
- Versión del payload. Se incrementa cuando se realizan cambios posteriores.
{
"cpnNo": "ZJLF0399WQBEQZJM",
"domain": "https://vvd.bz",
"nm": "$10 off cake coupon",
"grpIdx": 574,
"grpNm": "Event coupons",
"discTypeIdx": 457,
"discCurrency": "USD",
"formatDiscCurrency": "$10"
"disc": 10.0,
"strtYmd": "2025-01-01",
"endYmd": "2025-12-31",
"useLimit": 1,
"imgUrl": "https://file.vivoldi.com/coupon/2024/11/08/lmTFkqLQdCzeBuPdONKG.webp",
"onsiteYn": "Y",
"onsitePwd": "123456",
"memo": "$10 off cake with coupon at the venue",
"url": "",
"userId": "user08",
"userNm": "Emily",
"userPhnno": "202-555-0173",
"userEml": "test@gmail.com",
"userEtc1": "",
"userEtc2": "",
"useCnt": 0,
"regYmdt": "2025-08-31 18:10:22",
"payloadVersion": "v1"
}Payload Parameters
- cpnNostring
- Número de cupón.
- domainstring
- Dominio del cupón.
- nmstring
- Nombre del cupón.
- grpIdxinteger
- ID de grupo. Si existe un grupo asignado, se invoca el Webhook del grupo en lugar del Global.
- grpNmstring
- Nombre del grupo.
- discTypeIdxinteger
- Default:457
- Enum:457458
- Tipo de descuento. (457: Descuento por porcentaje %, 458: Descuento por cantidad)
- discCurrencystring
- Default:KRW
- Enum:KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
- Moneda. Obligatorio al usar descuento por cantidad (discTypeIdx:458).
- formatDiscCurrencystring
- Símbolo de la moneda.
- discdouble
- Default:0
- Para descuento por porcentaje (457): rango 1~100%. Para descuento por cantidad (458): valor monetario.
- imgUrlstring
- URL de la imagen del cupón.
- onsiteYnstring
- Default:N
- Enum:YN
- ¿Cupón presencial? Indica si se muestra el botón
“Usar cupón”en la página del cupón.
Necesario cuando el cupón se utiliza en una tienda física. - onsitePwdstring
- Contraseña del cupón presencial.
Requerida al utilizar el cupón en el lugar. - memostring
- Nota interna de referencia.
- urlstring
- Al ingresar una URL, se muestra el botón
“Ir a usar el cupón”en la página del cupón.
Al hacer clic en el botón o en la imagen del cupón, se redirige a la URL. - userIdstring
- Se utiliza para gestionar al destinatario del cupón.
Obligatorio si el límite de uso se establece entre 2 y 5. Generalmente se ingresa el ID de inicio de sesión del usuario o un nombre en inglés. - userNmstring
- Nombre del usuario del cupón. Solo para gestión interna.
- userPhnnostring
- Teléfono de contacto del usuario del cupón. Solo para gestión interna.
- userEmlstring
- Correo electrónico del usuario del cupón. Solo para gestión interna.
- userEtc1string
- Campo adicional para gestión interna.
- userEtc2string
- Campo adicional para gestión interna.
- useCntinteger
- Número de veces que se ha usado el cupón.
- regYmdtdatetime
- Fecha de creación del cupón. Ejemplo: 2025-07-21 11:50:20
{
"stampIdx": 16,
"domain": "https://vvd.bz",
"cardIdx": 1,
"cardNm": "Accumulate 10 Americanos",
"cardTtl": "Collect 10 stamps to get one free Americano.",
"stamps": 10,
"maxStamps": 12,
"stampUrl": "https://vvd.bz/stamp/274",
"url": "https://myshopping.com",
"strtYmd": "2025-01-01",
"endYmd": "2026-12-31",
"onsiteYn": "Y",
"onsitePwd": "123456",
"memo": null,
"activeYn": "Y",
"userId": "NKkDu9X4p4mQ",
"userNm": null,
"userPhnno": null,
"userEml": null,
"userEtc1": null,
"userEtc2": null,
"stampImgUrl": "https://cdn.vivoldi.com/www/image/icon/stamp/icon.stamp.1.webp",
"regYmdt": "2025-10-30 05:11:35",
"payloadVersion": "v1"
}Payload Parameters
- stampIdxinteger
- Stamp IDX.
- domainstring
- Dominio del sello.
- cardIdxinteger
- Card IDX.
- cardNmstring
- Nombre de la tarjeta.
- cardTtlstring
- Título de la tarjeta.
- stampsinteger
- Número de sellos acumulados hasta el momento.
- maxStampsinteger
- Número máximo de sellos permitidos en la tarjeta.
- stampUrlstring
- URL de la página del sello.
- urlstring
- URL a la que se redirige al usuario al hacer clic en el botón dentro de la página del sello.
- strtYmddate
- Fecha de inicio de validez del sello.
- endYmddate
- Fecha de expiración del sello.
- onsiteYnstring
- Enum:YN
- Indica si la acumulación en tienda está habilitada.
Si el valor esY, el personal de la tienda puede añadir sellos directamente en el establecimiento. - onsitePwdstring
- Contraseña de acumulación en tienda.
Obligatoria al usar la API de beneficios si la opción en tienda está activada (Y). - memostring
- Nota interna de referencia.
- activeYnstring
- Enum:YN
- Indica si el sello está activo.
Si está desactivado, los clientes no podrán usar el sello. - userIdstring
- ID del usuario. Se usa para administrar el destinatario del sello.
Normalmente corresponde al ID de inicio de sesión del miembro del sitio web.
Si no se establece, el sistema generará automáticamente un ID de usuario. - userNmstring
- Nombre del usuario. Solo para uso interno.
- userPhnnostring
- Teléfono del usuario. Solo para uso interno.
- userEmlstring
- Correo electrónico del usuario. Solo para uso interno.
- userEtc1string
- Campo adicional para gestión interna.
- userEtc2string
- Campo adicional para gestión interna.
- stampImgUrlstring
- URL de la imagen del sello.
- regYmdtdatetime
- Fecha de creación del sello. Ejemplo: 2025-07-21 11:50:20
Verificación de firma — Ejemplo de código
Las solicitudes Webhook deben verificarse utilizando el encabezado X-Vivoldi-Signature y la clave secreta (Secret Key) emitida.
La firma se genera combinando la marca de tiempo (t), el ID del evento (X-Vivoldi-Event-Id) y el hash SHA-256 del cuerpo de la solicitud en el siguiente formato:
timestamp.eventId.payloadSha256
El resultado de aplicar HMAC-SHA256 a esta cadena con la clave secreta se convierte en el valor v1, que debe coincidir con el encabezado X-Vivoldi-Signature para que la solicitud se considere válida.
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}`);
});
✨ Integración en tiempo real de nivel empresarial
El Webhook integra en tiempo real los eventos de Enlaces, Cupones y Sellos con tus sistemas de CRM, pago y análisis.
Con una infraestructura de alta disponibilidad, mecanismos confiables de cola y reintento, y seguridad basada en HMAC, ofrece una fiabilidad total en entornos Enterprise.