Webhook — Guide d’intégration

Le cœur de l’intégration Webhook de Vivoldi repose sur la vérification de la signature dans l’en-tête HTTP.

Chaque requête Webhook inclut X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature, etc.,
et leur vérification vous permet de traiter en toute sécurité les événements de Liens, Coupons et Tampons.

Ce guide détaille le rôle de chaque champ d’en-tête et la procédure de vérification de la signature, étape par étape, et fournit du code d’exemple pour intégrer rapidement et en toute sécurité les requêtes Webhook.

HTTP Header

Le Webhook envoie une requête POST à l’URL de rappel désignée, permettant de vérifier l’intégrité et l’authenticité de chaque requête à l’aide des en-têtes tels que X-Vivoldi-Signature et X-Vivoldi-Timestamp.

HTTP Header

X-Vivoldi-Request-Id: e2ea0405b7ba4f0b9b75797179731ae0
X-Vivoldi-Event-Id: 89365c75dae740ac8500dfc48c5014b5
X-Vivoldi-Webhook-Type: GLOBAL
X-Vivoldi-Resource-Type: URL
X-Vivoldi-Action-Type: NONE
X-Vivoldi-Comp-Idx: 50742
X-Vivoldi-Timestamp: 1758184391752
X-Content-SHA256: e040abf9ac2826bc108fce0117e49290086743733ad9db2fa379602b4db9792c
X-Vivoldi-Signature: t=1758184391752,v1=b610f699d4e7964cdb7612111f5765576920b680e7c33c649e20608406807aaf,alg=hmac-sha256

Request Parameters

X-Vivoldi-Request-Idstring
ID unique de la requête. Généré à chaque demande et utilisé pour identifier une transaction spécifique.
X-Vivoldi-Event-Idstring
ID unique de l’événement.
Si la première requête échoue et est retentée, le même Event-Id est conservé afin d’éviter le traitement en double du même événement.
X-Vivoldi-Webhook-Typestring
Default :GLOBAL
Enum :
GLOBALGROUP
Si un Webhook de type GROUP est activé, cette valeur est définie sur GROUP.
Les événements de tampon utilisent toujours GROUP car ils fonctionnent par carte de tampons.
Les événements de lien et de coupon sont envoyés en mode GLOBAL lorsqu’aucun Webhook de groupe n’est configuré.
X-Vivoldi-Resource-Typestring
Enum :
URLCOUPONSTAMP
URL : lien court, COUPON : coupon, STAMP : tampon.
X-Vivoldi-Action-Typestring
Enum :
NONEADDREMOVEUSE

NONE : utilisé pour les événements de clic sur un lien ou d’utilisation d’un coupon, sans action supplémentaire.
ADD : ajout d’un tampon
REMOVE : suppression d’un tampon
USE : utilisation d’une récompense de tampon

Si de nouvelles actions sont ajoutées aux événements de lien ou de coupon à l’avenir, cette valeur d’en-tête (X-Vivoldi-Action-Type) pourra être étendue.

X-Vivoldi-Comp-Idxinteger
IDX unique de l’organisation.
Consultable dans la page [Paramètres → Paramètres de l’organisation].
X-Vivoldi-Timestampinteger
Heure de la requête (secondes UNIX epoch). Tolérance recommandée : ±5 minutes.
X-Content-SHA256string
Valeur de hachage SHA-256 du corps (payload) de la requête.
X-Vivoldi-Signaturestring
Informations de signature de la requête. Format : t=horodatage, v1=valeur de signature, alg=algorithme.

Politique de transmission · réponse · réessais

Critères de réussite

  • Considéré comme réussi si le serveur récepteur renvoie une réponse HTTP 2xx (ex. : 200).
  • Après vérification de la signature, retournez 200 OK immédiatement. Le délai d’expiration est de 5 secondes, donc les traitements longs doivent être exécutés en mode asynchrone après la réponse.
Dans les environnements à fort trafic, un délai de réponse peut déclencher des réessais et générer des événements en double.

Réessais et désactivation

  • Jusqu’à 5 réessais en cas d’erreurs réseau ou de réponses autres que 2xx.
  • En cas de 5 échecs consécutifs, le Webhook est automatiquement désactivé et un e-mail d’alerte est envoyé à l’administrateur.
  • Prévention des doublons : vérifiez les doublons à l’aide de la valeur X-Vivoldi-Event-Id.

Les politiques peuvent être ajustées en fonction de l’environnement opérationnel.

Peut-on traiter un Webhook sans validation des en-têtes ?

Techniquement, traiter uniquement le corps POST (payload) suffit, mais en production vous devez systématiquement effectuer la vérification des en-têtes.
Omettre cette vérification expose à des risques de sécurité graves tels que requêtes falsifiées, altération du payload, traitements en double et perte de traçabilité.

Principaux risques :

  • Usurpation de requêtes (spoofing) : Un attaquant peut se faire passer pour Vivoldi et envoyer des requêtes falsifiées.
    Sans vérification des en-têtes, le système peut considérer ces requêtes comme légitimes.
  • Altération des données : Si le payload est modifié en transit, l’absence de vérification de la signature empêche de détecter la falsification.
  • Traitement en double : Les attaques de rejeu peuvent entraîner la réception multiple d’un même événement, provoquant des doublons ou des attributions en double.
  • Perte de traçabilité : Sans les en-têtes Request-Id ou Event-Id, il devient impossible de tracer les requêtes, d’analyser les erreurs ou de reproduire les incidents.

Payload

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

Payload Parameters

cpnNostring
Numéro du coupon.
domainstring
Domaine du coupon.
nmstring
Nom du coupon.
grpIdxinteger
IDX du groupe. Si un groupe est défini, le Webhook de ce groupe sera appelé au lieu du Webhook global.
grpNmstring
Nom du groupe.
discTypeIdxinteger
Par défaut :457
Enum :
457458
Type de réduction. (457 : réduction en %, 458 : réduction en montant)
discCurrencystring
Par défaut :KRW
Enum :
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Devise. Obligatoire si le type de réduction est un montant (discTypeIdx : 458).
formatDiscCurrencystring
Symbole monétaire.
discdouble
Par défaut :0
Pourcentage de réduction (457) : entre 1 et 100 %.
Réduction en montant (458) : saisir la valeur.
strtYmddate
Date de début de validité du coupon.
endYmddate
Date d’expiration du coupon.
useLimitinteger
Par défaut :1
Enum :
012345
Nombre d’utilisations du coupon. (0 : illimité, 1–5 : limité au nombre spécifié)
imgUrlstring
URL de l’image du coupon.
onsiteYnstring
Par défaut :N
Enum :
YN
Coupon sur site. Indique si le bouton « Utiliser le coupon » est affiché sur la page du coupon.
Utilisé dans les magasins physiques lorsque le personnel valide le coupon.
onsitePwdstring
Mot de passe du coupon sur site.
Requis pour l’utilisation du coupon.
memostring
Mémo interne de référence.
urlstring
Si une URL est saisie, un bouton « Aller utiliser le coupon » est affiché sur la page du coupon.
En cliquant sur le bouton ou l’image du coupon, l’utilisateur est redirigé vers cette URL.
userIdstring
Utilisé pour gérer le bénéficiaire du coupon.
Obligatoire si la limite d’utilisation du coupon est définie entre 2 et 5.
Généralement, on saisit l’ID de connexion du membre du site ou un nom en anglais.
userNmstring
Nom de l’utilisateur du coupon. À usage interne.
userPhnnostring
Numéro de téléphone de l’utilisateur du coupon. À usage interne.
userEmlstring
Adresse e-mail de l’utilisateur du coupon. À usage interne.
userEtc1string
Champ supplémentaire pour la gestion interne.
userEtc2string
Champ supplémentaire pour la gestion interne.
useCntinteger
Nombre d’utilisations du coupon.
regYmdtdatetime
Date de création du coupon. Exemple : 2025-07-21 11:50:20
{
    "stampIdx": 16,
    "domain": "https://vvd.bz",
    "cardIdx": 1,
    "cardNm": "Accumulate 10 Americanos",
    "cardTtl": "Collect 10 stamps to get one free Americano.",
    "stamps": 10,
    "maxStamps": 12,
    "stampUrl": "https://vvd.bz/stamp/274",
    "url": "https://myshopping.com",
    "strtYmd": "2025-01-01",
    "endYmd": "2026-12-31",
    "onsiteYn": "Y",
    "onsitePwd": "123456",
    "memo": null,
    "activeYn": "Y",
    "userId": "NKkDu9X4p4mQ",
    "userNm": null,
    "userPhnno": null,
    "userEml": null,
    "userEtc1": null,
    "userEtc2": null,
    "stampImgUrl": "https://cdn.vivoldi.com/www/image/icon/stamp/icon.stamp.1.webp",
    "regYmdt": "2025-10-30 05:11:35",
    "payloadVersion": "v1"
}

Payload Parameters

stampIdxinteger
Stamp IDX.
domainstring
Domaine du tampon.
cardIdxinteger
Card IDX.
cardNmstring
Nom de la carte.
cardTtlstring
Titre de la carte.
stampsinteger
Nombre de tampons collectés jusqu’à présent.
maxStampsinteger
Nombre maximal de tampons sur la carte.
stampUrlstring
URL de la page du tampon.
urlstring
URL vers laquelle l’utilisateur est redirigé en cliquant sur le bouton de la page du tampon.
strtYmddate
Date de début de validité du tampon.
endYmddate
Date d’expiration du tampon.
onsiteYnstring
Enum :
YN
Indique si la validation sur site est activée.
Si la valeur est Y, le personnel peut ajouter des tampons directement en magasin.
onsitePwdstring
Mot de passe pour la validation sur site.
Requis lors de l’utilisation de l’API d’avantage si l’option sur site est activée (Y).
memostring
Note interne à des fins de référence.
activeYnstring
Enum :
YN
Indique si le tampon est actif.
S’il est désactivé, le client ne peut pas l’utiliser.
userIdstring
ID utilisateur. Utilisé pour gérer le bénéficiaire du tampon.
Généralement, il correspond à l’identifiant de connexion du membre du site web.
S’il n’est pas défini, un ID utilisateur est automatiquement généré par le système.
userNmstring
Nom de l’utilisateur. Usage interne uniquement.
userPhnnostring
Numéro de téléphone de l’utilisateur. Usage interne uniquement.
userEmlstring
Adresse e-mail de l’utilisateur. Usage interne uniquement.
userEtc1string
Champ supplémentaire pour la gestion interne.
userEtc2string
Champ supplémentaire pour la gestion interne.
stampImgUrlstring
URL de l’image du tampon.
regYmdtdatetime
Date de création du tampon. Exemple : 2025-07-21 11:50:20

Vérification de signature — Exemple de code

Les requêtes Webhook doivent être vérifiées à l’aide de l’en-tête X-Vivoldi-Signature et de la clé secrète (Secret Key) fournie.
La signature est générée en combinant l’horodatage (t), l’ID de l’événement (X-Vivoldi-Event-Id) et le hachage SHA-256 du corps de la requête selon le format suivant :

timestamp.eventId.payloadSha256

Le résultat du hachage HMAC-SHA256 de cette chaîne avec la clé secrète devient la valeur v1, qui doit correspondre à la valeur de l’en-tête X-Vivoldi-Signature pour que la requête soit considérée comme valide.


import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.stereotype.Controller;
import org.apache.commons.codec.binary.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Map;

@RestController
@RequestMapping("/webhooks")
public class WebhookController {
    private final Logger log = LoggerFactory.getLogger(getClass());

    @Value("${vivoldi.webhook.secret}")
    private String globalSecretKey;  // global secret key

    @PostMapping("/vivoldi")
    public ResponseEntity<String> handleWebhook(@RequestBody String payload, @RequestHeader Map<String, String> headers) {

        // Extracting the Vivoldi header
        String requestId = headers.get("x-vivoldi-request-id");
        String eventId = headers.get("x-vivoldi-event-id");
        String webhookType = headers.get("x-vivoldi-webhook-type");
        String resourceType = headers.get("x-vivoldi-resource-type");
        String actionType = headers.get("x-vivoldi-action-type");
        String signature = headers.get("x-vivoldi-signature");

        // Signature Verification
        if (!verifySignature(payload, signature, webhookType, resourceType, eventId)) {
            return ResponseEntity.status(401).body("Invalid signature");
        }

        // Processing by Resource Type
        switch (resourceType) {
            case "URL":
                handleLink(payload);
                break;
            case "COUPON":
                handleCoupon(payload);
                break;
            case "STAMP":
                handleStamp(payload, actionType);
                break;
            default:
                log.warn("Unknown resourceType type: {}", resourceType);
        }

        return ResponseEntity.ok("success");
    }

    private String sha256(String data) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder();
        for (byte b : hash) sb.append(String.format("%02x", b));
        return sb.toString();
    }

    private boolean verifySignature(String payload, String signature, String webhookType, String resourceType, String eventId) {
        try {
            String timestamp = null;
            String sig = null;
            for (String part : signature.split(",")) {
                part = part.trim();
                if (part.startsWith("t=")) timestamp = part.substring(2);
                if (part.startsWith("v1=")) sig = part.substring(3);
            }
            if (timestamp == null || sig == null || eventId == null) return false;

            String payloadSha256 = null;
            try {
                payloadSha256 = sha256(payload);
            } catch (Exception e) {
                log.error(e.getMessage(), e);
                return false;
            }

            String signedPayload = timestamp + "." + eventId + "." + payloadSha256;
            String secretKey = webhookType.equals("GLOBAL") ? globalSecretKey : "";
            if (secretKey.isEmpty()) {
                JSONObject jsonObj = new JSONObject(payload);
                if (resourceType.equals("STAMP")) {
                    long cardIdx = jsonObj.optLong("cardIdx", -1);
                    secretKey = loadStampCardSecretKey(cardIdx);
                } else {
                    int grpIdx = jsonObj.optInt("grpIdx", -1);
                    secretKey = loadGroupSecretKey(grpIdx); // In actual production environments, database integration
                }
            }
            if (secretKey == null || secretKey.isEmpty()) return false;

            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] hash = mac.doFinal(signedPayload.getBytes(StandardCharsets.UTF_8));
            String computedSig = Hex.encodeHexString(hash);

            return MessageDigest.isEqual(
                sig.toLowerCase().getBytes(StandardCharsets.UTF_8),
                computedSig.toLowerCase().getBytes(StandardCharsets.UTF_8)
            );
        } catch (Exception e) {
            log.error("Signature verification failed", e);
            return false;
        }
    }

    private String loadStampCardSecretKey(long cardIdx) {
        switch (cardIdx) {
            case 147: return "your-stamp-card-secret-key-147";
            case 523: return "your-stamp-card-secret-key-523";
            default: return "";
        }
    }

    private String loadGroupSecretKey(int grpIdx) {
        switch (grpIdx) {
            case 3570: return "your-group-secret-key-3570";
            case 4178: return "your-group-secret-key-4178";
            default: return "";
        }
    }

    private void handleLink(String payload) {
        // Link Click Event Handling Logic
        log.info("Link clicked: {}", payload);
    }

    private void handleCoupon(String payload) {
        // Coupon Usage Event Handling Logic
        log.info("Coupon redeemed: {}", payload);
    }

    private void handleStamp(String payload, String actionType) {
        // Stamp Usage Event Handling Logic
        if (actionType.equals("ADD")) {
            log.info("Stamp added: {}", payload);
        } else if (actionType.equals("RMEOVE")) {
            log.info("Stamp removed: {}", payload);
        } else if (actionType.equals("USE")) {
            log.info("Stamp redeemed: {}", payload);
        }
    }
}

<?php
// Environment Settings
$globalSecretKey = $_ENV['VIVOLDI_WEBHOOK_SECRET'] ?? 'your-global-secret-key';

/**
 * Main Webhook Handler Function
 */
function handleWebhook($payload) {
    // Header Information Extraction
    $headers = array_change_key_case(getallheaders(), CASE_LOWER);
    $requestId = $headers['x-vivoldi-request-id'] ?? '';
    $eventId = $headers['x-vivoldi-event-id'] ?? '';
    $webhookType = $headers['x-vivoldi-webhook-type'] ?? '';
    $resourceType = $headers['x-vivoldi-resource-type'] ?? '';
    $actionType = $headers['x-vivoldi-action-type'] ?? '';
    $signature = $headers['x-vivoldi-signature'] ?? '';

    // Signature Verification
    if (!verifySignature($payload, $signature, $webhookType, $resourceType, $eventId)) {
        http_response_code(401);
        echo json_encode(['error' => 'Invalid signature']);
        return;
    }

    // Processing by Resource Type
    switch ($resourceType) {
        case 'URL':
            handleLink($payload);
            break;
        case 'COUPON':
            handleCoupon($payload);
            break;
        case 'STAMP':
            handleStamp($payload, $actionType);
            break;
        default:
            error_log('Unknown resourceType: ' . $resourceType);
    }

    http_response_code(200);
    echo json_encode(['status' => 'success']);
}

function sha256($data) {
    return hash('sha256', $data);
}

/**
 * HMAC-SHA256 Signature Verification Function
 */
function verifySignature($payload, $signature, $webhookType, $resourceType, $eventId) {
    try {
        $timestamp = null;
        $sig = null;
        foreach (explode(',', $signature) as $part) {
            $part = trim($part);
            if (strpos($part, 't=') === 0) $timestamp = substr($part, 2);
            if (strpos($part, 'v1=') === 0) $sig = substr($part, 3);
        }
        if (!$timestamp || !$sig || !$eventId) return false;

        // Timestamp Tolerance Verification (±60 seconds)
        if (abs(time() - (int)$timestamp) > 60) {
            return false;
        }

        // Payload SHA256
        $payloadSha256 = sha256($payload);
        $signedPayload = $timestamp . '.' . $eventId . '.' . $payloadSha256;
        $secretKey = getSecretKey($webhookType, $resourceType, $payload);
        if (empty($secretKey)) return false;

        $computedSig = hash_hmac('sha256', $signedPayload, $secretKey);

        // Safety Comparison (lowercase throughout)
        return hash_equals(strtolower($sig), strtolower($computedSig));
    } catch (Exception $e) {
        error_log('Signature verification failed: ' . $e->getMessage());
        return false;
    }
}

/**
 * Secret Key Return Based on Webhook Type and Group
 */
function getSecretKey($webhookType, $resourceType, $payload) {
    global $globalSecretKey;

    if ($webhookType === 'GLOBAL') {
        return $globalSecretKey;
    }

    // Group-Specific Secret Key Configuration
    $jsonData = json_decode($payload, true);

    if ($resourceType === 'STAMP') {
        if (!isset($jsonData['cardIdx'])) {
            return '';
        }

        // Stamp cardIdx
        $cardIdx = $jsonData['cardIdx'];
        switch ($cardIdx) {
            case 617:
                return 'your stamp card secret key for 617';
            case 3304:
                return 'your stamp card secret key for 3304';
            default:
                return '';
        }
    } else {
        if (!isset($jsonData['grpIdx'])) {
            return '';
        }

        $grpIdx = $jsonData['grpIdx'];
        if ($resourceType === 'LINK') {
            // Link grpIdx
            switch ($grpIdx) {
                case 17584:
                    return 'your group secret key for 17584';
                case 9158:
                    return 'your group secret key for 9158';
                default:
                    return '';
            }
        } else {
            // Coupon grpIdx
            switch ($grpIdx) {
                case 3570:
                    return 'your group secret key for 3570';
                case 4178:
                    return 'your group secret key for 4178';
                default:
                    return '';
            }
        }
    }
}

/**
 * Link Event Handler Function
 */
function handleLink($payload) {
    error_log('Link clicked: ' . $payload);

    // Processing link information by parsing JSON
    $linkData = json_decode($payload, true);

    if ($linkData) {
        // Link Click Statistics Update
        $linkId = $linkData['linkId'] ?? '';
        $clickTime = $linkData['timestamp'] ?? time();
        $userAgent = $linkData['userAgent'] ?? '';

        // Storing click information in the database
        saveClickEvent($linkId, $clickTime, $userAgent);

        error_log("Link {$linkId} clicked at {$clickTime}");
    }
}

/**
 * Coupon Event Handling Function
 */
function handleCoupon($payload) {
    error_log('Coupon redeemed: ' . $payload);

    // Parsing JSON to process coupon information
    $couponData = json_decode($payload, true);

    if ($couponData) {
        // Coupon Usage Information Processing
        $couponCode = $couponData['couponCode'] ?? '';
        $redeemTime = $couponData['timestamp'] ?? time();
        $userId = $couponData['userId'] ?? '';

        // Storing coupon usage information in the database
        saveCouponRedemption($couponCode, $userId, $redeemTime);

        error_log("Coupon {$couponCode} redeemed by user {$userId}");
    }
}

/**
 * Stamp Event Handling Function
 */
function handleStamp($payload, $actionType) {
    error_log('Stamp payload: ' . $payload);

    // Parsing JSON to process coupon information
    $stampData = json_decode($payload, true);

    if ($stampData) {
        $stampIdx = $stampData['stampIdx'] ?? 0;
        switch ($actionType) {
            case "ADD":
                // Stamp added
                break;
            case "REMOVE":
                // Stamp removed
                break;
            case "USE":
                // Stamp benefit used
                break;
            default:
                return '';
        }
    }
}

/**
 * Store click events in the database
 */
function saveClickEvent($linkId, $clickTime, $userAgent) {
    // Implementation of actual database integration logic
    // Example: Stored in MySQL, PostgreSQL, etc.

    error_log("Saving click event - Link: {$linkId}, Time: {$clickTime}");
}

/**
 * Store coupon usage information in the database
 */
function saveCouponRedemption($couponCode, $userId, $redeemTime) {
    // Implementation of actual database integration logic
    // Example: Updating coupon status, storing usage history, etc.

    error_log("Saving coupon redemption - Code: {$couponCode}, User: {$userId}");
}

/**
 * Log recording function
 */
function logWebhookEvent($eventType, $data) {
    $timestamp = date('Y-m-d H:i:s');
    $logMessage = "[{$timestamp}] {$eventType}: " . json_encode($data);
    error_log($logMessage);
}

// ===========================================
// Webhook Endpoint Execution Unit
// ===========================================

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $payload = file_get_contents('php://input');
    handleWebhook($payload);
} else {
    http_response_code(405);
    echo json_encode(['error' => 'Method not allowed']);
}
?>

const express = require('express');
const crypto = require('crypto');
const app = express();

// Environment Settings
const globalSecretKey = process.env.VIVOLDI_WEBHOOK_SECRET || 'your-global-secret-key';

// Form data parser for webhook payloads
app.use(express.raw({ type: '*/*' }));

/**
 * Main Webhook Handler Function
 */
function handleWebhook(headers, res, payload) {
    const requestId = headers['x-vivoldi-request-id'] || '';
    const eventId = headers['x-vivoldi-event-id'] || '';
    const webhookType = headers['x-vivoldi-webhook-type'] || '';
    const resourceType = headers['x-vivoldi-resource-type'] || '';
    const actionType = headers['x-vivoldi-action-type'] || '';
    const signature = headers['x-vivoldi-signature'] || '';

    // Signature Verification
    if (!verifySignature(payload, signature, webhookType, resourceType, eventId)) {
        res.status(401).json({ error: 'Invalid signature' });
        return;
    }

    // Processing by Resource Type
    switch (resourceType) {
        case 'URL':
            handleLink(payload);
            break;
        case 'COUPON':
            handleCoupon(payload);
            break;
        case 'STAMP':
            handleStamp(payload);
            break;
        default:
            console.error('Unknown resourceType: ' + resourceType);
    }

    res.status(200).json({ status: 'success' });
}

/**
 * SHA256(hex)
 */
function sha256Hex(data) {
    return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
}

/**
 * HMAC-SHA256 Signature Verification Function
 */
function verifySignature(payload, signature, webhookType, resourceType, eventId) {
    try {
        let timestamp, sig;
        for (const part of signature.split(',')) {
            const p = part.trim();
            if (p.startsWith('t=')) timestamp = p.slice(2);
            if (p.startsWith('v1=')) sig = p.slice(3);
        }
        if (!timestamp || !sig || !eventId) return false;

        // Timestamp check (±180s)
        if (Math.abs(Date.now()/1000 - Number(timestamp)) > 180) return false;

        const signedPayload = `${timestamp}.${eventId}.${sha256Hex(payload)}`;

        // Secret Key Determination
        const secretKey = getSecretKey(webhookType, resourceType, payload);
        if (!secretKey) return false;

        // HMAC-SHA256 Signature Calculation
        const computedSig = crypto
            .createHmac('sha256', secretKey)
            .update(signedPayload)
            .digest('hex');

        // Timing-Safe Comparison
        return crypto.timingSafeEqual(
            Buffer.from(sig.toLowerCase(), 'hex'),
            Buffer.from(computedSig.toLowerCase(), 'hex')
        );
    } catch (e) {
        console.error('Signature verification failed: ' + e.message);
        return false;
    }
}

/**
 * Secret Key Return Based on Webhook Type and Group
 */
function getSecretKey(webhookType, resourceType, payload) {
    if (webhookType === 'GLOBAL') {
        return globalSecretKey;
    }

    // Group-Specific Secret Key Configuration
    let jsonData;
    try {
        jsonData = JSON.parse(payload);
    } catch (error) {
        return '';
    }

    if (resourceType === 'STAMP') {
        if (!jsonData.cardIdx) {
            return '';
        }

        const cardIdx = jsonData.cardIdx;
        switch (cardIdx) {
            case 3570:
                return 'your stamp card secret key for 3570';
            case 4178:
                return 'your stamp card secret key for 4178';
            default:
                return '';
        }
    } else {
        if (!jsonData.grpIdx) {
            return '';
        }

        const grpIdx = jsonData.grpIdx;
        if (resourceType === 'LINK') {
            // Link grpIdx
            switch (grpIdx) {
                case 17584:
                    return 'your group secret key for 17584';
                case 9158:
                    return 'your group secret key for 9158';
                default:
                    return '';
            }
        } else {
            // Coupon grpIdx
            switch (grpIdx) {
                case 6350:
                    return 'your group secret key for 6350';
                case 17884:
                    return 'your group secret key for 17884';
                default:
                    return '';
            }
        }
    }
}

/**
 * Link Event Handler Function
 */
function handleLink(payload) {
    console.error('Link clicked: ' + payload);

    // Processing link information by parsing JSON
    let linkData;
    try {
        linkData = JSON.parse(payload);
    } catch (error) {
        return;
    }

    if (linkData) {
        // Link Click Statistics Update
        const linkId = linkData.linkId || '';
        const clickTime = linkData.timestamp || Math.floor(Date.now() / 1000);
        const userAgent = linkData.userAgent || '';

        // Storing click information in the database
        saveClickEvent(linkId, clickTime, userAgent);

        console.error(`Link ${linkId} clicked at ${clickTime}`);
    }
}

/**
 * Coupon Event Handling Function
 */
function handleCoupon(payload) {
    console.error('Coupon redeemed: ' + payload);

    // Parsing JSON to process coupon information
    let couponData;
    try {
        couponData = JSON.parse(payload);
    } catch (error) {
        return;
    }

    if (couponData) {
        // Coupon Usage Information Processing
        const couponCode = couponData.couponCode || '';
        const redeemTime = couponData.timestamp || Math.floor(Date.now() / 1000);
        const userId = couponData.userId || '';

        // Storing coupon usage information in the database
        saveCouponRedemption(couponCode, userId, redeemTime);

        console.error(`Coupon ${couponCode} redeemed by user ${userId}`);
    }
}

/**
 * Stamp Event Handling Function
 */
function handleStamp(payload, actionType) {
    console.error('Stamp payload: ' + payload);

    // Parsing JSON to process coupon information
    let stampData;
    try {
        stampData = JSON.parse(payload);
    } catch (error) {
        return;
    }

    if (stampData) {
        const stampIdx = stampData.stampIdx || 0;
        switch (actionType) {
            case "ADD":
                // Stamp added
                break;
            case "REMOVE":
                // Stamp removed
                break;
            case "USE":
                // Stamp benefit used
                break;
        }
    }
}

/**
 * Store click events in the database
 */
function saveClickEvent(linkId, clickTime, userAgent) {
    // Implementation of actual database integration logic
    // Example: Stored in MongoDB, MySQL, PostgreSQL, etc.

    console.error(`Saving click event - Link: ${linkId}, Time: ${clickTime}`);
}

/**
 * Store coupon usage information in the database
 */
function saveCouponRedemption(couponCode, userId, redeemTime) {
    // Implementation of actual database integration logic
    // Example: Updating coupon status, storing usage history, etc.

    console.error(`Saving coupon redemption - Code: ${couponCode}, User: ${userId}`);
}

/**
 * Log recording function
 */
function logWebhookEvent(eventType, data) {
    const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
    const logMessage = `[${timestamp}] ${eventType}: ${JSON.stringify(data)}`;
    console.error(logMessage);
}

// ===========================================
// Webhook Endpoint Execution Unit
// ===========================================

app.post('/webhook/vivoldi', (req, res) => {
    const payload = req.body.toString('utf8');
    const headers = req.headers;

    if (!verifySignature(payload, headers['x-vivoldi-signature'], headers['x-vivoldi-webhook-type'], headers['x-vivoldi-event-id'])) {
        return res.status(401).json({ error: 'Invalid signature' });
    }

    handleWebhook(req.headers, res, payload);
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Webhook server running on port ${PORT}`);
});

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

Le Webhook intègre en temps réel les événements de Liens, Coupons et Tampons à vos systèmes CRM, de paiement et d’analyse.

Grâce à une infrastructure à haute disponibilité, à des mécanismes fiables de mise en file d’attente et de nouvelle tentative, ainsi qu’à une sécurité basée sur HMAC, il garantit une fiabilité totale dans les environnements Enterprise.

Mise à niveau vers Enterprise