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
{
"linkId": "202509-event",
"domain": "https://event.com",
"compIdx": 50142,
"redirectType": 200,
"url": "https://my-event.com/books/event/202509",
"ttl": "September 2025 Event",
"description": "The 2025 National Book Festival will be held in the nation's capital at the Walter E.",
"metaImg": "https://my-event.com/storage-services/media/webcasts/2025/2509_thumbnail_00145901.jpg",
"memo": "",
"grpIdx": 0,
"grpNm": "",
"strtYmdt": "2025-09-01 00:00:00",
"endYmdt": "2025-09-30 23:59:59",
"expireYn": "Y",
"expireUrl": "https://my-event.com/books/event/closed",
"acesCnt": 17502,
"pernCnt": 16491,
"acesMaxCnt": 20000,
"referer": "https://www.google.com",
"queryString": "",
"country": "US",
"language": "en",
"regYmdt": "2025-08-31 18:10:22",
"modYmdt": "2025-08-31 18:10:22",
"payloadVersion": "v1"
}Payload Parameters
- linkIdstring
- 링크ID.
- domainstring
- 링크 도메인.
- urlstring
- 원본 URL.
- ttlstring
- 링크 제목.
- descriptionstring
redirectType값이200일 때, 메타 태그의 description 값을 설정합니다.- metaImgstring
redirectType값이200일 때, 메타 태그의 image 값을 설정합니다.- memostring
- 링크 관리용 메모입니다.
- grpIdxinteger
- 그룹 IDX. 지정된 그룹이 있으면 전역(Global) 대신 그룹 Webhook이 호출됩니다.
- grpNmstring
- 그룹 이름.
- strtYmdtdatetime
- 링크 유효기간 시작 일시.
- ednYmdtdatetime
- 링크 유효기간 만료 일시.
- expireYnstring
- Default:N
- Enum:YN
- 유효기간 만료 시
Y로 전달됩니다. - expireUrlstring
- 유효기간 만료 후 이동될 URL.
- acesCntinteger
- 총 클릭 수.
- pernCntinteger
- 클릭 사람 수 (고유 클릭 수 - Unique Users).
- acesMaxCntinteger
- 최대 클릭 허용 수. 초과 시 링크 접속이 차단됩니다.
- refererstring
- 요청이 발생한 페이지의 URL.
- queryStringstring
- 단축 URL 접속 시 포함된 Query String.
- countrystring
- 접속 사용자의 국가 코드(ISO-3166).
- languagestring
- 접속 사용자의 언어 코드(ISO-639).
- regYmdtdatetime
- 링크 생성 일시.
- modYmdtdatetime
- 링크 수정 일시.
- payloadVersionstring
- 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 환경에서 완벽한 신뢰성을 제공합니다.