Webhook API & Validación HMAC de Vivoldi

Una integración Webhook segura comienza con la validación de firmas mediante encabezados HTTP.

Cada solicitud Webhook de Vivoldi incluye encabezados como X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature.
La validación de estos encabezados permite bloquear solicitudes falsificadas y procesar de forma segura eventos de enlaces, cupones y recompensas.

Esta guía explica paso a paso la función de cada encabezado, el flujo de validación HMAC y ejemplos de implementación en Java, PHP y Node.js.

HTTP Header

Los Webhooks de Vivoldi envían solicitudes HTTP POST a la Callback URL registrada.
Cada solicitud incluye encabezados específicos con firmas, marcas de tiempo e identificadores de eventos, lo que permite verificar el origen de la solicitud y validar la integridad del 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 único de la solicitud. Se genera un nuevo ID en cada petición y se utiliza para identificarla de forma individual.
X-Vivoldi-Event-Id string
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-Type string
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-Type string
Enum:
URLCOUPONSTAMP
URL: enlace corto, COUPON: cupón, STAMP: sello.
X-Vivoldi-Action-Type string
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 sello

Si 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-Idx integer
IDX único de la organización.
Puede consultarse en la página [Configuración → Configuración de la organización].
X-Vivoldi-Timestamp integer
Momento de la solicitud (segundos UNIX epoch). Se recomienda una tolerancia de ±5 minutos.
X-Content-SHA256 string
Valor hash SHA-256 del cuerpo (payload) de la solicitud.
X-Vivoldi-Signature string
Información de la firma de la solicitud. Formato: t=timestamp, v1=valor de la firma, alg=algoritmo.

Entrega de Webhooks, Respuestas & Política de Reintentos

Los Webhooks de Vivoldi definen reglas claras para respuestas exitosas, reintentos automáticos y desactivación de endpoints con el objetivo de garantizar una entrega confiable de eventos.
Comprender estas políticas ayuda a evitar procesos duplicados y reducir el riesgo de pérdida de eventos.

Criterios de éxito

Estos son los criterios que utiliza Vivoldi para determinar si su servidor ha recibido correctamente una solicitud Webhook.

  • La entrega se considera exitosa cuando el servidor responde con un código HTTP 2xx (por ejemplo, 200).
  • Después de validar la firma, devuelva 200 OK inmediatamente. Dado que el tiempo límite del Webhook es de 5 segundos, las tareas prolongadas deben procesarse de forma asíncrona después de responder con 200 OK.
En entornos de alto tráfico, las respuestas lentas pueden generar reintentos y provocar eventos duplicados.

Reintentos y desactivación

Si la entrega falla, Vivoldi realiza reintentos automáticos y puede desactivar el Webhook tras fallos repetidos para evitar tráfico innecesario.

  • Se realizan hasta 5 reintentos automáticos en caso de errores de red o respuestas distintas de 2xx.
  • El Webhook se desactiva automáticamente tras 5 fallos consecutivos y se envía un correo de notificación al administrador.
  • Prevención de eventos duplicados: utilice el valor X-Vivoldi-Event-Id para detectar solicitudes duplicadas.

Las políticas pueden ajustarse según el entorno operativo.

¿Es Seguro Procesar Webhooks Sin Verificar las Firmas de los Headers?

Técnicamente, es posible procesar Webhooks utilizando únicamente el cuerpo POST (Payload). Sin embargo, en entornos de producción, la validación de headers debe implementarse siempre.
Omitir esta validación puede exponer el sistema a riesgos graves de seguridad, como solicitudes falsificadas, manipulación del payload, procesamiento duplicado y pérdida de trazabilidad.

Riesgos principales:

  • Solicitudes falsificadas (Spoofing): Un atacante puede hacerse pasar por los servidores de Vivoldi y enviar solicitudes Webhook falsas.
    Sin validación de headers, el sistema podría tratarlas erróneamente como solicitudes legítimas.
  • Manipulación de datos: Si el payload es alterado durante la transmisión, la modificación no podrá detectarse sin validación de firma.
  • Procesamiento duplicado: Los ataques de repetición pueden provocar que el mismo evento sea recibido varias veces, generando duplicados o recompensas múltiples.
  • Falta de trazabilidad: Sin los headers Request-Id o Event-Id, el seguimiento de solicitudes, la depuración y la reproducción de errores se vuelven mucho más difíciles.

Payload

{
    "cpnNo": "ZJLF0399WQBEQZJM",
    "domain": "https://vvd.bz",
    "nm": "$10 off cake coupon",
    "grpIdx": 574,
    "grpNm": "Event coupons",
    "discTypeIdx": 457,
    "discCurrency": "USD",
    "formatDiscCurrency": "$10"
    "disc": 10.0,
    "strtYmd": "2025-01-01",
    "endYmd": "2025-12-31",
    "useLimit": 1,
    "imgUrl": "https://file.vivoldi.com/coupon/2024/11/08/lmTFkqLQdCzeBuPdONKG.webp",
    "onsiteYn": "Y",
    "onsitePwd": "123456",
    "memo": "$10 off cake with coupon at the venue",
    "url": "",
    "userId": "user08",
    "userNm": "Emily",
    "userPhnno": "202-555-0173",
    "userEml": "test@gmail.com",
    "userEtc1": "",
    "userEtc2": "",
    "useCnt": 0,
    "regYmdt": "2025-08-31 18:10:22",
    "payloadVersion": "v1"
}

Payload Parameters

cpnNo string
Número de cupón.
domain string
Dominio del cupón.
nm string
Nombre del cupón.
grpIdx integer
ID de grupo. Si existe un grupo asignado, se invoca el Webhook del grupo en lugar del Global.
grpNm string
Nombre del grupo.
discTypeIdx integer
Default:457
Enum:
457458
Tipo de descuento. (457: Descuento por porcentaje %, 458: Descuento por cantidad)
discCurrency string
Default:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Moneda. Obligatorio al usar descuento por cantidad (discTypeIdx:458).
formatDiscCurrency string
Símbolo de la moneda.
disc double
Default:0
Para descuento por porcentaje (457): rango 1~100%. Para descuento por cantidad (458): valor monetario.
imgUrl string
URL de la imagen del cupón.
onsiteYn string
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.
onsitePwd string
Contraseña del cupón presencial.
Requerida al utilizar el cupón en el lugar.
memo string
Nota interna de referencia.
url string
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.
userId string
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.
userNm string
Nombre del usuario del cupón. Solo para gestión interna.
userPhnno string
Teléfono de contacto del usuario del cupón. Solo para gestión interna.
userEml string
Correo electrónico del usuario del cupón. Solo para gestión interna.
userEtc1 string
Campo adicional para gestión interna.
userEtc2 string
Campo adicional para gestión interna.
useCnt integer
Número de veces que se ha usado el cupón.
regYmdt datetime
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

stampIdx integer
Stamp IDX.
domain string
Dominio del sello.
cardIdx integer
Card IDX.
cardNm string
Nombre de la tarjeta.
cardTtl string
Título de la tarjeta.
stamps integer
Número de sellos acumulados hasta el momento.
maxStamps integer
Número máximo de sellos permitidos en la tarjeta.
stampUrl string
URL de la página del sello.
url string
URL a la que se redirige al usuario al hacer clic en el botón dentro de la página del sello.
strtYmd date
Fecha de inicio de validez del sello.
endYmd date
Fecha de expiración del sello.
onsiteYn string
Enum:
YN
Indica si la acumulación en tienda está habilitada.
Si el valor es Y, el personal de la tienda puede añadir sellos directamente en el establecimiento.
onsitePwd string
Contraseña de acumulación en tienda.
Obligatoria al usar la API de beneficios si la opción en tienda está activada (Y).
memo string
Nota interna de referencia.
activeYn string
Enum:
YN
Indica si el sello está activo.
Si está desactivado, los clientes no podrán usar el sello.
userId string
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.
userNm string
Nombre del usuario. Solo para uso interno.
userPhnno string
Teléfono del usuario. Solo para uso interno.
userEml string
Correo electrónico del usuario. Solo para uso interno.
userEtc1 string
Campo adicional para gestión interna.
userEtc2 string
Campo adicional para gestión interna.
stampImgUrl string
URL de la imagen del sello.
regYmdt datetime
Fecha de creación del sello. Ejemplo: 2025-07-21 11:50:20

Verificación de Firma Webhook & Ejemplos de Código

La autenticidad de una solicitud Webhook se verifica utilizando el header X-Vivoldi-Signature y la Secret Key emitida.

La firma se genera combinando el timestamp (t), el ID del evento (X-Vivoldi-Event-Id) y el hash SHA-256 del cuerpo de la solicitud en una cadena separada por puntos (.), para luego aplicar HMAC-SHA256 utilizando la Secret Key.

timestamp.eventId.payloadSha256

Si el valor hash generado (v1) coincide con el valor del header X-Vivoldi-Signature, la solicitud debe considerarse válida.
Si no coincide, rechace inmediatamente la solicitud y registre el incidente en los logs.


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

Optimizado para entornos empresariales que gestionan grandes volúmenes de eventos de enlaces, cupones y sellos.

Basado en infraestructura de alta disponibilidad y sistemas de colas confiables, Vivoldi ofrece integraciones estables con plataformas CRM, pagos y análisis sin pérdida de eventos, incluso durante picos repentinos de tráfico.

Actualización a Enterprise