Vivoldi Webhook API & HMAC-Signaturprüfung

Eine sichere Webhook-Integration beginnt mit der Signaturprüfung über HTTP-Header.

Jede Vivoldi Webhook-Anfrage enthält Header wie X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature.
Durch die Validierung dieser Header lassen sich manipulierte Anfragen erkennen und Link-, Coupon- sowie Stempel-Events sicher verarbeiten.

Diese Anleitung erklärt Schritt für Schritt die Funktion der einzelnen Header, den Ablauf der HMAC-Signaturprüfung sowie Implementierungsbeispiele für Java, PHP und Node.js.

HTTP Header

Vivoldi Webhooks senden HTTP-POST-Anfragen an die registrierte Callback-URL.
Jede Anfrage enthält spezielle Header mit Signaturen, Zeitstempeln und Event-IDs, sodass sich die Herkunft der Anfrage und die Integrität der Payload zuverlässig überprüfen lassen.

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
Eindeutige Anfrage-ID. Wird für jede Anfrage neu generiert und dient zur eindeutigen Identifizierung des Vorgangs.
X-Vivoldi-Event-Id string
Eindeutige Ereignis-ID.
Wenn die erste Anfrage fehlschlägt und wiederholt wird, bleibt dieselbe Event-Id erhalten, um eine doppelte Verarbeitung desselben Ereignisses zu verhindern.
X-Vivoldi-Webhook-Type string
Default:GLOBAL
Enum:
GLOBALGROUP
Wenn ein GROUP-Webhook aktiviert ist, wird dieser Wert auf GROUP gesetzt.
Stempel-Events verwenden immer GROUP, da sie pro Stempelkarte arbeiten.
Link- und Coupon-Events werden als GLOBAL gesendet, wenn kein Gruppen-Webhook konfiguriert ist.
X-Vivoldi-Resource-Type string
Enum:
URLCOUPONSTAMP
URL: Kurzlink, COUPON: Coupon, STAMP: Stempel.
X-Vivoldi-Action-Type string
Enum:
NONEADDREMOVEUSE

NONE: Wird für Link-Klick- oder Coupon-Nutzungsereignisse verwendet; keine zusätzliche Aktion.
ADD: Stempel hinzufügen
REMOVE: Stempel entfernen
USE: Stempelbelohnung einlösen

Wenn künftig neue Aktionen für Link- oder Coupon-Events hinzugefügt werden, kann dieser Header-Wert (X-Vivoldi-Action-Type) erweitert werden.

X-Vivoldi-Comp-Idx integer
Organisations-ID (IDX).
Kann auf der Seite [Einstellungen → Organisations­einstellungen] eingesehen werden.
X-Vivoldi-Timestamp integer
Zeitpunkt der Anfrage (UNIX-Epoch-Sekunden). Empfohlene Toleranz: ±5 Minuten.
X-Content-SHA256 string
SHA-256-Hashwert des Anfrage-Payloads.
X-Vivoldi-Signature string
Signaturinformationen der Anfrage. Format: t=Zeitstempel, v1=Signaturwert, alg=Algorithmus.

Webhook-Zustellung, Antworten & Retry-Richtlinien

Vivoldi Webhooks definieren klare Regeln für erfolgreiche Antworten, automatische Wiederholungsversuche und die Deaktivierung von Endpoints, um eine zuverlässige Event-Zustellung sicherzustellen.
Das Verständnis dieser Richtlinien hilft dabei, doppelte Verarbeitungen und den Verlust von Events zu vermeiden.

Erfolgskriterien

Dies definiert die Kriterien, anhand derer Vivoldi erkennt, ob Ihr Server eine Webhook-Anfrage erfolgreich empfangen hat.

  • Die Zustellung gilt als erfolgreich, wenn der Server einen HTTP-Status 2xx (z. B. 200) zurückgibt.
  • Geben Sie nach der Signaturprüfung sofort 200 OK zurück. Da das Webhook-Timeout 5 Sekunden beträgt, sollten lang laufende Prozesse asynchron nach dem Senden von 200 OK verarbeitet werden.
In Umgebungen mit hohem Datenverkehr können verzögerte Antworten Wiederholungsversuche auslösen und doppelte Events verursachen.

Wiederholung & Deaktivierung

Bei fehlgeschlagener Zustellung führt Vivoldi automatische Wiederholungsversuche durch und deaktiviert das Webhook bei wiederholten Fehlern, um unnötigen Traffic zu vermeiden.

  • Automatische Wiederholungsversuche bis zu 5 Mal bei Netzwerkfehlern oder Nicht-2xx-Antworten.
  • Das Webhook wird nach 5 aufeinanderfolgenden Fehlern automatisch deaktiviert, und der Administrator erhält eine Benachrichtigungs-E-Mail.
  • Vermeidung doppelter Events: Verwenden Sie den Wert X-Vivoldi-Event-Id, um doppelte Events zu erkennen.

Die Richtlinien können je nach Betriebsumgebung angepasst werden.

Ist die Verarbeitung von Webhooks ohne Header-Signaturprüfung sicher?

Technisch gesehen können Webhooks auch nur anhand des POST-Body (Payload) verarbeitet werden. In Produktionsumgebungen sollte jedoch immer eine Header-Validierung durchgeführt werden.
Wird die Header-Prüfung ausgelassen, kann dies zu schwerwiegenden Sicherheitsrisiken wie gefälschten Anfragen, manipulierten Payloads, doppelter Verarbeitung und fehlender Nachvollziehbarkeit führen.

Wichtige Risiken:

  • Gefälschte Anfragen (Spoofing): Angreifer können sich als Vivoldi-Server ausgeben und gefälschte Webhook-Anfragen senden.
    Ohne Header-Validierung könnte das System diese irrtümlich als legitime Anfragen behandeln.
  • Datenmanipulation: Wird die Payload während der Übertragung verändert, kann dies ohne Signaturprüfung nicht erkannt werden.
  • Doppelte Verarbeitung: Replay-Angriffe können dazu führen, dass dasselbe Event mehrfach empfangen wird, was doppelte Verarbeitung oder doppelte Gutschriften verursachen kann.
  • Fehlende Nachvollziehbarkeit: Ohne Request-Id- oder Event-Id-Header werden Request-Tracking, Fehleranalyse und Problemreproduktion deutlich erschwert.

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
Coupon-Nummer.
domain string
Coupon-Domain.
nm string
Coupon-Name.
grpIdx integer
Gruppen-Index. Wenn eine Gruppe angegeben ist, wird das Gruppen-Webhook anstelle des globalen Webhooks ausgelöst.
grpNm string
Gruppenname.
discTypeIdx integer
Standard:457
Enum:
457458
Rabatt-Typ. (457: Prozentualer Rabatt %, 458: Betrag-Rabatt)
discCurrency string
Standard:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Währungseinheit. Pflichtfeld, wenn Betrag-Rabatt (discTypeIdx:458) verwendet wird.
formatDiscCurrency string
Währungssymbol.
disc double
Standard:0
Bei Prozent-Rabatt (457) 1–100%, bei Betrag-Rabatt (458) ein Geldbetrag.
imgUrl string
Coupon-Bild-URL.
onsiteYn string
Standard:N
Enum:
YN
Vor-Ort-Coupon. Legt fest, ob die Schaltfläche „Coupon verwenden“ auf der Coupon-Seite angezeigt wird.
Erforderlich, wenn Coupons im stationären Geschäft durch Mitarbeiter eingelöst werden.
onsitePwd string
Passwort für Vor-Ort-Coupon.
Wird benötigt, um den Coupon einzulösen.
memo string
Interne Notizen.
url string
Bei Eingabe einer URL wird auf der Coupon-Seite die Schaltfläche „Zur Coupon-Nutzung“ angezeigt.
Beim Klicken auf die Schaltfläche oder das Coupon-Bild erfolgt eine Weiterleitung zu dieser URL.
userId string
Wird zur Verwaltung der Coupon-Zielnutzer verwendet.
Wenn die Coupon-Nutzungsanzahl auf 2–5 gesetzt ist, muss dieses Feld angegeben werden.
Üblicherweise wird die Login-ID oder der englische Name des Website-Mitglieds eingetragen.
userNm string
Name des Coupon-Nutzers (intern).
userPhnno string
Telefonnummer des Coupon-Nutzers (intern).
userEml string
E-Mail-Adresse des Coupon-Nutzers (intern).
userEtc1 string
Zusätzliches internes Feld.
userEtc2 string
Zusätzliches internes Feld.
useCnt integer
Anzahl der Coupon-Nutzungen.
regYmdt datetime
Erstellungsdatum des Coupons. Beispiel: 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
Stempel-IDX.
domain string
Stempel-Domain.
cardIdx integer
Karten-IDX.
cardNm string
Kartenname.
cardTtl string
Kartentitel.
stamps integer
Anzahl der bisher gesammelten Stempel.
maxStamps integer
Maximale Anzahl der Stempel auf der Karte.
stampUrl string
URL der Stempelseite.
url string
URL, zu der weitergeleitet wird, wenn auf der Stempelseite die Schaltfläche angeklickt wird.
strtYmd date
Beginn des Gültigkeitszeitraums des Stempels.
endYmd date
Ablaufdatum des Stempels.
onsiteYn string
Enum:
YN
Gibt an, ob die Vor-Ort-Sammelfunktion aktiviert ist.
Wenn der Wert Y ist, kann das Personal Stempel direkt im Geschäft hinzufügen.
onsitePwd string
Passwort für die Vor-Ort-Stempelfunktion.
Erforderlich bei der Verwendung der Stempel-Belohnungs-API, wenn die Vor-Ort-Option aktiviert ist (Y).
memo string
Interne Notiz zu Referenzzwecken.
activeYn string
Enum:
YN
Gibt an, ob der Stempel aktiv ist.
Wenn er deaktiviert ist, kann der Kunde den Stempel nicht verwenden.
userId string
Benutzer-ID. Wird verwendet, um den Empfänger des Stempels zu verwalten.
Normalerweise entspricht dies der Anmelde-ID des Website-Mitglieds.
Wenn kein Wert gesetzt ist, wird die Benutzer-ID automatisch vom System generiert.
userNm string
Benutzername. Nur für die interne Verwaltung.
userPhnno string
Telefonnummer des Benutzers. Nur für die interne Verwaltung.
userEml string
E-Mail-Adresse des Benutzers. Nur für die interne Verwaltung.
userEtc1 string
Zusätzliches internes Verwaltungsfeld.
userEtc2 string
Zusätzliches internes Verwaltungsfeld.
stampImgUrl string
Bild-URL des Stempels.
regYmdt datetime
Erstellungsdatum des Stempels. Beispiel: 2025-07-21 11:50:20

Webhook-Signaturprüfung & Codebeispiele

Die Echtheit einer Webhook-Anfrage wird mithilfe des Headers X-Vivoldi-Signature und des ausgegebenen Secret Keys überprüft.

Die Signatur wird erzeugt, indem der Zeitstempel (t), die Event-ID (X-Vivoldi-Event-Id) und der SHA-256-Hash des Request-Bodys zu einer durch Punkte (.) getrennten Zeichenkette kombiniert und anschließend mit dem Secret Key per HMAC-SHA256 gehasht werden.

timestamp.eventId.payloadSha256

Wenn der erzeugte Hashwert (v1) mit dem Wert des Headers X-Vivoldi-Signature übereinstimmt, sollte die Anfrage als gültig verarbeitet werden.
Stimmen die Werte nicht überein, sollte die Anfrage sofort abgelehnt und im Log protokolliert werden.


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}`);
});

✨ Echtzeit-Integration auf Enterprise-Niveau

Optimiert für Enterprise-Umgebungen, die große Mengen an Link-, Coupon- und Stempel-Events verarbeiten.

Basierend auf hochverfügbarer Infrastruktur und zuverlässigen Queueing-Systemen ermöglicht Vivoldi stabile Integrationen mit CRM-, Zahlungs- und Analyseplattformen ohne Eventverlust — selbst bei plötzlichen Traffic-Spitzen.

Upgrade auf Enterprise