Vivoldi Webhook API & HMAC Signature Verification

Secure Webhook integration starts with HTTP header signature verification.

Every Vivoldi Webhook request includes headers such as X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature.
Validating these headers helps prevent forged requests and allows you to securely process link activity, coupon events, and stamp rewards in real time.

This guide walks through the purpose of each header, the HMAC signature verification workflow, and implementation examples for Java, PHP, and Node.js.

HTTP Header

Vivoldi Webhooks deliver HTTP POST requests to your registered Callback URL.
Each request includes dedicated headers containing signatures, timestamps, and event identifiers, enabling you to verify the request source and validate payload integrity.

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
A unique ID for identifying the request. A new ID is generated for each HTTP request and can be used to track specific requests.
X-Vivoldi-Event-Id string
A unique ID for identifying an event. The same Event ID is maintained when an event is retried, allowing the receiving system to prevent duplicate event processing.
X-Vivoldi-Webhook-Type string
Default:GLOBAL
Enum:
GLOBALGROUP
Indicates the scope where the Webhook is applied.
GROUP: Used when a group Webhook is applied.
Stamp events are supported only with group Webhooks, so they are always sent with GROUP.
Link and coupon events are sent with GLOBAL when no group Webhook is configured.
X-Vivoldi-Resource-Type string
Enum:
URLCOUPONSTAMP
The resource type associated with the event.
URL: Short URL
COUPON: Coupon
STAMP: Stamp
X-Vivoldi-Action-Type string
Enum:
CLICKUSEADDREMOVE
The type of action that triggered the event.

CLICK: Link click
USE: Coupon usage, stamp reward redemption
ADD: Stamp earned
REMOVE: Stamp removed

Use together with Resource-Type to accurately identify the event type.

X-Vivoldi-Comp-Idx integer
The organization identifier IDX. You can find it on the [Settings → Organization Settings] page.
X-Vivoldi-Timestamp integer
The timestamp when the request was created. It is provided in UNIX epoch seconds format. A time difference within ±5 minutes is recommended to account for server clock differences.
X-Content-SHA256 string
The SHA-256 hash value of the request payload. It can be used to verify payload integrity.
X-Vivoldi-Signature string
Signature information used to verify the request. Includes t: timestamp, v1: signature value, and alg: signature algorithm.

Webhook Delivery, Responses & Retry Policies

Vivoldi Webhooks define clear rules for successful responses, automatic retries, and endpoint deactivation to ensure reliable event delivery.
Understanding these policies helps prevent duplicate processing and reduces the risk of missing events.

Success Criteria

Webhook request success is determined based on the HTTP response status code returned by the receiving server.

  • An HTTP 2xx response is considered successful.
    All 2xx responses, including 200, 202, and 204, are accepted. The response body content is not validated.
  • The response timeout is 5 seconds.
    After verifying the signature, we recommend immediately returning a 2xx response and handling the actual processing asynchronously.
  • HTTP redirects are not followed. Responses such as 301 and 302 are treated as failures, so you must register the final Callback URL.
If the response takes longer than 5 seconds or returns a non-2xx status code, a retry may occur and the same event may be delivered multiple times.

Retry & Deactivation

When delivery fails, Webhook automatically performs retries. If repeated failures occur, the Webhook status is changed to System disabled to prevent unnecessary repeated delivery attempts.

  • Retries are performed for all HTTP response codes. Responses such as 400, 404, and 401 follow the same retry policy.
  • During retries, X-Vivoldi-Event-Id remains unchanged. The receiving server should use this value to prevent duplicate event processing.
  • Even after 5 failed retry attempts, the Webhook is not disabled immediately. An email notification is sent first, followed by a 60-minute grace period. If recovery does not occur during this period, the Webhook status is changed to System disabled.

Webhooks with the System disabled status can be found using the System disabled filter in the dashboard list and re-enabled.

Stage Timing Action
Attempts 1–3 Immediately · After 1 sec · After 2 sec Retries immediately to handle temporary network errors.
Attempt 4 After 10 min Retries while considering the time required for receiving server restarts or temporary issue recovery.
Attempt 5 After 30 min Performs the final retry attempt. If it fails, automatic retries are stopped.
Warning email Immediately after 5 failures The Webhook is not disabled immediately. A 60-minute grace period begins after the 5th failure, and an email notification is sent. Depending on the notification processing cycle, the email may be delayed by up to 10 minutes.
Grace period 30 min–90 min If the server recovers within the 60-minute grace period, Webhook delivery resumes without system disablement.
System disabled After 90 min If the first delivery attempt after the grace period also fails, the Webhook status is changed to System disabled.

If repeated failures occur from the same Callback URL, delivery is temporarily restricted to prevent requests from continuously accumulating until the receiving server recovers.
Short interruptions, such as deployments or temporary outages, are automatically resumed after recovery.

Coupon usage and stamp events are never lost.
Since these are important one-time events, they are stored in a queue during retries and the grace period, then delivered sequentially.
Link click events occur repeatedly, and analytics data is stored in Vivoldi. Therefore, these events are not stored separately or resent when Webhook delivery fails.

Webhook receiver implementation guide

  • The same event may be delivered more than once.
    The same event may be delivered multiple times due to retries or network conditions. Store X-Vivoldi-Event-Id and return 200 OK without additional processing if the event has already been processed.
    This is especially important for operations that must not be processed multiple times, such as coupon usage or stamp earning.
  • Event order is not guaranteed.
    A retried event may arrive after an event that occurred later.
    If event ordering is required, use the regYmdt and modYmdt values from the Payload as references.
  • Separating response handling from actual processing is recommended.
    Performing database operations or external API calls before sending a response may exceed the 5-second timeout limit.
    We recommend implementing the flow as: signature verification → 200 OK response → internal queue processing.
  • Verify the signature using the original request body.
    Parsing JSON and serializing it again may change the hash value due to differences in whitespace or key ordering.
    If your framework automatically transforms the request body, you must separately capture the raw body.
  • Ignore unknown fields.
    New fields may be added to the Payload in the future. Implement your integration to ignore fields that are not recognized.
  • The Secret Key depends on the Webhook target.
    If X-Vivoldi-Webhook-Type is GLOBAL, verify the signature using the global Secret Key. If it is GROUP, verify the signature using the Secret Key configured for the corresponding group or stamp card.

Is It Safe to Process Webhooks Without Header Signature Verification?

Technically, your server can process Webhooks using only the POST body (Payload). However, in production environments, header verification should always be enforced.
Skipping header validation can expose your system to serious security risks, including forged requests, payload tampering, duplicate processing, and loss of request traceability.

Key Risks:

  • Forged Requests (Spoofing): Attackers may impersonate Vivoldi servers and send fake Webhook requests.
    Without header verification, your system could mistakenly process these requests as legitimate.
  • Payload Tampering: If payload data is modified during network transmission, the change cannot be detected without signature validation.
  • Duplicate Processing: Replay attacks may repeatedly deliver the same event, causing duplicate processing or duplicate reward issuance.
  • Lack of Traceability: Without Request-Id or Event-Id headers, request tracking, debugging, and issue reproduction become significantly more difficult.

Payload

Event trigger point

Coupon Webhook sends event information to the configured Callback URL when a coupon redemption event occurs.

Webhook can be configured for individual coupons or coupon groups.
If both are configured, the coupon group settings take priority, and the same event is not sent multiple times. Coupon group Webhook is available on the Business plan and above.

The event is sent immediately after coupon redemption is processed, and the X-Vivoldi-Action-Type value is USE.
The event is sent in the same way regardless of whether the coupon is used through the dashboard, API, or offline processing.

Coupon redemption is a one-time event for each coupon and cannot be recovered if lost.
When the rate limit is exceeded or a retry is pending, events are stored in a queue and delivered in order.
When multiple coupons are processed at once through the API, event delivery may occur sequentially over multiple operations.
{
    "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 number.
domain string
Coupon page domain.
nm string
Coupon name.
grpIdx integer
IDX of the coupon group this coupon belongs to. Returns 0 if the coupon does not belong to any group.
When a group Webhook is configured, group settings take priority, and the X-Vivoldi-Webhook-Type value is sent as GROUP.
If no group Webhook is configured, the event is sent according to the individual coupon settings.
grpNm string
Coupon group name.
discTypeIdx integer
Enum:
457458
Discount type.
457: Percentage discount (%)
458: Fixed amount discount
discCurrency string
Default:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
Currency unit for the discount amount. Required when using fixed amount discounts (discTypeIdx=458).
formatDiscCurrency string
Currency display format.
disc double
Default:0
Discount value.
Percentage discounts (457) must be between 1~100%, while fixed amount discounts (458) represent the discount amount.
strtYmd date
Coupon validity start date.
endYmd date
Coupon expiration date.
useLimit integer
Default:1
Enum:
012345
Number of times the coupon can be used.
0: Unlimited
1~5: Available for the configured number of uses
imgUrl string
Coupon image URL.
onsiteYn string
Default:N
Enum:
YN
Whether in-store coupon redemption is supported. When the value is Y, a Redeem Coupon button is displayed on the coupon page, and the coupon can be redeemed at an offline store after employee verification.
onsitePwd string
Password used to verify in-store coupon redemption.
Since it is included as plain text in the Payload, do not store it in receiving server logs.
memo string
Internal note.
url string
When configured, a Go to Coupon Redemption button is displayed on the coupon page.
Users are redirected to this URL when they click the button or coupon image.
userId string
ID used to identify the coupon user.
Required when the coupon usage limit is set between 2~5. Typically, a service member ID or customer identifier is used.
userNm string
Coupon user name. Used for internal management and identification purposes.
userPhnno string
Coupon user contact information. Used for internal management and identification purposes.
userEml string
Coupon user email address. Used for internal management and identification purposes.
userEtc1 string
Additional field for internal management.
userEtc2 string
Additional field for internal management.
useCnt integer
Current coupon usage count. The current redemption event is not included in this value yet.
To include the current redemption, calculate it as useCnt + 1.
regYmdt datetime
Coupon creation date and time. Example: 2025-07-21 11:50:20
payloadVersion string
Payload specification version. Even if new fields are added, the meaning and behavior of existing fields remain unchanged until this value is updated.

Event trigger point

Webhook is configured in the stamp card. All stamp events generated from the card are delivered.

Events are sent when stamps are added, removed, or rewards are redeemed. The event type is identified using the X-Vivoldi-Action-Type header value.

  • ADD — Stamp added
  • REMOVE — Stamp removed
  • USE — Stamp reward redeemed

Regardless of whether the change is made through the dashboard, API, stamp management screen, or another method, the event is delivered with the same event type.

changedStamps represents the number of stamps affected. Whether stamps were added or removed is determined by the X-Vivoldi-Action-Type value.
Reward redemption (USE) does not change the stamp count, so 0 is sent.

The meaning of the stamps value depends on how the event is generated.
For stamp add, remove, and reward redemption events through the API, stamps represents the stamp count before the change. The value after the change can be calculated as stamps + changedStamps. For REMOVE, subtract changedStamps instead.
When the change is made from the dashboard stamp management screen, stamps represents the stamp count after the change.
To calculate the current stamp count accurately, use the value before the event and changedStamps to calculate the value after the change.
{
    "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
Stamp identifier IDX.
domain string
Stamp page domain.
cardIdx integer
Stamp card identifier IDX.
cardNm string
Stamp card name.
cardTtl string
Stamp card title.
stamps integer
Current stamp count. However, the reference point depends on how the event was triggered.
For stamp add, remove, and reward redemption events through the API, this value represents the stamp count before the change. The value after the change can be calculated using stamps and changedStamps.
(ADD: Stamp added, REMOVE: Stamp removed)
When the value is changed directly from the dashboard stamp management screen, this value represents the stamp count after the change.
maxStamps integer
Maximum number of stamps available on the stamp card.
changedStamps integer
Number of stamps changed by this event. Whether stamps were added or removed is determined by the X-Vivoldi-Action-Type value.
Reward redemption (USE) does not change the stamp count, so the value is 0.
stampUrl string
Stamp page URL.
url string
URL to navigate to when a button is clicked on the stamp page.
strtYmd date
Stamp validity start date.
endYmd date
Stamp validity expiration date.
onsiteYn string
Enum:
YN
Whether in-store stamp collection is supported. When the value is Y, employees can verify customers and add stamps at the store.
onsitePwd string
Password used to verify in-store stamp collection or reward redemption.
Required for related API requests when in-store stamp collection is enabled (onsiteYn=Y).
memo string
Internal note for reference.
activeYn string
Enum:
YN
Whether the stamp card is active. When disabled, customers cannot use the stamp card.
userId string
User ID used to identify the stamp user.
Typically, a service member ID or customer identifier is used.
If not provided, Vivoldi automatically generates one.
userNm string
Stamp user name. Used for internal management and identification purposes.
userPhnno string
Stamp user contact information. Used for internal management and identification purposes.
userEml string
Stamp user email address. Used for internal management and identification purposes.
userEtc1 string
Additional internal management field.
userEtc2 string
Additional internal management field.
stampImgUrl string
Stamp image URL.
regYmdt datetime
Stamp creation date and time. Example: 2025-07-21 11:50:20
payloadVersion string
Payload specification version. Even if new fields are added, the meaning and behavior of existing fields remain unchanged until this value is updated.

Webhook Signature Verification & Code Examples

The authenticity of a Webhook request is verified using the X-Vivoldi-Signature header and your issued Secret Key.

The signature is generated by combining the timestamp (t), event ID (X-Vivoldi-Event-Id), and the SHA-256 hash of the request body into a dot-separated string (.), then applying HMAC-SHA256 with the Secret Key.

timestamp.eventId.payloadSha256

If the generated hash value (v1) matches the value of the X-Vivoldi-Signature header, the request should be treated as valid.
If the values do not match, reject the request immediately and record the incident in your logs.


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

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

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

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

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

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

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

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

        return ResponseEntity.ok("success");
    }

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

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

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

✨ Enterprise-grade Real-time Integration

Optimized for enterprise environments that handle large-scale link, coupon, and stamp event processing.

Built on high-availability infrastructure and reliable queueing systems, Vivoldi delivers stable integrations with your CRM, payment, and analytics platforms without event loss, even during sudden traffic spikes.

Enterprise Upgrade