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 comoGROUP.
Los eventos de enlaces y cupones se envían comoGLOBALcuando 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 eliminadoUtilícelo junto con
Resource-Typepara 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, yalg: 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, incluidas200,202y204. 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 respuesta2xxy gestionar el procesamiento real de forma asíncrona. -
No se siguen las redirecciones HTTP.
Las respuestas como
301y302se consideran fallos, por lo que debes registrar la URL de Callback final.
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,404y401siguen la misma política de reintentos. -
Durante los reintentos,
X-Vivoldi-Event-Idse 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. GuardaX-Vivoldi-Event-Idy devuelve200 OKsin 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 valoresregYmdtymodYmdtdel 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 → respuesta200 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.
SiX-Vivoldi-Webhook-TypeesGLOBAL, verifica la firma utilizando la Secret Key global. Si esGROUP, 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
Link Webhook envía información del evento a la URL de Callback configurada cuando se produce un evento de clic en una URL acortada.
Webhook puede configurarse para enlaces individuales o grupos de enlaces.
Si ambos están configurados, la configuración del grupo de enlaces tiene prioridad,
y los criterios de envío y el intervalo de envío siguen la configuración del grupo. El mismo evento no se envía varias veces.
El valor de X-Vivoldi-Action-Type es CLICK.
Link Webhook para grupos de enlaces es una función exclusiva del plan Enterprise.
Puedes seleccionar como criterio de envío el número de clics o el número de visitantes, y Webhook se enviará cada vez que se alcance el umbral acumulado configurado.
Por ejemplo, si el criterio de envío es el número de clics y el intervalo de envío está configurado cada 100 clics, Webhook se enviará cuando el número acumulado de clics alcance 100, 200, 300, y así sucesivamente.
{
"linkId": "202509-event",
"domain": "https://event.com",
"compIdx": 50142,
"redirectType": 200,
"url": "https://my-event.com/books/event/202509",
"ttl": "September 2025 Event",
"description": "The 2025 National Book Festival will be held in the nation's capital at the Walter E.",
"metaImg": "https://my-event.com/storage-services/media/webcasts/2025/2509_thumbnail_00145901.jpg",
"memo": "",
"grpIdx": 0,
"grpNm": "",
"strtYmdt": "2025-09-01 00:00:00",
"endYmdt": "2025-09-30 23:59:59",
"expireYn": "Y",
"expireUrl": "https://my-event.com/books/event/closed",
"acesCnt": 17502,
"pernCnt": 16491,
"acesMaxCnt": 20000,
"referer": "https://www.google.com",
"queryString": "",
"country": "US",
"language": "en",
"regYmdt": "2025-08-31 18:10:22",
"modYmdt": "2025-08-31 18:10:22",
"payloadVersion": "v1"
}
Payload Parameters
- linkId string
- ID identificador del enlace.
- domain string
- Dominio del enlace.
- compIdx integer
-
IDX de la organización.
Este valor es igual al valor del encabezado
X-Vivoldi-Comp-Idx. - redirectType integer
-
Enum:
200301302
-
Método de redirección del enlace.
200: Modo de visualización de página
301: Redirección permanente
302: Redirección temporal
Para más información, consulte la página de Terminología. - url string
- URL original.
- ttl string
- Título del enlace.
- description string
-
Valor de la etiqueta meta description utilizado cuando
redirectTypees200. - metaImg string
-
URL de la imagen de la etiqueta meta utilizada cuando
redirectTypees200. - memo string
- Nota para la gestión del enlace.
- grpIdx integer
-
IDX del grupo de enlaces.
Si el grupo de enlaces tiene configurado un Webhook, el Webhook del grupo tiene prioridad sobre la configuración individual del enlace. - grpNm string
- Nombre del grupo de enlaces.
- strtYmdt datetime
- Fecha y hora de inicio de validez del enlace.
- endYmdt datetime
- Fecha y hora de finalización de validez del enlace.
- expireYn string
-
Enum:
YN
-
Indica si el período de validez del enlace ha expirado.
Si el enlace ha caducado, se envía el valor
Y. - expireUrl string
- URL a la que se redirige después de que el enlace expire.
- acesCnt integer
-
Número acumulado de clics.
Este valor incluye el evento de clic actual.
La condición de envío del Webhook también se evalúa según este valor. Por ejemplo, si el intervalo de envío se establece en 100 clics, el Webhook se enviará cada vez que el número acumulado de clics alcance 100, 200, 300, etc. - pernCnt integer
- Número acumulado de visitantes (usuarios únicos). Este valor incluye el evento de clic actual.
- acesMaxCnt integer
-
Número máximo permitido de clics.
Si el valor es
0, no existe límite. Cuando se supera el límite, el acceso al enlace se bloquea. - referer string
- URL de la página anterior desde la que se originó la solicitud.
- queryString string
- Query String enviado al acceder a la URL acortada.
- country string
- Código de país del usuario visitante (ISO-3166).
- language string
- Código de idioma del usuario visitante (ISO-639).
- regYmdt datetime
- Fecha y hora de creación del enlace.
- modYmdt datetime
- Fecha y hora de modificación del enlace.
- 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 sea actualizado.
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.
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 deX-Vivoldi-Webhook-Typese envía comoGROUP.
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 entre1~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ónUsar cupónen 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 delPayload, 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ónen 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 entre2~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 comouseCnt + 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ñadidoREMOVE— Sello eliminadoUSE— 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.
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 utilizandostampsychangedStamps.
(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 es0. - 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.