Webhook — Integrationshandbuch

Der Kern der Vivoldi-Webhook-Integration ist die Signaturprüfung der HTTP-Header.

Jede Webhook-Anfrage enthält X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature u. a.,
und durch deren Validierung verarbeiten Sie Link-, Coupon- und Stempel-Events sicher.

Dieses Handbuch erläutert die Rolle der einzelnen Header-Felder und das schrittweise Verfahren der Signaturprüfung und stellt Beispielcode bereit, um Webhook-Anfragen schnell und sicher zu integrieren.

HTTP Header

Webhook sendet eine POST-Anfrage an die angegebene Callback-URL und ermöglicht die Überprüfung der Integrität und Authentizität jeder Anfrage anhand von Headern wie X-Vivoldi-Signature und 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
Eindeutige Anfrage-ID. Wird für jede Anfrage neu generiert und dient zur eindeutigen Identifizierung des Vorgangs.
X-Vivoldi-Event-Idstring
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-Typestring
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-Typestring
Enum:
URLCOUPONSTAMP
URL: Kurzlink, COUPON: Coupon, STAMP: Stempel.
X-Vivoldi-Action-Typestring
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-Idxinteger
Organisations-ID (IDX).
Kann auf der Seite [Einstellungen → Organisations­einstellungen] eingesehen werden.
X-Vivoldi-Timestampinteger
Zeitpunkt der Anfrage (UNIX-Epoch-Sekunden). Empfohlene Toleranz: ±5 Minuten.
X-Content-SHA256string
SHA-256-Hashwert des Anfrage-Payloads.
X-Vivoldi-Signaturestring
Signaturinformationen der Anfrage. Format: t=Zeitstempel, v1=Signaturwert, alg=Algorithmus.

Übertragungs- · Antwort- · Wiederholungsrichtlinie

Erfolgskriterien

  • Als erfolgreich gilt es, wenn der empfangende Server eine HTTP-2xx-Antwort (z. B. 200) zurückgibt.
  • Nach der Signaturprüfung geben Sie sofort 200 OK zurück. Da das Timeout 5 Sekunden beträgt, sollten lange Prozesse asynchron nach der Antwort ausgeführt werden.
In Hochlastumgebungen können verzögerte Antworten Wiederholungen auslösen und doppelte Ereignisse verursachen.

Wiederholung & Deaktivierung

  • Bis zu 5 Wiederholungen bei Netzwerkfehlern oder Nicht-2xx-Antworten.
  • Nach 5 aufeinanderfolgenden Fehlern wird das Webhook automatisch deaktiviert und eine Benachrichtigungs-E-Mail an den Administrator gesendet.
  • Vermeidung von Duplikaten: Überprüfen Sie Duplikate anhand des X-Vivoldi-Event-Id-Werts.

Richtlinien können je nach Betriebsumgebung angepasst werden.

Kann man Webhooks ohne Header-Validierung verarbeiten?

Technisch genügt es, nur den POST-Body (Payload) zu verarbeiten, jedoch sollten Sie in der Produktionsumgebung stets eine Header-Verifikation durchführen.
Wird die Header-Prüfung ausgelassen, ergeben sich ernsthafte Sicherheitsrisiken wie gefälschte Anfragen, Manipulation des Payloads, doppelte Verarbeitung und Verlust der Nachverfolgbarkeit.

Hauptsächliche Risiken:

  • Gefälschte Anfragen (Spoofing): Ein Angreifer kann Vivoldi vortäuschen und gefälschte Anfragen senden.
    Ohne Header-Verifikation könnte das System diese als legitime Anfragen behandeln.
  • Manipulation von Daten: Wenn der Payload auf dem Transportweg verändert wird, kann dies ohne Signaturprüfung nicht erkannt werden.
  • Doppelte Verarbeitung: Replay-Angriffe können dazu führen, dass dasselbe Ereignis mehrfach empfangen wird und doppelte Verarbeitung oder Doppelgutschriften entstehen.
  • Verlust der Nachverfolgbarkeit: Ohne Request-Id oder Event-Id Header ist das Nachverfolgen von Anfragen, die Fehleranalyse und das Reproduzieren von Vorfällen nicht möglich.

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
Coupon-Nummer.
domainstring
Coupon-Domain.
nmstring
Coupon-Name.
grpIdxinteger
Gruppen-Index. Wenn eine Gruppe angegeben ist, wird das Gruppen-Webhook anstelle des globalen Webhooks ausgelöst.
grpNmstring
Gruppenname.
discTypeIdxinteger
Standard:457
Enum:
457458
Rabatt-Typ. (457: Prozentualer Rabatt %, 458: Betrag-Rabatt)
discCurrencystring
Standard:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Währungseinheit. Pflichtfeld, wenn Betrag-Rabatt (discTypeIdx:458) verwendet wird.
formatDiscCurrencystring
Währungssymbol.
discdouble
Standard:0
Bei Prozent-Rabatt (457) 1–100%, bei Betrag-Rabatt (458) ein Geldbetrag.
imgUrlstring
Coupon-Bild-URL.
onsiteYnstring
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.
onsitePwdstring
Passwort für Vor-Ort-Coupon.
Wird benötigt, um den Coupon einzulösen.
memostring
Interne Notizen.
urlstring
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.
userIdstring
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.
userNmstring
Name des Coupon-Nutzers (intern).
userPhnnostring
Telefonnummer des Coupon-Nutzers (intern).
userEmlstring
E-Mail-Adresse des Coupon-Nutzers (intern).
userEtc1string
Zusätzliches internes Feld.
userEtc2string
Zusätzliches internes Feld.
useCntinteger
Anzahl der Coupon-Nutzungen.
regYmdtdatetime
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

stampIdxinteger
Stempel-IDX.
domainstring
Stempel-Domain.
cardIdxinteger
Karten-IDX.
cardNmstring
Kartenname.
cardTtlstring
Kartentitel.
stampsinteger
Anzahl der bisher gesammelten Stempel.
maxStampsinteger
Maximale Anzahl der Stempel auf der Karte.
stampUrlstring
URL der Stempelseite.
urlstring
URL, zu der weitergeleitet wird, wenn auf der Stempelseite die Schaltfläche angeklickt wird.
strtYmddate
Beginn des Gültigkeitszeitraums des Stempels.
endYmddate
Ablaufdatum des Stempels.
onsiteYnstring
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.
onsitePwdstring
Passwort für die Vor-Ort-Stempelfunktion.
Erforderlich bei der Verwendung der Stempel-Belohnungs-API, wenn die Vor-Ort-Option aktiviert ist (Y).
memostring
Interne Notiz zu Referenzzwecken.
activeYnstring
Enum:
YN
Gibt an, ob der Stempel aktiv ist.
Wenn er deaktiviert ist, kann der Kunde den Stempel nicht verwenden.
userIdstring
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.
userNmstring
Benutzername. Nur für die interne Verwaltung.
userPhnnostring
Telefonnummer des Benutzers. Nur für die interne Verwaltung.
userEmlstring
E-Mail-Adresse des Benutzers. Nur für die interne Verwaltung.
userEtc1string
Zusätzliches internes Verwaltungsfeld.
userEtc2string
Zusätzliches internes Verwaltungsfeld.
stampImgUrlstring
Bild-URL des Stempels.
regYmdtdatetime
Erstellungsdatum des Stempels. Beispiel: 2025-07-21 11:50:20

Signaturprüfung — Codebeispiel

Webhook-Anfragen müssen mit dem X-Vivoldi-Signature-Header und dem ausgegebenen Webhook-Secret-Key überprüft werden.
Die Signatur wird erstellt, indem der Zeitstempel (t), die Ereignis-ID (X-Vivoldi-Event-Id) und der SHA-256-Hash des Anfragebodys im folgenden Format kombiniert werden:

timestamp.eventId.payloadSha256

Das Ergebnis der HMAC-SHA256-Hashing-Operation dieser Zeichenkette mit dem Secret Key ergibt den v1-Wert, der mit dem Header X-Vivoldi-Signature übereinstimmen muss, damit die Anfrage als gültig gilt.


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

Webhook integriert Link-, Coupon- und Stempel-Events in Echtzeit in Ihre CRM-, Zahlungs- und Analysesysteme.

Durch die Kombination aus hochverfügbarer Infrastruktur, stabilen Queue- und Wiederholungsmechanismen sowie HMAC-basierter Sicherheit bietet es höchste Zuverlässigkeit in Enterprise-Umgebungen.

Upgrade auf Enterprise