Webhook API & Vérification HMAC Vivoldi

Une intégration Webhook sécurisée repose avant tout sur la vérification des signatures via les en-têtes HTTP.

Chaque requête Webhook Vivoldi inclut des en-têtes tels que X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature.
La validation de ces en-têtes permet de bloquer les requêtes falsifiées et de traiter en toute sécurité les événements liés aux liens, coupons et stamps.

Ce guide présente le rôle de chaque en-tête, le processus de validation HMAC ainsi que des exemples d’implémentation en Java, PHP et Node.js.

HTTP Header

Les Webhooks Vivoldi envoient des requêtes HTTP POST vers l’URL de callback enregistrée.
Chaque requête inclut des en-têtes dédiés contenant une signature, un horodatage et des identifiants d’événement afin de vérifier l’origine de la requête et l’intégrité du 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
Identifiant unique permettant d’identifier la requête. Un nouvel identifiant est généré pour chaque requête HTTP et peut être utilisé pour suivre une requête spécifique.
X-Vivoldi-Event-Id string
Identifiant unique permettant d’identifier un événement. Le même Event ID est conservé lors des nouvelles tentatives d’envoi d’un même événement, ce qui permet au système destinataire d’éviter les traitements en double.
X-Vivoldi-Webhook-Type string
Default:GLOBAL
Enum:
GLOBALGROUP
Indique le périmètre d’application du Webhook.
GROUP : utilisé lorsqu’un Webhook de groupe est appliqué.
Les événements de carte de fidélité prennent uniquement en charge les Webhooks de groupe et sont donc toujours envoyés avec la valeur GROUP.
Les événements liés aux liens et aux coupons sont envoyés avec la valeur GLOBAL lorsqu’aucun Webhook de groupe n’est configuré.
X-Vivoldi-Resource-Type string
Enum:
URLCOUPONSTAMP
Type de ressource associé à l’événement.
URL : URL courte
COUPON : Coupon
STAMP : Tampon
X-Vivoldi-Action-Type string
Enum:
CLICKUSEADDREMOVE
Type d’action ayant déclenché l’événement.

CLICK : Clic sur un lien
USE : Utilisation d’un coupon, utilisation d’une récompense de carte de fidélité
ADD : Ajout d’un tampon
REMOVE : Suppression d’un tampon

Utilisez cette valeur avec Resource-Type pour identifier précisément le type d’événement.

X-Vivoldi-Comp-Idx integer
IDX permettant d’identifier l’organisation. Vous pouvez le consulter dans la page [Paramètres → Paramètres de l’organisation].
X-Vivoldi-Timestamp integer
Heure de création de la requête. Elle est transmise au format UNIX epoch seconds. Une différence maximale de ±5 minutes est recommandée afin de tenir compte des écarts d’heure entre les serveurs.
X-Content-SHA256 string
Valeur de hachage SHA-256 du payload de la requête. Elle peut être utilisée pour vérifier l’intégrité du payload.
X-Vivoldi-Signature string
Informations de signature utilisées pour vérifier la requête. Elles incluent t : horodatage, v1 : valeur de signature, et alg : algorithme de signature.

Livraison des Webhooks, Réponses & Politique de Retry

Les Webhooks Vivoldi définissent clairement les conditions de réponse réussie, les retries automatiques et les règles de désactivation des endpoints afin de garantir une livraison fiable des événements.
La compréhension de ces politiques permet d’éviter les traitements en double et de réduire le risque de perte d’événements.

Critères de réussite

Le succès d’une requête Webhook est déterminé selon le code de statut HTTP renvoyé par le serveur destinataire.

  • Une réponse HTTP 2xx est considérée comme réussie.
    Toutes les réponses 2xx, y compris 200, 202 et 204, sont acceptées. Le contenu du corps de la réponse n’est pas vérifié.
  • Le délai d’attente de la réponse est de 5 secondes.
    Après la vérification de la signature, nous recommandons de renvoyer immédiatement une réponse 2xx et d’effectuer le traitement réel de manière asynchrone.
  • Les redirections HTTP ne sont pas suivies. Les réponses telles que 301 et 302 sont considérées comme des échecs. Vous devez donc enregistrer l’URL de Callback finale.
Si le délai de réponse dépasse 5 secondes ou si un code de statut différent de 2xx est renvoyé, une nouvelle tentative peut être effectuée et le même événement peut être livré plusieurs fois.

Tentatives de réessai & Désactivation

En cas d’échec d’envoi, le Webhook effectue automatiquement des tentatives de réessai. Si des échecs répétés se produisent, le statut du Webhook passe à Désactivé par le système afin d’éviter les tentatives d’envoi inutiles.

  • Les tentatives de réessai sont effectuées pour tous les codes de réponse HTTP. Les réponses telles que 400, 404 et 401 suivent la même politique de réessai.
  • Pendant les tentatives de réessai, X-Vivoldi-Event-Id reste identique. Le serveur destinataire doit utiliser cette valeur pour éviter le traitement en double des événements.
  • Même après 5 tentatives de réessai échouées, le Webhook n’est pas désactivé immédiatement. Une notification par e-mail est d’abord envoyée, puis un délai de grâce de 60 minutes est accordé. Si le rétablissement n’a pas lieu pendant cette période, le statut du Webhook passe à Désactivé par le système.

Les Webhooks avec le statut Désactivé par le système peuvent être retrouvés via le filtre Désactivé par le système dans la liste du tableau de bord, puis réactivés.

Étape Délai Action
Tentatives 1 à 3 Immédiatement · Après 1 s · Après 2 s Des nouvelles tentatives immédiates sont effectuées pour gérer les erreurs réseau temporaires.
4e tentative Après 10 min Une nouvelle tentative est effectuée en tenant compte du temps nécessaire au redémarrage du serveur destinataire ou à la résolution d’un incident temporaire.
5e tentative Après 30 min La dernière tentative d’envoi est effectuée. En cas d’échec, les nouvelles tentatives automatiques sont arrêtées.
E-mail d’avertissement Immédiatement après 5 échecs Le Webhook n’est pas désactivé immédiatement. Un délai de grâce de 60 minutes commence après le 5e échec et une notification par e-mail est envoyée. Selon le cycle de traitement des notifications, l’e-mail peut être retardé jusqu’à environ 10 minutes.
Délai de grâce 30 min à 90 min Si le serveur est rétabli pendant le délai de grâce de 60 minutes, l’envoi du Webhook reprend sans désactivation du système.
Désactivation par le système Après 90 min Si la première tentative d’envoi après le délai de grâce échoue également, le statut du Webhook passe à Désactivé par le système.

Si des échecs répétés se produisent depuis la même URL de Callback, l’envoi est temporairement limité afin d’éviter l’accumulation continue des requêtes jusqu’au rétablissement du serveur destinataire.
Les interruptions courtes, comme les déploiements ou les incidents temporaires, reprennent automatiquement après rétablissement.

Les événements d’utilisation de coupons et de tampons ne sont jamais perdus.
Comme il s’agit d’événements importants qui ne se produisent qu’une seule fois, ils sont conservés dans une file d’attente pendant les nouvelles tentatives d’envoi et le délai de grâce, puis transmis dans l’ordre.
Les événements de clic sur les liens se produisent de manière répétée et les données analytiques sont enregistrées dans Vivoldi. Par conséquent, ces événements ne sont pas stockés séparément et ne sont pas renvoyés en cas d’échec d’envoi du Webhook.

Guide d’implémentation du serveur de réception Webhook

  • Le même événement peut être envoyé plusieurs fois.
    Le même événement peut être reçu plusieurs fois en raison des nouvelles tentatives d’envoi ou des conditions réseau. Conservez X-Vivoldi-Event-Id et retournez 200 OK sans traitement supplémentaire si l’événement a déjà été traité.
    Ceci est particulièrement important pour les opérations qui ne doivent pas être traitées plusieurs fois, comme l’utilisation d’un coupon ou l’ajout d’un tampon.
  • L’ordre des événements n’est pas garanti.
    Un événement renvoyé peut arriver après un événement généré ultérieurement.
    Si le respect de l’ordre des événements est nécessaire, utilisez les valeurs regYmdt et modYmdt du Payload comme référence.
  • Il est recommandé de séparer la gestion de la réponse du traitement réel.
    L’exécution d’opérations en base de données ou d’appels API externes avant l’envoi de la réponse peut dépasser la limite de 5 secondes.
    Nous recommandons le flux suivant : vérification de la signature → réponse 200 OK → traitement via une file d’attente interne.
  • Vérifiez la signature avec le corps original de la requête.
    L’analyse du JSON puis sa sérialisation peuvent modifier la valeur de hachage en raison des changements d’espaces ou d’ordre des clés.
    Si votre framework transforme automatiquement le corps de la requête, récupérez séparément le raw body.
  • Ignorez les champs inconnus.
    De nouveaux champs peuvent être ajoutés au Payload à l’avenir. Votre intégration doit ignorer les champs qui ne sont pas reconnus.
  • La Secret Key dépend de la cible du Webhook.
    Si X-Vivoldi-Webhook-Type est GLOBAL, vérifiez la signature avec la Secret Key globale. S’il est GROUP, utilisez la Secret Key configurée pour le groupe ou la carte de fidélité concernée.

Est-il sûr de traiter les Webhooks sans vérification de signature des en-têtes ?

Techniquement, il est possible de traiter les Webhooks uniquement à partir du corps POST (Payload). Cependant, dans un environnement de production, la vérification des en-têtes doit toujours être appliquée.
Ignorer cette validation peut exposer le système à des risques de sécurité majeurs, notamment des requêtes falsifiées, des modifications du payload, des traitements en double et une perte de traçabilité.

Principaux risques :

  • Requêtes falsifiées (Spoofing) : Un attaquant peut usurper l’identité des serveurs Vivoldi et envoyer de fausses requêtes Webhook.
    Sans vérification des en-têtes, le système peut considérer ces requêtes à tort comme légitimes.
  • Altération des données : Si le payload est modifié pendant la transmission réseau, la modification ne pourra pas être détectée sans validation de signature.
  • Traitement en double : Les attaques par rejeu peuvent provoquer la réception répétée du même événement et entraîner des traitements ou crédits multiples.
  • Absence de traçabilité : Sans les en-têtes Request-Id ou Event-Id, le suivi des requêtes, l’analyse des erreurs et la reproduction des incidents deviennent beaucoup plus difficiles.

Payload

Moment de déclenchement de l’événement

Coupon Webhook envoie les informations d’événement vers l’URL de Callback configurée lorsqu’un événement d’utilisation d’un coupon se produit.

Webhook peut être configuré pour des coupons individuels ou des groupes de coupons.
Si les deux sont configurés, les paramètres du groupe de coupons sont prioritaires, et le même événement n’est pas envoyé plusieurs fois. Coupon Webhook pour les groupes de coupons est disponible à partir du forfait Business.

L’événement est envoyé immédiatement après le traitement de l’utilisation du coupon, et la valeur de X-Vivoldi-Action-Type est USE.
L’événement est envoyé de la même manière, que le coupon soit utilisé via le tableau de bord, l’API ou un traitement hors ligne.

L’utilisation d’un coupon est un événement unique pour chaque coupon et ne peut pas être restaurée en cas de perte.
Lorsque la limite d’appels est dépassée ou qu’une nouvelle tentative est en attente, les événements sont stockés dans une file d’attente puis transmis dans l’ordre.
Lorsque plusieurs coupons sont traités simultanément via l’API, la livraison des événements peut être effectuée progressivement en plusieurs opérations.
{
    "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
Numéro du coupon.
domain string
Domaine de la page du coupon.
nm string
Nom du coupon.
grpIdx integer
IDX du groupe auquel appartient le coupon. Si le coupon n’appartient à aucun groupe, la valeur est 0.
Lorsqu’un Webhook de groupe est configuré, les paramètres du groupe sont prioritaires, et la valeur de X-Vivoldi-Webhook-Type est envoyée avec la valeur GROUP.
Si aucun Webhook de groupe n’est configuré, l’envoi suit les paramètres du coupon individuel.
grpNm string
Nom du groupe de coupons.
discTypeIdx integer
Enum:
457458
Type de remise.
457 : Remise en pourcentage (%)
458 : Remise d’un montant fixe
discCurrency string
Default:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Devise utilisée pour le montant de la remise. Obligatoire lors de l’utilisation d’une remise d’un montant fixe (discTypeIdx=458).
formatDiscCurrency string
Format d’affichage de la devise.
disc double
Default:0
Valeur de la remise.
Pour une remise en pourcentage (457), la valeur doit être comprise entre 1~100%, tandis qu’une remise d’un montant fixe (458) correspond au montant de la réduction.
strtYmd date
Date de début de validité du coupon.
endYmd date
Date d’expiration du coupon.
useLimit integer
Default:1
Enum:
012345
Nombre d’utilisations autorisées du coupon.
0 : Sans limite
1~5 : Utilisable selon le nombre de fois défini
imgUrl string
URL de l’image du coupon.
onsiteYn string
Default:N
Enum:
YN
Indique si l’utilisation du coupon en magasin est prise en charge. Lorsque la valeur est Y, le bouton Utiliser le coupon s’affiche sur la page du coupon, et le coupon peut être utilisé en magasin après vérification par un employé.
onsitePwd string
Mot de passe utilisé pour l’authentification de l’utilisation du coupon en magasin.
Comme il est inclus en clair dans le Payload, veillez à ne pas l’enregistrer dans les journaux du serveur destinataire.
memo string
Note interne.
url string
Si cette valeur est définie, le bouton Accéder à l’utilisation du coupon s’affiche sur la page du coupon.
L’utilisateur est redirigé vers cette URL lorsqu’il clique sur le bouton ou l’image du coupon.
userId string
ID permettant d’identifier l’utilisateur du coupon.
Obligatoire lorsque la limite d’utilisation du coupon est définie entre 2~5. Il s’agit généralement de l’ID membre du service ou d’un identifiant client.
userNm string
Nom de l’utilisateur du coupon. Utilisé pour la gestion interne et l’identification.
userPhnno string
Coordonnées de l’utilisateur du coupon. Utilisées pour la gestion interne et l’identification.
userEml string
Adresse e-mail de l’utilisateur du coupon. Utilisée pour la gestion interne et l’identification.
userEtc1 string
Champ supplémentaire pour la gestion interne.
userEtc2 string
Champ supplémentaire pour la gestion interne.
useCnt integer
Nombre actuel d’utilisations du coupon. L’événement d’utilisation actuel n’est pas encore inclus dans cette valeur.
Pour inclure l’utilisation actuelle, calculez la valeur avec useCnt + 1.
regYmdt datetime
Date et heure de création du coupon. Exemple : 2025-07-21 11:50:20
payloadVersion string
Version de la spécification du Payload. Même si de nouveaux champs sont ajoutés, la signification et le comportement des champs existants restent inchangés jusqu’à la modification de cette valeur.

Moment de déclenchement de l’événement

Le Webhook est configuré dans la carte de fidélité. Tous les événements de tampons générés depuis cette carte sont envoyés.

Il est envoyé lors des événements d’ajout, de suppression ou d’utilisation d’une récompense de fidélité. Le type d’événement est identifié par la valeur de l’en-tête X-Vivoldi-Action-Type.

  • ADD — Tampon ajouté
  • REMOVE — Tampon supprimé
  • USE — Récompense de fidélité utilisée

Quelle que soit l’origine de la modification (tableau de bord, API, écran de gestion des tampons ou autre méthode), l’événement est envoyé avec le même type.

changedStamps représente le nombre de tampons concernés. L’ajout ou la suppression est déterminé par la valeur de X-Vivoldi-Action-Type.
L’utilisation d’une récompense (USE) ne modifie pas le nombre de tampons, la valeur envoyée est donc 0.

La signification de la valeur stamps dépend de la manière dont l’événement est généré.
Pour les événements d’ajout, de suppression et d’utilisation d’une récompense via l’API, stamps correspond au nombre de tampons avant modification. La valeur après modification peut être calculée avec stamps + changedStamps. Pour REMOVE, il faut soustraire changedStamps.
Lorsque la modification est effectuée depuis l’écran de gestion des tampons du tableau de bord, stamps correspond au nombre de tampons après modification.
Pour calculer précisément le nombre actuel de tampons, utilisez la valeur avant l’événement et changedStamps afin de calculer la valeur après modification.
{
    "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 d’identification du tampon.
domain string
Domaine de la page de fidélité.
cardIdx integer
IDX d’identification de la carte de fidélité.
cardNm string
Nom de la carte de fidélité.
cardTtl string
Titre de la carte de fidélité.
stamps integer
Nombre actuel de tampons. Toutefois, le point de référence dépend de la manière dont l’événement est généré.
Pour les événements d’ajout, de suppression et d’utilisation de récompense via l’API, cette valeur correspond au nombre de tampons avant modification. La valeur après modification peut être calculée à l’aide de stamps et changedStamps.
(ADD : tampon ajouté, REMOVE : tampon supprimé)
Lorsque la modification est effectuée directement depuis l’écran de gestion des tampons du tableau de bord, cette valeur correspond au nombre de tampons après modification.
maxStamps integer
Nombre maximal de tampons de la carte de fidélité.
changedStamps integer
Nombre de tampons modifiés par cet événement. L’ajout ou la suppression est déterminé par la valeur de X-Vivoldi-Action-Type.
L’utilisation d’une récompense (USE) ne modifie pas le nombre de tampons, la valeur est donc 0.
stampUrl string
URL de la page de fidélité.
url string
URL vers laquelle l’utilisateur est redirigé lorsqu’un bouton est cliqué sur la page de fidélité.
strtYmd date
Date de début de validité des tampons.
endYmd date
Date d’expiration de validité des tampons.
onsiteYn string
Enum:
YN
Indique si la collecte de tampons en magasin est prise en charge. Lorsque la valeur est Y, les employés peuvent vérifier le client et ajouter des tampons en magasin.
onsitePwd string
Mot de passe utilisé pour vérifier la collecte de tampons en magasin ou l’utilisation d’une récompense.
Requis pour les appels API associés lorsque la collecte de tampons en magasin est activée (onsiteYn=Y).
memo string
Note interne.
activeYn string
Enum:
YN
Indique si la carte de fidélité est active. Lorsqu’elle est désactivée, les clients ne peuvent pas utiliser la carte de fidélité.
userId string
ID utilisateur permettant d’identifier l’utilisateur de la carte de fidélité.
Il s’agit généralement de l’ID membre du service ou d’un identifiant client.
Si aucune valeur n’est définie, Vivoldi la génère automatiquement.
userNm string
Nom de l’utilisateur de la carte de fidélité. Utilisé pour la gestion interne et l’identification.
userPhnno string
Coordonnées de l’utilisateur de la carte de fidélité. Utilisées pour la gestion interne et l’identification.
userEml string
Adresse e-mail de l’utilisateur de la carte de fidélité. Utilisée pour la gestion interne et l’identification.
userEtc1 string
Champ supplémentaire pour la gestion interne.
userEtc2 string
Champ supplémentaire pour la gestion interne.
stampImgUrl string
URL de l’image du tampon.
regYmdt datetime
Date et heure de création du tampon. Exemple : 2025-07-21 11:50:20
payloadVersion string
Version de la spécification du Payload. Même si de nouveaux champs sont ajoutés, la signification et le comportement des champs existants restent inchangés jusqu’à la modification de cette valeur.

Vérification de Signature Webhook & Exemples de Code

L’authenticité d’une requête Webhook est vérifiée à l’aide de l’en-tête X-Vivoldi-Signature et de la Secret Key fournie.

La signature est générée en combinant le timestamp (t), l’identifiant d’événement (X-Vivoldi-Event-Id) et le hash SHA-256 du corps de la requête dans une chaîne séparée par des points (.), puis en appliquant un hash HMAC-SHA256 avec la Secret Key.

timestamp.eventId.payloadSha256

Si la valeur de hash générée (v1) correspond à la valeur de l’en-tête X-Vivoldi-Signature, la requête doit être considérée comme valide.
Dans le cas contraire, rejetez immédiatement la requête et consignez l’incident dans les 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}`);
});

✨ Intégration en temps réel de niveau Enterprise

Optimisé pour les environnements d’entreprise traitant de grands volumes d’événements liés aux liens, coupons et tampons.

Grâce à une infrastructure haute disponibilité et à des systèmes de queueing fiables, Vivoldi assure une intégration stable avec vos plateformes CRM, de paiement et d’analyse, sans perte d’événements, même lors de pics soudains de trafic.

Mise à niveau vers Enterprise