Webhook — 연동 가이드

비볼디 Webhook의 연동 핵심은 HTTP Header 서명 검증입니다.

모든 Webhook 요청에는 X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature 등이 포함되며,
이를 검증함으로서 링크, 쿠폰, 스템프 이벤트를 안전하게 처리할 수 있습니다.

이 가이드는 각 Header 필드의 역할과 서명 검증 절차를 단계별로 설명하며, 제공된 샘플 코드를 참고하여 Webhook 요청을 빠르고 안전하게 통합할 수 있습니다.

HTTP Header

Webhook은 지정된 Callback URL로 POST 요청을 전송하며, X-Vivoldi-Signature, 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
요청 단위 고유 ID. 매 요청마다 새로 부여되며, 단일 요청을 식별할 때 사용됩니다.
X-Vivoldi-Event-Idstring
이벤트 고유 ID. 최초 요청이 실패하여 재시도되는 경우 동일한 Event-Id가 유지되어, 동일 이벤트에 대한 중복 처리를 방지할 수 있습니다.
X-Vivoldi-Webhook-Typestring
Default:GLOBAL
Enum:
GLOBALGROUP
GROUP Webhook이 활성화된 경우, 이 값은 GROUP으로 설정됩니다.
스템프 이벤트는 그룹 단위(스템프 카드) Webhook만 지원하므로 항상 GROUP이 사용됩니다.
링크와 쿠폰 이벤트는 그룹 Webhook이 설정되지 않은 경우, 기본적으로 GLOBAL로 전송됩니다.
X-Vivoldi-Resource-Typestring
Enum:
URLCOUPONSTAMP
URL: 단축 URL, COUPON: 쿠폰, STAMP: 스템프
X-Vivoldi-Action-Typestring
Enum:
NONEADDREMOVEUSE

NONE: 링크 클릭, 쿠폰 사용 이벤트에 사용됩니다. 별도의 추가 행위가 없습니다.
ADD: 스템프 도장 추가
REMOVE: 스템프 도장 제거
USE: 스템프 혜택 사용

향후 링크 또는 쿠폰 이벤트에 행위(Action)가 추가될 경우, 이 헤더 값(X-Vivoldi-Action-Type)이 확장될 수 있습니다.

X-Vivoldi-Comp-Idxinteger
조직 고유 IDX. [설정 → 조직 설정] 페이지에서 조직 IDX를 확인할 수 있습니다.
X-Vivoldi-Timestampinteger
요청 시점(UNIX epoch seconds). 허용 오차 ±5분 이내 권장.
X-Content-SHA256string
요청 Payload의 SHA-256 해시 값.
X-Vivoldi-Signaturestring
요청 서명 정보. t=타임스탬프, v1=서명값, alg=알고리즘.

전송 · 응답 · 재시도 정책

성공 기준

  • 수신 서버가 HTTP 2xx(예: 200) 응답 시 성공으로 간주합니다.
  • 서명 검증 후 즉시 200 OK를 반환하세요. Timeout이 5초이므로 장시간 처리 로직은 선 응답 후 비동기(Async) 방식으로 실행하세요.
대량 트래픽 환경에서 응답 지연은 재시도를 유발하여 중복 이벤트가 발생할 수 있습니다.

재시도 & 비활성화

  • 네트워크 오류 또는 비 2xx 응답 시 최대 5회 재시도.
  • 5회 연속 실패 시 Webhook 자동 비활성화되며 관리자에게 알림 메일 발송.
  • 중복 수신 방지: X-Vivoldi-Event-Id 값으로 중복 여부 확인.

정책은 운영 환경에 따라 조정될 수 있습니다.

헤더 검증 없이 Webhook을 처리해도 되나요?

기술적으로는 POST Body(Payload)만 수신하여 처리해도 동작하지만, 운영 환경에서는 헤더 검증을 반드시 수행해야 합니다.
헤더 검증을 생략하면 위조 요청, Payload 변조, 중복 처리, 추적 불가 등 심각한 보안 위험이 발생할 수 있습니다.

주요 위험:

  • 위조 요청(스푸핑): 공격자가 비볼디 서버를 사칭해 위조된 요청을 전송할 수 있습니다.
    헤더 검증이 구현되어 있지 않다면, 시스템은 이를 정상 요청으로 오인해 처리할 수 있습니다.
  • 데이터 변조: 네트워크 전송 구간에서 Payload가 조작되어도 서명 검증이 없으면 변조를 감지할 수 없습니다.
  • 중복 처리: 재전송 공격으로 동일 이벤트가 반복 수신되어, 중복 처리나 이중 적립이 발생할 수 있습니다.
  • 추적 불가: Request-Id 또는 Event-Id 헤더가 없으면 요청 추적, 오류 분석, 재현이 불가능해집니다.

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
쿠폰 번호.
domainstring
쿠폰 도메인.
nmstring
쿠폰 이름.
grpIdxinteger
그룹 IDX. 지정된 그룹이 있으면 전역(Global) 대신 그룹 Webhook이 호출됩니다.
grpNmstring
그룹 이름.
discTypeIdxinteger
Default:457
Enum:
457458
할인 유형. (457:요율 할인 %, 458:금액 할인)
discCurrencystring
Default:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
화폐 단위. 금액 할인(discTypeIdx:458) 사용시 필수.
formatDiscCurrencystring
화폐 통화 기호.
discdouble
Default:0
할인율(457)은 1~100% 범위, 할인금액(458)은 금액 입력
strtYmddate
쿠폰 유효 시작일.
endYmddate
쿠폰 유효 만료일.
useLimitinteger
Default:1
Enum:
012345
쿠폰 사용 가능 횟수. (0:무제한, 1~5: 횟수 제한)
imgUrlstring
쿠폰 이미지 URL.
onsiteYnstring
Default:N
Enum:
YN
현장쿠폰 여부. 쿠폰 페이지에 “쿠폰 사용” 버튼 표시 여부.
오프라인 매장에서 직원이 쿠폰 사용 시 필요.
onsitePwdstring
현장쿠폰 비밀번호. 쿠폰 사용 시 필요한 비밀번호.
memostring
내부 참고용 메모.
urlstring
URL 입력 시 쿠폰 페이지에 “쿠폰 사용하러 가기” 버튼이 표시됩니다.
버튼 또는 쿠폰 이미지 클릭 시 해당 URL로 리디렉션.
userIdstring
쿠폰 발급 대상자를 관리하는 데 사용됩니다.
쿠폰 사용 가능 횟수가 2~5로 설정된 경우 반드시 입력해야 하며, 일반적으로 웹사이트 회원의 로그인 ID 또는 영문 이름을 입력합니다.
userNmstring
쿠폰 사용자 이름. 내부 관리용.
userPhnnostring
쿠폰 사용자 연락처. 내부 관리용.
userEmlstring
쿠폰 사용자 이메일. 내부 관리용.
userEtc1string
추가 내부 관리용 필드.
userEtc2string
추가 내부 관리용 필드.
useCntinteger
쿠폰 사용 횟수.
regYmdtdatetime
쿠폰 생성 날짜. 예: 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
스템프 IDX.
domainstring
스템프 도메인.
cardIdxinteger
카드 IDX.
cardNmstring
카드 이름.
cardTtlstring
카드 제목.
stampsinteger
현재까지 적립된 도장 수.
maxStampsinteger
카드의 최대 도장 수.
stampUrlstring
스템프 페이지의 URL.
urlstring
스템프 페이지에서 버튼 클릭 시 이동될 URL.
strtYmddate
스템프 유효 시작일.
endYmddate
스템프 유효 만료일.
onsiteYnstring
Enum:
YN
현장 적립 여부로 값이 Y이면, 매장에서 직원이 도장 적립이 가능합니다.
onsitePwdstring
현장적립 비밀번호. 현장적립 여부(Y)일 경우, 스템프 혜택 사용 API 호출 시 반드시 필요합니다.
memostring
내부 참고용 메모.
activeYnstring
Enum:
YN
스템프 활성화 여부. 비활성화되면 고객이 스템프를 사용할 수 없습니다.
userIdstring
사용자 ID. 스템프 발급 대상자를 관리하는데 사용됩니다.
일반적으로 웹사이트 회원의 로그인 ID를 입력합니다.
설정되지 않으면 시스템에 의해 자동으로 사용자 ID가 생성됩니다.
userNmstring
사용자 이름. 내부 관리용.
userPhnnostring
사용자 연락처. 내부 관리용.
userEmlstring
사용자 이메일. 내부 관리용.
userEtc1string
추가 내부 관리용 필드.
userEtc2string
추가 내부 관리용 필드.
stampImgUrlstring
스템프 도장의 이미지 URL.
regYmdtdatetime
스템프 생성 날짜. 예: 2025-07-21 11:50:20

서명 검증 — 코드 샘플

Webhook 요청은 X-Vivoldi-Signature 헤더와 발급된 Webhook 비밀키(Secret Key)를 사용해 검증해야 합니다.
서명은 타임스탬프(t), 이벤트 ID(X-Vivoldi-Event-Id), 요청 Body의 SHA-256 해시값을 결합하여 다음 형식으로 생성됩니다.

timestamp.eventId.payloadSha256

이 문자열을 Secret Key로 HMAC-SHA256 해싱한 결과가 v1 값이며, 헤더의 X-Vivoldi-Signature 값과 일치해야 요청이 유효한 것으로 간주됩니다.


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

✨ 엔터프라이즈급 실시간 연동

Webhook은 링크, 쿠폰, 스템프 이벤트를 실시간으로 귀사의 CRM, 결제, 분석 시스템에 연동합니다.

고가용성 인프라, 안정적인 큐잉·재시도 메커니즘, HMAC 기반 보안 기능이 결합되어 Enterprise 환경에서 완벽한 신뢰성을 제공합니다.

엔터프라이즈 업그레이드