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

Request Parameters

X-Vivoldi-Request-Id string
Eine eindeutige ID zur Identifizierung der Anfrage. Für jede HTTP-Anfrage wird eine neue ID erstellt, die zur Nachverfolgung bestimmter Anfragen verwendet werden kann.
X-Vivoldi-Event-Id string
Eine eindeutige ID zur Identifizierung eines Ereignisses. Auch bei erneuten Zustellversuchen bleibt dieselbe Event-ID erhalten, sodass das empfangende System doppelte Ereignisverarbeitung verhindern kann.
X-Vivoldi-Webhook-Type string
Default:GLOBAL
Enum:
GLOBALGROUP
Gibt den Anwendungsbereich des Webhooks an.
GROUP: Wird verwendet, wenn ein Gruppen-Webhook angewendet wird.
Stempelereignisse unterstützen nur Gruppen-Webhooks und werden daher immer als GROUP gesendet.
Link- und Coupon-Ereignisse werden als GLOBAL gesendet, wenn kein Gruppen-Webhook konfiguriert ist.
X-Vivoldi-Resource-Type string
Enum:
URLCOUPONSTAMP
Der Ressourcentyp, der mit dem Ereignis verbunden ist.
URL: Kurz-URL
COUPON: Coupon
STAMP: Stempel
X-Vivoldi-Action-Type string
Enum:
CLICKUSEADDREMOVE
Der Aktionstyp, der das Ereignis ausgelöst hat.

CLICK: Link-Klick
USE: Coupon-Nutzung, Einlösen einer Stempelbelohnung
ADD: Stempel hinzugefügt
REMOVE: Stempel entfernt

Verwenden Sie diesen Wert zusammen mit Resource-Type, um den Ereignistyp eindeutig zu bestimmen.

X-Vivoldi-Comp-Idx integer
Die IDX zur Identifizierung der Organisation. Sie finden sie auf der Seite [Einstellungen → Organisationseinstellungen].
X-Vivoldi-Timestamp integer
Der Zeitpunkt, zu dem die Anfrage erstellt wurde. Die Übertragung erfolgt im Format UNIX epoch seconds. Um Zeitunterschiede zwischen Servern zu berücksichtigen, wird eine Abweichung von ±5 Minuten empfohlen.
X-Content-SHA256 string
Der SHA-256-Hashwert des Request-Payloads. Er kann zur Überprüfung der Payload-Integrität verwendet werden.
X-Vivoldi-Signature string
Signaturinformationen zur Überprüfung der Anfrage. Enthält t: Zeitstempel, v1: Signaturwert, und alg: Signaturalgorithmus.

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

Der Erfolg einer Webhook-Anfrage wird anhand des HTTP-Statuscodes bestimmt, den der Empfangsserver zurückgibt.

  • Eine HTTP-Antwort mit 2xx wird als erfolgreich betrachtet.
    Alle 2xx-Antworten wie 200, 202 und 204 werden akzeptiert. Der Inhalt des Response-Bodys wird nicht geprüft.
  • Die Antwortzeitüberschreitung beträgt 5 Sekunden.
    Nach der Signaturprüfung empfehlen wir, sofort eine 2xx-Antwort zurückzugeben und die eigentliche Verarbeitung asynchron auszuführen.
  • HTTP-Weiterleitungen werden nicht verfolgt. Antworten wie 301 und 302 werden als Fehler behandelt. Daher muss die endgültige Callback-URL registriert werden.
Wenn die Antwort länger als 5 Sekunden dauert oder ein anderer Statuscode als 2xx zurückgegeben wird, kann ein Wiederholungsversuch erfolgen und dasselbe Ereignis kann mehrfach zugestellt werden.

Wiederholungsversuche & Deaktivierung

Bei einem Übertragungsfehler führt der Webhook automatisch Wiederholungsversuche durch. Wenn wiederholt Fehler auftreten, wird der Status des Webhooks auf Vom System deaktiviert geändert, um unnötige erneute Zustellversuche zu verhindern.

  • Wiederholungsversuche werden für alle HTTP-Statuscodes durchgeführt. Antworten wie 400, 404 und 401 unterliegen derselben Wiederholungsrichtlinie.
  • Während der Wiederholungsversuche bleibt X-Vivoldi-Event-Id unverändert. Der Empfangsserver sollte diesen Wert verwenden, um doppelte Event-Verarbeitung zu verhindern.
  • Auch nach 5 fehlgeschlagenen Wiederholungsversuchen wird der Webhook nicht sofort deaktiviert. Zunächst wird eine E-Mail-Benachrichtigung gesendet und eine 60-minütige Karenzzeit gewährt. Wenn keine Wiederherstellung innerhalb dieses Zeitraums erfolgt, wird der Webhook-Status auf Vom System deaktiviert gesetzt.

Webhooks mit dem Status Vom System deaktiviert können über den Filter Vom System deaktiviert in der Dashboard-Liste gefunden und wieder aktiviert werden.

Phase Zeitpunkt Aktion
Versuche 1–3 Sofort · Nach 1 Sek. · Nach 2 Sek. Es werden sofortige Wiederholungsversuche durchgeführt, um vorübergehende Netzwerkfehler zu behandeln.
4. Versuch Nach 10 Min. Ein weiterer Versuch wird durchgeführt, wobei die Zeit für Neustarts des Empfangsservers oder die Behebung vorübergehender Störungen berücksichtigt wird.
5. Versuch Nach 30 Min. Der letzte Wiederholungsversuch wird durchgeführt. Bei einem Fehler werden automatische Wiederholungsversuche beendet.
Warn-E-Mail Direkt nach 5 Fehlern Der Webhook wird nicht sofort deaktiviert. Nach dem fünften Fehler beginnt eine 60-minütige Karenzzeit und eine E-Mail-Benachrichtigung wird gesendet. Abhängig vom Benachrichtigungsprozess kann die E-Mail um bis zu etwa 10 Minuten verzögert werden.
Karenzzeit 30 Min.–90 Min. Wenn der Server innerhalb der 60-minütigen Karenzzeit wiederhergestellt wird, wird die Webhook-Zustellung ohne Systemdeaktivierung fortgesetzt.
Vom System deaktiviert Nach 90 Min. Wenn der erste Zustellversuch nach Ablauf der Karenzzeit ebenfalls fehlschlägt, wird der Webhook-Status auf Vom System deaktiviert geändert.

Wenn wiederholt Fehler bei derselben Callback-URL auftreten, wird die Zustellung vorübergehend eingeschränkt, damit sich Anfragen bis zur Wiederherstellung des Empfangsservers nicht weiter ansammeln.
Kurze Unterbrechungen wie Deployments oder vorübergehende Störungen werden nach der Wiederherstellung automatisch fortgesetzt.

Coupon-Nutzungs- und Stempelereignisse gehen niemals verloren.
Da es sich um wichtige einmalige Ereignisse handelt, werden sie während der Wiederholungsversuche und der Karenzzeit in einer Warteschlange gespeichert und anschließend der Reihe nach übertragen.
Link-Klickereignisse treten wiederholt auf und Analysedaten werden in Vivoldi gespeichert. Daher werden diese Ereignisse bei einem fehlgeschlagenen Webhook-Versand nicht separat gespeichert oder erneut gesendet.

Implementierungsleitfaden für den Webhook-Empfangsserver

  • Dasselbe Ereignis kann mehrfach zugestellt werden.
    Aufgrund von Wiederholungsversuchen oder Netzwerkbedingungen kann dasselbe Ereignis mehrfach empfangen werden. Speichern Sie X-Vivoldi-Event-Id und geben Sie 200 OK zurück, ohne eine weitere Verarbeitung auszuführen, wenn das Ereignis bereits verarbeitet wurde.
    Dies ist besonders wichtig für Vorgänge, die nicht doppelt ausgeführt werden dürfen, wie die Verwendung von Coupons oder das Hinzufügen von Stempeln.
  • Die Reihenfolge der Events ist nicht garantiert.
    Ein erneut gesendetes Event kann nach einem später erzeugten Event eintreffen.
    Wenn eine Verarbeitung in Reihenfolge erforderlich ist, verwenden Sie die Werte regYmdt und modYmdt aus dem Payload als Referenz.
  • Es wird empfohlen, die Antwortverarbeitung vom eigentlichen Prozess zu trennen.
    Datenbankoperationen oder externe API-Aufrufe vor dem Senden der Antwort können das Zeitlimit von 5 Sekunden überschreiten.
    Empfohlen wird folgender Ablauf: Signaturprüfung → 200 OK-Antwort → Verarbeitung über eine interne Queue.
  • Prüfen Sie die Signatur mit dem ursprünglichen Request Body.
    Das Parsen von JSON und eine erneute Serialisierung können den Hash-Wert durch Änderungen an Leerzeichen oder Schlüsselreihenfolge verändern.
    Wenn Ihr Framework den Request Body automatisch verarbeitet, erfassen Sie den Raw Body separat.
  • Ignorieren Sie unbekannte Felder.
    Dem Payload können zukünftig neue Felder hinzugefügt werden. Stellen Sie sicher, dass nicht bekannte Felder ignoriert werden.
  • Der Secret Key hängt vom Webhook-Ziel ab.
    Wenn X-Vivoldi-Webhook-Type GLOBAL ist, prüfen Sie die Signatur mit dem globalen Secret Key. Bei GROUP verwenden Sie den Secret Key, der für die entsprechende Gruppe oder Stempelkarte konfiguriert wurde.

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

Zeitpunkt des Ereignisses

Coupon-Webhooks senden Ereignisinformationen an die konfigurierte Callback-URL, sobald ein Coupon-Nutzungsereignis auftritt.

Webhooks können für einzelne Coupons oder Coupon-Gruppen konfiguriert werden.
Wenn beide konfiguriert sind, haben die Einstellungen der Coupon-Gruppe Vorrang, und dasselbe Ereignis wird nicht mehrfach gesendet. Coupon-Gruppen-Webhooks sind ab dem Business-Tarif oder höher verfügbar.

Das Ereignis wird unmittelbar nach Abschluss der Coupon-Nutzung gesendet, und der Wert von X-Vivoldi-Action-Type ist USE.
Unabhängig davon, ob der Coupon über das Dashboard, die API oder eine Offline-Verarbeitung verwendet wird, erfolgt die Übertragung auf dieselbe Weise.

Die Coupon-Nutzung ist ein einmaliges Ereignis für jeden Coupon und kann bei Verlust nicht wiederhergestellt werden.
Wenn das Aufruflimit überschritten wurde oder ein Wiederholungsversuch aussteht, werden die Ereignisse in einer Warteschlange gespeichert und der Reihe nach zugestellt.
Wenn mehrere Coupons gleichzeitig über die API verarbeitet werden, kann die Ereigniszustellung schrittweise über mehrere Vorgänge erfolgen.
{
    "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
Gutscheinnummer.
domain string
Domain der Gutscheinseite.
nm string
Gutscheinname.
grpIdx integer
IDX der Gruppe, zu der der Gutschein gehört. Wenn der Gutschein keiner Gruppe zugeordnet ist, lautet der Wert 0.
Wenn ein Gruppen-Webhook konfiguriert ist, hat die Gruppeneinstellung Vorrang und der Wert von X-Vivoldi-Webhook-Type wird als GROUP gesendet.
Wenn kein Gruppen-Webhook konfiguriert ist, erfolgt die Übertragung entsprechend der individuellen Gutscheineinstellungen.
grpNm string
Name der Gutschein-Gruppe.
discTypeIdx integer
Enum:
457458
Rabatttyp.
457: Prozentualer Rabatt (%)
458: Fester Rabattbetrag
discCurrency string
Default:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Währungseinheit für den Rabattbetrag. Bei Verwendung eines festen Rabattbetrags (discTypeIdx=458) ist dieses Feld erforderlich.
formatDiscCurrency string
Format zur Anzeige der Währung.
disc double
Default:0
Rabattwert.
Prozentuale Rabatte (457) liegen im Bereich von 1~100%, während feste Rabattbeträge (458) den abzuziehenden Betrag darstellen.
strtYmd date
Startdatum der Gutschein-Gültigkeit.
endYmd date
Ablaufdatum der Gutschein-Gültigkeit.
useLimit integer
Default:1
Enum:
012345
Anzahl der zulässigen Gutscheinverwendungen.
0: Unbegrenzt
1~5: Nutzung entsprechend der festgelegten Anzahl möglich
imgUrl string
URL des Gutscheinbildes.
onsiteYn string
Default:N
Enum:
YN
Gibt an, ob die Nutzung des Gutscheins vor Ort unterstützt wird. Bei Y wird auf der Gutscheinseite die Schaltfläche Gutschein verwenden angezeigt, und der Gutschein kann nach Bestätigung durch Mitarbeiter in einem Geschäft eingelöst werden.
onsitePwd string
Passwort zur Authentifizierung der Gutscheinverwendung vor Ort.
Da es im Payload im Klartext enthalten ist, darf es nicht in den Logs des empfangenden Servers gespeichert werden.
memo string
Interne Notiz.
url string
Wenn festgelegt, wird auf der Gutscheinseite die Schaltfläche Gutschein verwenden angezeigt.
Beim Klicken auf die Schaltfläche oder das Gutscheinbild wird zu dieser URL weitergeleitet.
userId string
ID zur Identifizierung des Gutscheinbenutzers.
Erforderlich, wenn das Nutzungslimit des Gutscheins auf 2~5 festgelegt ist. Normalerweise wird eine Service-Mitglieds-ID oder ein Kundenidentifikator verwendet.
userNm string
Name des Gutscheinbenutzers. Wird für die interne Verwaltung und Identifizierung verwendet.
userPhnno string
Kontaktdaten des Gutscheinbenutzers. Wird für die interne Verwaltung und Identifizierung verwendet.
userEml string
E-Mail-Adresse des Gutscheinbenutzers. Wird für die interne Verwaltung und Identifizierung verwendet.
userEtc1 string
Zusätzliches Feld für die interne Verwaltung.
userEtc2 string
Zusätzliches Feld für die interne Verwaltung.
useCnt integer
Aktuelle Anzahl der Gutscheinverwendungen. Das aktuelle Nutzungsereignis ist in diesem Wert noch nicht enthalten.
Wenn die Anzahl einschließlich der aktuellen Verwendung benötigt wird, berechnen Sie sie mit useCnt + 1.
regYmdt datetime
Datum und Uhrzeit der Gutscheinerstellung. Beispiel: 2025-07-21 11:50:20
payloadVersion string
Version der Payload-Spezifikation. Auch wenn neue Felder hinzugefügt werden, bleiben Bedeutung und Verhalten bestehender Felder unverändert, bis dieser Wert geändert wird.

Zeitpunkt des Ereignisses

Webhooks werden in der Stempelkarte konfiguriert. Alle Stempelereignisse, die über diese Karte ausgelöst werden, werden gesendet.

Die Übertragung erfolgt bei Ereignissen zum Hinzufügen, Entfernen oder Einlösen von Stempelbelohnungen. Der Ereignistyp wird anhand des Werts im Header X-Vivoldi-Action-Type bestimmt.

  • ADD — Stempel hinzugefügt
  • REMOVE — Stempel entfernt
  • USE — Stempelbelohnung eingelöst

Unabhängig davon, ob die Änderung über das Dashboard, die API, die Stempelverwaltung oder eine andere Methode erfolgt, wird derselbe Ereignistyp gesendet.

changedStamps gibt die Anzahl der betroffenen Stempel an. Ob Stempel hinzugefügt oder entfernt wurden, wird anhand des Werts von X-Vivoldi-Action-Type bestimmt.
Beim Einlösen einer Belohnung (USE) ändert sich die Stempelanzahl nicht, daher wird 0 übertragen.

Die Bedeutung des Werts stamps hängt davon ab, wie das Ereignis ausgelöst wurde.
Bei Stempel-Hinzufügung, Entfernung und Belohnungseinlösung über die API stellt stamps die Stempelanzahl vor der Änderung dar. Der Wert nach der Änderung kann mit stamps + changedStamps berechnet werden. Bei REMOVE wird changedStamps abgezogen.
Wenn die Änderung über die Stempelverwaltung im Dashboard erfolgt, stellt stamps die Stempelanzahl nach der Änderung dar.
Um die aktuelle Stempelanzahl korrekt zu berechnen, verwenden Sie den Wert vor dem Ereignis und changedStamps, um den Wert nach der Änderung zu bestimmen.
{
    "stampIdx": 16,
    "domain": "https://vvd.bz",
    "cardIdx": 1,
    "cardNm": "Accumulate 10 Americanos",
    "cardTtl": "Collect 10 stamps to get one free Americano.",
    "stamps": 10,
    "maxStamps": 12,
    "changedStamps": 2,
    "stampUrl": "https://vvd.bz/stamp/274",
    "url": "https://myshopping.com",
    "strtYmd": "2025-01-01",
    "endYmd": "2026-12-31",
    "onsiteYn": "Y",
    "onsitePwd": "123456",
    "memo": null,
    "activeYn": "Y",
    "userId": "NKkDu9X4p4mQ",
    "userNm": null,
    "userPhnno": null,
    "userEml": null,
    "userEtc1": null,
    "userEtc2": null,
    "stampImgUrl": "https://cdn.vivoldi.com/www/image/icon/stamp/icon.stamp.1.webp",
    "regYmdt": "2025-10-30 05:11:35",
    "payloadVersion": "v1"
}

Payload Parameters

stampIdx integer
IDX zur Identifizierung des Stempels.
domain string
Domain der Stempelseite.
cardIdx integer
IDX zur Identifizierung der Stempelkarte.
cardNm string
Name der Stempelkarte.
cardTtl string
Titel der Stempelkarte.
stamps integer
Aktuelle Anzahl der Stempel. Der Bezugszeitpunkt kann jedoch je nach Auslöser des Ereignisses unterschiedlich sein.
Bei Ereignissen zum Hinzufügen, Entfernen und Einlösen von Belohnungen über die API entspricht dieser Wert der Stempelanzahl vor der Änderung. Der Wert nach der Änderung kann mit stamps und changedStamps berechnet werden.
(ADD: Stempel hinzugefügt, REMOVE: Stempel entfernt)
Wenn die Änderung direkt über die Stempelverwaltung im Dashboard vorgenommen wurde, entspricht dieser Wert der Stempelanzahl nach der Änderung.
maxStamps integer
Maximale Anzahl der Stempel auf der Stempelkarte.
changedStamps integer
Anzahl der durch dieses Ereignis geänderten Stempel. Ob Stempel hinzugefügt oder entfernt wurden, wird anhand des Werts von X-Vivoldi-Action-Type bestimmt.
Beim Einlösen einer Belohnung (USE) ändert sich die Stempelanzahl nicht, daher ist der Wert 0.
stampUrl string
URL der Stempelseite.
url string
URL, zu der beim Klicken auf eine Schaltfläche auf der Stempelseite weitergeleitet wird.
strtYmd date
Startdatum der Stempelgültigkeit.
endYmd date
Ablaufdatum der Stempelgültigkeit.
onsiteYn string
Enum:
YN
Gibt an, ob das Sammeln von Stempeln vor Ort unterstützt wird. Wenn der Wert Y ist, können Mitarbeiter Kunden verifizieren und Stempel im Geschäft hinzufügen.
onsitePwd string
Passwort zur Verifizierung der Stempelvergabe vor Ort oder der Nutzung von Belohnungen.
Erforderlich für entsprechende API-Aufrufe, wenn die Stempelvergabe vor Ort aktiviert ist (onsiteYn=Y).
memo string
Interne Notiz.
activeYn string
Enum:
YN
Gibt an, ob die Stempelkarte aktiv ist. Wenn sie deaktiviert ist, können Kunden die Stempelkarte nicht verwenden.
userId string
Benutzer-ID zur Identifizierung des Stempelkartenbenutzers.
Normalerweise wird eine Service-Mitglieds-ID oder ein Kundenidentifikator verwendet.
Wenn kein Wert festgelegt ist, wird dieser automatisch von Vivoldi erstellt.
userNm string
Name des Stempelkartenbenutzers. Wird zur internen Verwaltung und Identifizierung verwendet.
userPhnno string
Kontaktdaten des Stempelkartenbenutzers. Wird zur internen Verwaltung und Identifizierung verwendet.
userEml string
E-Mail-Adresse des Stempelkartenbenutzers. Wird zur internen Verwaltung und Identifizierung verwendet.
userEtc1 string
Zusätzliches Feld für die interne Verwaltung.
userEtc2 string
Zusätzliches Feld für die interne Verwaltung.
stampImgUrl string
URL des Stempelbildes.
regYmdt datetime
Datum und Uhrzeit der Erstellung des Stempels. Beispiel: 2025-07-21 11:50:20
payloadVersion string
Version der Payload-Spezifikation. Auch wenn neue Felder hinzugefügt werden, bleiben Bedeutung und Verhalten bestehender Felder unverändert, bis dieser Wert aktualisiert wird.

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;

            // Timestamp tolerance (±5 minutes)
            // X-Vivoldi-Timestamp is in MILLISECONDS, so compare against System.currentTimeMillis().
            if (Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)) > 300_000L) {
                log.warn("Webhook timestamp out of tolerance: {}", timestamp);
                return false;
            }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        // Timestamp tolerance (±5 minutes)
        // X-Vivoldi-Timestamp is in MILLISECONDS, so compare against time() * 1000.
        if (abs(time() * 1000 - (int)$timestamp) > 300000) {
            return false;
        }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        // Timestamp tolerance (±5 minutes)
        // X-Vivoldi-Timestamp is in MILLISECONDS, so compare against Date.now() directly.
        if (Math.abs(Date.now() - Number(timestamp)) > 300000) return false;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

✨ 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