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: NONE
X-Vivoldi-Comp-Idx: 50742
X-Vivoldi-Timestamp: 1758184391752
X-Content-SHA256: e040abf9ac2826bc108fce0117e49290086743733ad9db2fa379602b4db9792c
X-Vivoldi-Signature: t=1758184391752,v1=b610f699d4e7964cdb7612111f5765576920b680e7c33c649e20608406807aaf,alg=hmac-sha256

Request Parameters

X-Vivoldi-Request-Id string
ID unique de la requête. Généré à chaque demande et utilisé pour identifier une transaction spécifique.
X-Vivoldi-Event-Id string
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-Type string
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-Type string
Enum :
URLCOUPONSTAMP
URL : lien court, COUPON : coupon, STAMP : tampon.
X-Vivoldi-Action-Type string
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-Idx integer
IDX unique de l’organisation.
Consultable dans la page [Paramètres → Paramètres de l’organisation].
X-Vivoldi-Timestamp integer
Heure de la requête (secondes UNIX epoch). Tolérance recommandée : ±5 minutes.
X-Content-SHA256 string
Valeur de hachage SHA-256 du corps (payload) de la requête.
X-Vivoldi-Signature string
Informations de signature de la requête. Format : t=horodatage, v1=valeur de signature, alg=algorithme.

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

Ces critères permettent à Vivoldi de déterminer si votre serveur a correctement reçu une requête Webhook.

  • La livraison est considérée comme réussie lorsque le serveur renvoie une réponse HTTP 2xx (par exemple 200).
  • Après la vérification de la signature, retournez immédiatement 200 OK. Le délai d’expiration du Webhook étant de 5 secondes, les traitements longs doivent être exécutés de manière asynchrone après l’envoi de 200 OK.
Dans les environnements à fort trafic, des réponses lentes peuvent déclencher des retries et provoquer des événements en double.

Réessais et désactivation

En cas d’échec de livraison, Vivoldi effectue automatiquement des retries et peut désactiver le Webhook après plusieurs échecs afin d’éviter un trafic inutile.

  • Jusqu’à 5 retries automatiques sont effectués en cas d’erreur réseau ou de réponse non 2xx.
  • Le Webhook est automatiquement désactivé après 5 échecs consécutifs, et un e-mail de notification est envoyé à l’administrateur.
  • Prévention des événements en double : utilisez la valeur X-Vivoldi-Event-Id pour détecter les événements déjà reçus.

Les politiques peuvent être ajustées selon l’environnement d’exploitation.

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

{
    "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 du coupon.
nm string
Nom du coupon.
grpIdx integer
IDX du groupe. Si un groupe est défini, le Webhook de ce groupe sera appelé au lieu du Webhook global.
grpNm string
Nom du groupe.
discTypeIdx integer
Par défaut :457
Enum :
457458
Type de réduction. (457 : réduction en %, 458 : réduction en montant)
discCurrency string
Par défaut :KRW
Enum :
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Devise. Obligatoire si le type de réduction est un montant (discTypeIdx : 458).
formatDiscCurrency string
Symbole monétaire.
disc double
Par défaut :0
Pourcentage de réduction (457) : entre 1 et 100 %.
Réduction en montant (458) : saisir la valeur.
strtYmd date
Date de début de validité du coupon.
endYmd date
Date d’expiration du coupon.
useLimit integer
Par défaut :1
Enum :
012345
Nombre d’utilisations du coupon. (0 : illimité, 1–5 : limité au nombre spécifié)
imgUrl string
URL de l’image du coupon.
onsiteYn string
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.
onsitePwd string
Mot de passe du coupon sur site.
Requis pour l’utilisation du coupon.
memo string
Mémo interne de référence.
url string
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.
userId string
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.
userNm string
Nom de l’utilisateur du coupon. À usage interne.
userPhnno string
Numéro de téléphone de l’utilisateur du coupon. À usage interne.
userEml string
Adresse e-mail de l’utilisateur du coupon. À usage interne.
userEtc1 string
Champ supplémentaire pour la gestion interne.
userEtc2 string
Champ supplémentaire pour la gestion interne.
useCnt integer
Nombre d’utilisations du coupon.
regYmdt datetime
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

stampIdx integer
Stamp IDX.
domain string
Domaine du tampon.
cardIdx integer
Card IDX.
cardNm string
Nom de la carte.
cardTtl string
Titre de la carte.
stamps integer
Nombre de tampons collectés jusqu’à présent.
maxStamps integer
Nombre maximal de tampons sur la carte.
stampUrl string
URL de la page du tampon.
url string
URL vers laquelle l’utilisateur est redirigé en cliquant sur le bouton de la page du tampon.
strtYmd date
Date de début de validité du tampon.
endYmd date
Date d’expiration du tampon.
onsiteYn string
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.
onsitePwd string
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).
memo string
Note interne à des fins de référence.
activeYn string
Enum :
YN
Indique si le tampon est actif.
S’il est désactivé, le client ne peut pas l’utiliser.
userId string
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.
userNm string
Nom de l’utilisateur. Usage interne uniquement.
userPhnno string
Numéro de téléphone de l’utilisateur. Usage interne uniquement.
userEml string
Adresse e-mail de l’utilisateur. Usage interne uniquement.
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 de création du tampon. Exemple : 2025-07-21 11:50:20

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;

            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

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