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: CLICK
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 para identificar la solicitud. Se genera uno nuevo para cada solicitud HTTP y se utiliza para rastrear solicitudes específicas.
X-Vivoldi-Event-Id string
ID único para identificar un evento. El mismo Event ID se mantiene incluso cuando un evento se reintenta, lo que permite al sistema receptor evitar eventos duplicados.
X-Vivoldi-Webhook-Type string
Default:GLOBAL
Enum:
GLOBALGROUP
Indica el alcance de aplicación del Webhook.
GROUP: Se utiliza cuando se aplica un Webhook de grupo.
Los eventos de sellos solo son compatibles con Webhooks de grupo, por lo que siempre se envían como GROUP.
Los eventos de enlaces y cupones se envían como GLOBAL cuando no existe un Webhook de grupo configurado.
X-Vivoldi-Resource-Type string
Enum:
URLCOUPONSTAMP
Tipo de recurso asociado al evento.
URL: URL corta
COUPON: Cupón
STAMP: Sello
X-Vivoldi-Action-Type string
Enum:
CLICKUSEADDREMOVE
Tipo de acción que generó el evento.

CLICK: Clic en enlace
USE: Uso de cupón, canje de recompensa de sellos
ADD: Sello añadido
REMOVE: Sello eliminado

Utilícelo junto con Resource-Type para identificar correctamente el tipo de evento.

X-Vivoldi-Comp-Idx integer
IDX identificador de la organización. Puede consultarlo en la página [Configuración → Configuración de la organización].
X-Vivoldi-Timestamp integer
Hora de creación de la solicitud. Se proporciona en formato UNIX epoch seconds. Se recomienda un margen de error de ±5 minutos para considerar las diferencias horarias entre servidores.
X-Content-SHA256 string
Valor hash SHA-256 del payload de la solicitud. Puede utilizarse para verificar la integridad del payload.
X-Vivoldi-Signature string
Información de firma utilizada para validar la solicitud. Incluye t: timestamp, v1: valor de firma, y alg: algoritmo de firma.

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

El éxito de una solicitud Webhook se determina según el código de estado HTTP devuelto por el servidor receptor.

  • Una respuesta HTTP 2xx se considera exitosa.
    Se aceptan todas las respuestas 2xx, incluidas 200, 202 y 204. El contenido del cuerpo de la respuesta no se valida.
  • El tiempo de espera de la respuesta es de 5 segundos.
    Después de verificar la firma, recomendamos devolver inmediatamente una respuesta 2xx y gestionar el procesamiento real de forma asíncrona.
  • No se siguen las redirecciones HTTP. Las respuestas como 301 y 302 se consideran fallos, por lo que debes registrar la URL de Callback final.
Si la respuesta tarda más de 5 segundos o devuelve un código de estado distinto de 2xx, puede producirse un reintento y el mismo evento puede entregarse varias veces.

Reintentos y desactivación

Cuando falla la entrega, Webhook realiza reintentos automáticamente. Si se producen fallos repetidos, el estado del Webhook cambia a Desactivado por el sistema para evitar intentos de entrega innecesarios.

  • Los reintentos se realizan para todos los códigos de respuesta HTTP. Las respuestas como 400, 404 y 401 siguen la misma política de reintentos.
  • Durante los reintentos, X-Vivoldi-Event-Id se mantiene sin cambios. El servidor receptor debe utilizar este valor para evitar el procesamiento duplicado de eventos.
  • Incluso después de 5 intentos de reintento fallidos, el Webhook no se desactiva inmediatamente. Primero se envía una notificación por correo electrónico y se proporciona un período de gracia de 60 minutos. Si no se recupera durante este período, el estado del Webhook cambia a Desactivado por el sistema.

Los Webhooks con estado Desactivado por el sistema pueden encontrarse mediante el filtro Desactivado por el sistema en la lista del panel y volver a activarse.

Etapa Momento Acción
Intentos 1–3 Inmediatamente · Después de 1 s · Después de 2 s Se realizan reintentos inmediatos para solucionar errores temporales de red.
Intento 4 Después de 10 min Se realiza un nuevo intento considerando el tiempo necesario para reinicios del servidor receptor o la recuperación de problemas temporales.
Intento 5 Después de 30 min Se realiza el último intento de entrega. Si falla, los reintentos automáticos finalizan.
Correo de advertencia Inmediatamente después de 5 fallos El Webhook no se desactiva inmediatamente. Tras el quinto fallo comienza un período de gracia de 60 minutos y se envía una notificación por correo electrónico. Según el ciclo de procesamiento de notificaciones, puede producirse un retraso de hasta 10 minutos.
Período de gracia 30 min–90 min Si el servidor se recupera durante el período de gracia de 60 minutos, la entrega del Webhook se reanuda sin desactivación del sistema.
Desactivación del sistema Después de 90 min Si el primer intento de entrega después del período de gracia también falla, el estado del Webhook cambia a Desactivado por el sistema.

Si se producen fallos repetidos desde la misma URL de Callback, el envío se restringe temporalmente para evitar que las solicitudes se acumulen continuamente hasta que el servidor receptor se recupere.
Las interrupciones breves, como despliegues o problemas temporales, se reanudan automáticamente después de la recuperación.

Los eventos de uso de cupones y sellos nunca se pierden.
Como son eventos importantes que solo ocurren una vez, se almacenan en una cola durante los reintentos y el período de gracia, y después se entregan en orden.
Los eventos de clic en enlaces se producen repetidamente y los datos analíticos se almacenan en Vivoldi. Por ello, estos eventos no se almacenan por separado ni se vuelven a enviar cuando falla la entrega del Webhook.

Guía de implementación del servidor receptor de Webhooks

  • El mismo evento puede entregarse varias veces.
    El mismo evento puede entregarse más de una vez debido a reintentos o condiciones de red. Guarda X-Vivoldi-Event-Id y devuelve 200 OK sin realizar procesamiento adicional si el evento ya ha sido procesado.
    Esto es especialmente importante para operaciones que no deben procesarse varias veces, como el uso de cupones o la acumulación de sellos.
  • El orden de los eventos no está garantizado.
    Un evento reenviado puede llegar después de otro evento generado posteriormente.
    Si necesitas considerar el orden de los eventos, utiliza los valores regYmdt y modYmdt del Payload como referencia.
  • Se recomienda separar la respuesta del procesamiento real.
    Realizar operaciones de base de datos o llamadas a API externas antes de enviar la respuesta puede superar el límite de espera de 5 segundos.
    Recomendamos implementar el flujo como: verificación de firma → respuesta 200 OK → procesamiento mediante cola interna.
  • Verifica la firma utilizando el cuerpo original de la solicitud.
    Analizar el JSON y volver a serializarlo puede cambiar el valor hash debido a diferencias en espacios o en el orden de las claves.
    Si tu framework transforma automáticamente el cuerpo de la solicitud, debes obtener por separado el raw body.
  • Ignora los campos desconocidos.
    En el futuro pueden añadirse nuevos campos al Payload. Implementa la integración para ignorar los campos que no reconozca.
  • La Secret Key depende del destino del Webhook.
    Si X-Vivoldi-Webhook-Type es GLOBAL, verifica la firma utilizando la Secret Key global. Si es GROUP, verifica la firma utilizando la Secret Key configurada para el grupo o tarjeta de sellos correspondiente.

¿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

Momento de activación del evento

Coupon Webhook envía información del evento a la URL de Callback configurada cuando se produce un evento de uso de cupón.

Webhook puede configurarse para cupones individuales o grupos de cupones.
Si ambos están configurados, la configuración del grupo de cupones tiene prioridad, y el mismo evento no se envía varias veces. Coupon Webhook para grupos de cupones está disponible en el plan Business o superior.

El evento se envía inmediatamente después de procesar el uso del cupón y el valor de X-Vivoldi-Action-Type es USE.
El evento se envía de la misma forma independientemente de si el cupón se utiliza desde el panel, la API o mediante procesamiento offline.

El uso de un cupón es un evento único para cada cupón y no puede recuperarse si se pierde.
Cuando se supera el límite de llamadas o hay un reintento pendiente, los eventos se almacenan en una cola y se entregan en orden.
Cuando se procesan varios cupones a la vez mediante la API, la entrega de eventos puede realizarse de forma secuencial en varias operaciones.
{
    "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 de la página del cupón.
nm string
Nombre del cupón.
grpIdx integer
IDX del grupo al que pertenece el cupón. Si el cupón no pertenece a ningún grupo, el valor es 0.
Cuando se configura un Webhook de grupo, la configuración del grupo tiene prioridad y el valor de X-Vivoldi-Webhook-Type se envía como GROUP.
Si no existe un Webhook de grupo configurado, el envío se realiza según la configuración individual del cupón.
grpNm string
Nombre del grupo de cupones.
discTypeIdx integer
Enum:
457458
Tipo de descuento.
457: Descuento porcentual (%)
458: Descuento por importe fijo
discCurrency string
Default:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Unidad monetaria utilizada para el importe del descuento. Es obligatorio cuando se utiliza un descuento por importe fijo (discTypeIdx=458).
formatDiscCurrency string
Formato de visualización de moneda.
disc double
Default:0
Valor del descuento.
Para descuentos porcentuales (457), el valor debe estar entre 1~100%, mientras que para descuentos por importe fijo (458) representa el importe descontado.
strtYmd date
Fecha de inicio de validez del cupón.
endYmd date
Fecha de vencimiento del cupón.
useLimit integer
Default:1
Enum:
012345
Número de usos permitidos del cupón.
0: Sin límite
1~5: Puede utilizarse el número de veces configurado
imgUrl string
URL de la imagen del cupón.
onsiteYn string
Default:N
Enum:
YN
Indica si se admite el uso del cupón en tienda. Si el valor es Y, se muestra el botón Usar cupón en la página del cupón y el cupón puede utilizarse en una tienda física tras la verificación del empleado.
onsitePwd string
Contraseña utilizada para autenticar el uso del cupón en tienda.
Como se incluye en texto sin cifrar dentro del Payload, no debe almacenarse en los registros del servidor receptor.
memo string
Nota interna.
url string
Si se configura, se muestra el botón Ir a usar cupón en la página del cupón.
Al hacer clic en el botón o en la imagen del cupón, el usuario accede a esta URL.
userId string
ID utilizado para identificar al usuario del cupón.
Es obligatorio cuando el límite de uso del cupón está configurado entre 2~5. Normalmente se utiliza el ID de miembro del servicio o un identificador de cliente.
userNm string
Nombre del usuario del cupón. Se utiliza para la gestión interna y la identificación.
userPhnno string
Información de contacto del usuario del cupón. Se utiliza para la gestión interna y la identificación.
userEml string
Correo electrónico del usuario del cupón. Se utiliza para la gestión interna y la identificación.
userEtc1 string
Campo adicional para gestión interna.
userEtc2 string
Campo adicional para gestión interna.
useCnt integer
Número actual de usos del cupón. El evento de uso actual todavía no está incluido en este valor.
Si necesita incluir el uso actual, calcúlelo como useCnt + 1.
regYmdt datetime
Fecha y hora de creación del cupón. Ejemplo: 2025-07-21 11:50:20
payloadVersion string
Versión de la especificación del Payload. Aunque se agreguen nuevos campos, el significado y comportamiento de los campos existentes se mantienen hasta que este valor cambie.

Momento de activación del evento

Webhook se configura en la tarjeta de sellos. Todos los eventos de sellos generados desde la tarjeta se envían.

Se envía cuando se producen eventos de adición, eliminación o canje de recompensas. El tipo de evento se identifica mediante el valor del encabezado X-Vivoldi-Action-Type.

  • ADD — Sello añadido
  • REMOVE — Sello eliminado
  • USE — Canje de recompensa de sellos

Independientemente de si el cambio se realiza desde el panel, API, pantalla de gestión de sellos u otro método, el evento se envía con el mismo tipo de evento.

changedStamps representa la cantidad de sellos afectados. Si los sellos aumentan o disminuyen se determina mediante el valor de X-Vivoldi-Action-Type.
El canje de recompensa (USE) no modifica la cantidad de sellos, por lo que se envía como 0.

El significado del valor stamps depende de cómo se genera el evento.
En los eventos de adición, eliminación y canje de recompensas mediante API, stamps representa la cantidad de sellos antes del cambio. El valor posterior se puede calcular como stamps + changedStamps. En el caso de REMOVE, se debe restar changedStamps.
Cuando el cambio se realiza desde la pantalla de gestión de sellos del panel, stamps representa la cantidad de sellos después del cambio.
Para calcular correctamente la cantidad actual de sellos, utiliza el valor anterior al evento y changedStamps para calcular el valor posterior.
{
    "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,
    "changedStamps": 2,
    "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
IDX identificador del sello.
domain string
Dominio de la página de sellos.
cardIdx integer
IDX identificador de la tarjeta de sellos.
cardNm string
Nombre de la tarjeta de sellos.
cardTtl string
Título de la tarjeta de sellos.
stamps integer
Cantidad actual de sellos. Sin embargo, el punto de referencia depende de cómo se generó el evento.
En eventos de adición, eliminación y canje de recompensas mediante API, representa la cantidad de sellos antes del cambio. El valor posterior al cambio puede calcularse utilizando stamps y changedStamps.
(ADD: sello añadido, REMOVE: sello eliminado)
Cuando se modifica directamente desde la pantalla de gestión de sellos del panel, representa la cantidad de sellos después del cambio.
maxStamps integer
Número máximo de sellos de la tarjeta de sellos.
changedStamps integer
Cantidad de sellos modificados por este evento. El aumento o la disminución se determina mediante el valor de X-Vivoldi-Action-Type.
El canje de recompensa (USE) no modifica la cantidad de sellos, por lo que el valor es 0.
stampUrl string
URL de la página de sellos.
url string
URL a la que se accede al hacer clic en un botón de la página de sellos.
strtYmd date
Fecha de inicio de validez de los sellos.
endYmd date
Fecha de vencimiento de validez de los sellos.
onsiteYn string
Enum:
YN
Indica si se admite la acumulación de sellos en tienda. Si el valor es Y, los empleados pueden verificar al cliente y añadir sellos en el establecimiento.
onsitePwd string
Contraseña utilizada para verificar la acumulación de sellos en tienda o el uso de recompensas.
Es necesaria para las llamadas API relacionadas cuando la acumulación de sellos en tienda está activada (onsiteYn=Y).
memo string
Nota interna de referencia.
activeYn string
Enum:
YN
Indica si la tarjeta de sellos está activa. Si está desactivada, los clientes no pueden utilizar la tarjeta de sellos.
userId string
ID de usuario utilizado para identificar al usuario de la tarjeta de sellos.
Normalmente se utiliza el ID de miembro del servicio o un identificador de cliente.
Si no se configura, Vivoldi lo genera automáticamente.
userNm string
Nombre del usuario de la tarjeta de sellos. Se utiliza para la gestión interna y la identificación.
userPhnno string
Información de contacto del usuario de la tarjeta de sellos. Se utiliza para la gestión interna y la identificación.
userEml string
Dirección de correo electrónico del usuario de la tarjeta de sellos. Se utiliza para la gestión interna y la identificación.
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 y hora de creación del sello. Ejemplo: 2025-07-21 11:50:20
payloadVersion string
Versión de la especificación del Payload. Aunque se agreguen nuevos campos, el significado y comportamiento de los campos existentes se mantienen hasta que este valor cambie.

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;

            // Timestamp tolerance (±5 minutes)
            // X-Vivoldi-Timestamp is in MILLISECONDS, so compare against System.currentTimeMillis().
            if (Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)) > 300_000L) {
                log.warn("Webhook timestamp out of tolerance: {}", timestamp);
                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 (±5 minutes)
        // X-Vivoldi-Timestamp is in MILLISECONDS, so compare against time() * 1000.
        if (abs(time() * 1000 - (int)$timestamp) > 300000) {
            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 tolerance (±5 minutes)
        // X-Vivoldi-Timestamp is in MILLISECONDS, so compare against Date.now() directly.
        if (Math.abs(Date.now() - Number(timestamp)) > 300000) 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