비볼디 Webhook API 연동 · 서명 검증 가이드

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

모든 Webhook 요청에는 X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature 등의 헤더가 포함됩니다.
이 헤더를 검증하면 위조 요청을 차단하고 링크, 쿠폰, 스탬프 이벤트를 안전하게 처리할 수 있습니다.

각 헤더 필드의 역할, 서명 검증 절차, Java · PHP · Node.js 샘플 코드까지 단계별로 안내합니다.

HTTP Header

비볼디 Webhook은 등록된 Callback URL로 HTTP POST 요청을 전송합니다.
요청에는 서명·타임스탬프·이벤트 식별자가 포함된 전용 헤더가 함께 전달되며, 이를 통해 요청 출처의 진위와 페이로드 무결성을 검증할 수 있습니다.

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
요청 식별을 위한 고유 ID입니다. 각 HTTP 요청마다 새롭게 생성되며, 특정 요청을 추적할 때 사용합니다.
X-Vivoldi-Event-Id string
이벤트 식별을 위한 고유 ID입니다. 동일 이벤트가 재시도되는 경우에도 동일한 Event ID가 유지되어, 수신 측에서 중복 이벤트를 방지하는 데 사용할 수 있습니다.
X-Vivoldi-Webhook-Type string
Default:GLOBAL
Enum:
GLOBALGROUP
Webhook 적용 범위를 나타냅니다.
GROUP: 그룹 Webhook이 적용된 경우 사용됩니다.
스탬프 이벤트는 그룹 단위 Webhook만 지원하므로 항상 GROUP으로 전송됩니다.
링크와 쿠폰 이벤트는 그룹 Webhook이 설정되지 않은 경우 GLOBAL로 전송됩니다.
X-Vivoldi-Resource-Type string
Enum:
URLCOUPONSTAMP
이벤트 대상 리소스 유형입니다.
URL: 단축 URL
COUPON: 쿠폰
STAMP: 스탬프
X-Vivoldi-Action-Type string
Enum:
CLICKUSEADDREMOVE
이벤트가 발생한 작업 유형입니다.

CLICK: 링크 클릭
USE: 쿠폰 사용, 스탬프 보상 사용
ADD: 스탬프 적립
REMOVE: 스탬프 삭제

Resource-Type과 함께 사용하면 이벤트 유형을 정확하게 구분할 수 있습니다.

X-Vivoldi-Comp-Idx integer
조직 식별자 IDX입니다. [설정 → 조직 설정] 페이지에서 확인할 수 있습니다.
X-Vivoldi-Timestamp integer
요청 생성 시각입니다. UNIX epoch seconds 형식으로 전달됩니다. 서버 시간 차이를 고려해 ±5분 이내의 오차를 권장합니다.
X-Content-SHA256 string
요청 Payload의 SHA-256 해시 값입니다. Payload 무결성 검증에 사용할 수 있습니다.
X-Vivoldi-Signature string
요청 검증을 위한 서명 정보입니다. t: 타임스탬프, v1: 서명 값, alg: 서명 알고리즘을 포함합니다.

Webhook 전송 · 응답 · 재시도 정책

비볼디 Webhook은 안정적인 이벤트 전달을 위해 응답 성공 기준, 자동 재시도, 비활성화 정책을 명확하게 정의하고 있습니다.
각 정책을 숙지하면 중복 처리와 이벤트 유실을 방지할 수 있습니다.

성공 기준

Webhook 요청 성공 여부는 수신 서버의 HTTP 응답 상태 코드를 기준으로 판단합니다.

  • HTTP 2xx 응답이면 성공으로 처리합니다.
    200, 202, 204 등 모든 2xx 응답을 허용하며, 응답 본문 내용은 검증하지 않습니다.
  • 응답 대기 시간은 5초입니다.
    서명을 검증한 후 즉시 2xx 응답을 반환하고, 실제 처리는 비동기로 처리하는 방식을 권장합니다.
  • HTTP 리다이렉트를 따라가지 않습니다. 301, 302 등의 응답도 실패로 처리되므로 최종 Callback URL을 등록해야 합니다.
응답 시간이 5초를 초과하거나 2xx 이외의 응답을 반환하면 재시도가 발생할 수 있으며, 동일한 이벤트가 중복 전달될 수 있습니다.

재시도 & 중지

전송 실패 시 자동으로 재시도를 수행하며, 반복적인 실패가 발생하면 Webhook을 시스템 중지 상태로 변경하여 불필요한 반복 요청을 방지합니다.

  • 모든 HTTP 응답 코드에 대해 재시도합니다. 400, 404, 401 등의 응답도 동일한 재시도 정책이 적용됩니다.
  • 재시도 과정에서도 X-Vivoldi-Event-Id는 동일하게 유지됩니다. 수신 서버에서는 해당 값을 기준으로 중복 이벤트 처리를 방지하세요.
  • 5회 재시도 실패 후에도 즉시 중지되지 않습니다. 이메일로 안내한 후 60분의 유예 시간을 제공하며, 이후에도 복구되지 않으면 시스템 중지 상태로 변경됩니다.

시스템 중지된 Webhook은 대시보드 목록의 시스템 중지 필터에서 확인한 후 다시 활성화할 수 있습니다.

단계 시점 동작
1~3회 시도 즉시 · 1초 후 · 2초 후 즉시 재시도하여 일시적인 네트워크 오류에 대응합니다.
4회 시도 10분 후 수신 서버 재시작이나 일시적인 장애 복구 시간을 고려해 재시도합니다.
5회 시도 30분 후 최종 재시도를 진행합니다. 이후 실패하면 자동 재시도를 종료합니다.
경고 이메일 5회 실패 직후 Webhook은 즉시 중지되지 않습니다. 5회째 실패와 동시에 60분 유예가 시작되고 이메일이 발송됩니다. 알림 처리 주기에 따라 최대 10분 정도 지연될 수 있습니다.
유예 30분 ~ 90분 60분 유예 시간 내에 복구되면 시스템 중지 없이 Webhook 전송이 재개됩니다.
시스템 중지 90분 이후 유예 종료 후 첫 번째 전송에서도 실패하면 해당 Webhook을 시스템 중지 상태로 변경합니다.

같은 Callback URL에서 반복적인 실패가 발생하면 일시적으로 전송을 제한하고, 수신 서버가 정상화될 때까지 요청이 계속 누적되지 않도록 관리합니다.
배포나 일시적인 장애처럼 짧은 중단 상황은 복구 후 자동으로 전송이 재개됩니다.

쿠폰 사용·스탬프 이벤트는 유실되지 않습니다.
한 번만 발생하는 중요한 이벤트이므로 재시도 및 유예 기간 동안 대기열에 보관한 후 순서대로 전달합니다.
링크 클릭 이벤트는 반복적으로 발생하며 통계 데이터가 비볼디에 저장되므로, Webhook 전송 실패 시 해당 이벤트를 별도로 보관하거나 재전송하지 않습니다.

Webhook 수신 서버 구현 가이드

  • 동일한 이벤트가 중복 전달될 수 있습니다.
    재시도 또는 네트워크 상황에 따라 동일한 이벤트가 여러 번 전달될 수 있습니다. X-Vivoldi-Event-Id를 저장하고 이미 처리한 이벤트라면 추가 작업 없이 200 OK를 반환하세요.
    쿠폰 사용이나 스탬프 적립처럼 중복 처리하면 안 되는 작업에서는 특히 중요합니다.
  • 이벤트 순서는 보장되지 않습니다.
    재시도된 이벤트가 이후 발생한 이벤트보다 늦게 도착할 수 있습니다.
    순서 처리가 필요한 경우 Payload의 regYmdt, modYmdt 값을 기준으로 판단하세요.
  • 응답과 실제 처리를 분리하는 것을 권장합니다.
    DB 저장이나 외부 API 호출을 응답 전에 수행하면 5초 제한을 초과할 수 있습니다.
    서명 검증 → 200 OK 응답 → 내부 큐 처리 순서로 구현하는 방식을 권장합니다.
  • 요청 본문은 원문 그대로 서명을 검증하세요.
    JSON을 파싱한 후 다시 직렬화하면 공백이나 키 순서 변경으로 인해 해시 값이 달라질 수 있습니다.
    프레임워크에서 요청 본문을 자동 변환하는 경우 raw body를 별도로 확보해야 합니다.
  • 알 수 없는 필드는 무시하세요.
    Payload에는 향후 새로운 필드가 추가될 수 있습니다. 알 수 없는 필드는 무시하도록 구현하세요.
  • Secret Key는 Webhook 대상에 따라 다릅니다.
    X-Vivoldi-Webhook-TypeGLOBAL이면 전역 Secret Key로, GROUP이면 해당 그룹 또는 스탬프 카드에 설정된 Secret Key로 서명을 검증합니다.

헤더 서명 검증 없이 Webhook을 처리해도 안전한가요?

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

주요 위험:

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

Payload

이벤트 발생 시점

쿠폰 Webhook은 쿠폰 사용 이벤트 발생 시 설정된 Callback URL로 이벤트 정보를 전송합니다.

Webhook은 개별 쿠폰 또는 쿠폰 그룹에서 설정할 수 있습니다.
두 곳 모두 설정된 경우 쿠폰 그룹 설정이 우선 적용되며, 동일한 이벤트는 중복 전송되지 않습니다. 쿠폰 그룹 Webhook은 비즈니스 요금제 이상에서 사용할 수 있습니다.

쿠폰 사용이 처리되는 즉시 전송되며 X-Vivoldi-Action-Type 값은 USE입니다.
대시보드, API, 오프라인 사용 처리 등 어떤 경로로 쿠폰이 사용되어도 동일한 방식으로 전송됩니다.

쿠폰 사용은 쿠폰별 1회성 이벤트이므로 유실되면 복구할 수 없습니다.
호출 한도 초과 또는 재시도 대기 상황에서는 이벤트를 대기열에 보관한 후 순서대로 전달합니다.
API를 통해 여러 쿠폰을 한 번에 처리하는 경우 이벤트 전달이 여러 번에 걸쳐 순차적으로 진행될 수 있습니다.
{
    "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
쿠폰 번호.
domain string
쿠폰 페이지 도메인.
nm string
쿠폰 이름.
grpIdx integer
쿠폰이 속한 그룹 IDX입니다. 그룹이 없는 경우 0입니다.
그룹 Webhook이 설정된 경우 그룹 설정이 우선 적용되며, X-Vivoldi-Webhook-Type 값은 GROUP으로 전송됩니다.
그룹 Webhook이 설정되지 않은 경우 개별 쿠폰 설정에 따라 전송됩니다.
grpNm string
쿠폰 그룹 이름.
discTypeIdx integer
Enum:
457458
할인 유형입니다.
457: 할인율(%)
458: 할인 금액
discCurrency string
Default:KRW
Enum:
KRWCADCNYEURGBPIDRJPYMURRUBSGDUSD
할인 금액의 화폐 단위입니다. 금액 할인(discTypeIdx=458) 사용 시 필수입니다.
formatDiscCurrency string
화폐 표시 형식입니다.
disc double
Default:0
할인 값입니다.
할인율(457)은 1~100% 범위이며, 금액 할인(458)은 할인 금액을 입력합니다.
strtYmd date
쿠폰 유효 시작일.
endYmd date
쿠폰 유효 만료일.
useLimit integer
Default:1
Enum:
012345
쿠폰 사용 가능 횟수입니다.
0: 제한 없음
1~5: 설정한 횟수만큼 사용 가능
imgUrl string
쿠폰 이미지 URL.
onsiteYn string
Default:N
Enum:
YN
현장 사용 지원 여부입니다. Y인 경우 쿠폰 페이지에 쿠폰 사용 버튼이 표시되며, 오프라인 매장에서 직원 확인 후 사용할 수 있습니다.
onsitePwd string
현장 쿠폰 사용 인증용 비밀번호입니다.
Payload에 평문으로 포함되므로 수신 서버 로그에 저장되지 않도록 주의하세요.
memo string
내부 참고용 메모.
url string
설정된 경우 쿠폰 페이지에 쿠폰 사용하러 가기 버튼이 표시됩니다.
버튼 또는 쿠폰 이미지 클릭 시 해당 URL로 이동합니다.
userId string
쿠폰 사용자를 식별하기 위한 ID입니다.
쿠폰 사용 가능 횟수가 2~5로 설정된 경우 필수입니다. 일반적으로 서비스 회원 ID 또는 고객 식별 값을 사용합니다.
userNm string
쿠폰 사용자 이름입니다. 내부 관리 및 식별 용도로 사용됩니다.
userPhnno string
쿠폰 사용자 연락처입니다. 내부 관리 및 식별 용도로 사용됩니다.
userEml string
쿠폰 사용자 이메일입니다. 내부 관리 및 식별 용도로 사용됩니다.
userEtc1 string
추가 내부 관리용 필드.
userEtc2 string
추가 내부 관리용 필드.
useCnt integer
현재 쿠폰 사용 횟수입니다. 현재 사용 이벤트는 아직 반영되지 않은 값입니다.
이번 사용까지 포함한 횟수가 필요한 경우 useCnt + 1로 계산하세요.
regYmdt datetime
쿠폰 생성 일시. 예: 2025-07-21 11:50:20
payloadVersion string
Payload 규격 버전입니다. 필드가 추가되더라도 이 값이 변경되기 전까지 기존 필드의 의미와 동작은 유지됩니다.

이벤트 발생 시점

Webhook은 스탬프 카드에서 설정합니다. 해당 카드로 발급된 모든 스탬프 이벤트가 전송됩니다.

스탬프 변경 및 사용 이벤트 발생 시 전송되며, X-Vivoldi-Action-Type 헤더 값으로 이벤트 유형을 구분합니다.

  • ADD — 스탬프 적립
  • REMOVE — 스탬프 삭제
  • USE — 스탬프 혜택 사용

대시보드, API, 스탬프 수정 화면 등 어떤 경로에서 변경되었는지와 관계없이 동일한 이벤트 유형으로 전송됩니다.

changedStamps는 변경된 스탬프 수량입니다. 증가 또는 감소 여부는 X-Vivoldi-Action-Type 값으로 판단합니다.
혜택 사용(USE)은 스탬프 수량이 변경되지 않으므로 0으로 전달됩니다.

stamps 값의 기준은 이벤트 발생 경로에 따라 다릅니다.
API를 통한 적립·삭제·혜택 사용 이벤트에서는 stamps변경 전 스탬프 수입니다. 변경 후 값은 stamps + changedStamps로 계산할 수 있으며, REMOVE인 경우에는 changedStamps를 차감합니다.
대시보드 스탬프 수정 화면에서 변경한 경우에는 stamps변경 후 스탬프 수로 전달됩니다.
현재 스탬프 수를 정확히 계산해야 하는 경우에는 이벤트 이전 값과 changedStamps를 이용해 변경 후 값을 계산하세요.
{
    "stampIdx": 16,
    "domain": "https://vvd.bz",
    "cardIdx": 1,
    "cardNm": "Accumulate 10 Americanos",
    "cardTtl": "Collect 10 stamps to get one free Americano.",
    "stamps": 10,
    "maxStamps": 12,
    "changedStamps": 2,
    "stampUrl": "https://vvd.bz/stamp/274",
    "url": "https://myshopping.com",
    "strtYmd": "2025-01-01",
    "endYmd": "2026-12-31",
    "onsiteYn": "Y",
    "onsitePwd": "123456",
    "memo": null,
    "activeYn": "Y",
    "userId": "NKkDu9X4p4mQ",
    "userNm": null,
    "userPhnno": null,
    "userEml": null,
    "userEtc1": null,
    "userEtc2": null,
    "stampImgUrl": "https://cdn.vivoldi.com/www/image/icon/stamp/icon.stamp.1.webp",
    "regYmdt": "2025-10-30 05:11:35",
    "payloadVersion": "v1"
}

Payload Parameters

stampIdx integer
스탬프 식별자 IDX.
domain string
스탬프 페이지 도메인.
cardIdx integer
스탬프 카드 식별자 IDX.
cardNm string
스탬프 카드 이름.
cardTtl string
스탬프 카드 제목.
stamps integer
현재 스탬프 수입니다. 단, 이벤트 발생 경로에 따라 기준 시점이 다릅니다.
API를 통한 적립·삭제·혜택 사용 이벤트에서는 변경 전 스탬프 수입니다. 변경 후 값은 stampschangedStamps를 이용해 계산할 수 있습니다.
(ADD: 도장 적립, REMOVE: 도장 삭제)
대시보드 스탬프 수정 화면에서 직접 변경한 경우에는 변경 후 스탬프 수입니다.
maxStamps integer
스탬프 카드의 최대 스탬프 수.
changedStamps integer
이번 이벤트로 변경된 스탬프 수량입니다. 증가 또는 감소 여부는 X-Vivoldi-Action-Type 값으로 판단합니다.
혜택 사용(USE)은 스탬프 수량이 변경되지 않으므로 0입니다.
stampUrl string
스탬프 페이지 URL.
url string
스탬프 페이지에서 버튼 클릭 시 이동할 URL.
strtYmd date
스탬프 유효 시작일.
endYmd date
스탬프 유효 만료일.
onsiteYn string
Enum:
YN
현장 적립 지원 여부입니다. 값이 Y이면 매장에서 직원 인증을 통한 스탬프 적립이 가능합니다.
onsitePwd string
현장 적립 또는 혜택 사용 인증용 비밀번호입니다.
현장 적립이 활성화된 경우(onsiteYn=Y), 관련 API 호출 시 필요합니다.
memo string
내부 참고용 메모.
activeYn string
Enum:
YN
스탬프 활성화 여부입니다. 비활성화되면 고객이 스탬프를 사용할 수 없습니다.
userId string
스탬프 사용자를 식별하기 위한 사용자 ID입니다.
일반적으로 서비스 회원 ID 또는 고객 식별 값을 사용합니다.
설정하지 않은 경우 비볼디에서 자동으로 생성합니다.
userNm string
스탬프 사용자 이름입니다. 내부 관리 및 식별 용도로 사용됩니다.
userPhnno string
스탬프 사용자 연락처입니다. 내부 관리 및 식별 용도로 사용됩니다.
userEml string
스탬프 사용자 이메일입니다. 내부 관리 및 식별 용도로 사용됩니다.
userEtc1 string
추가 내부 관리용 필드.
userEtc2 string
추가 내부 관리용 필드.
stampImgUrl string
스탬프 도장 이미지 URL.
regYmdt datetime
스탬프 생성 일시. 예: 2025-07-21 11:50:20
payloadVersion string
Payload 규격 버전입니다. 필드가 추가되더라도 이 값이 변경되기 전까지 기존 필드의 의미와 동작은 유지됩니다.

Webhook 서명 검증 — 코드 샘플

Webhook 요청의 진위는 X-Vivoldi-Signature 헤더와 발급된 Secret Key로 검증합니다.

서명은 타임스탬프(t), 이벤트 ID(X-Vivoldi-Event-Id), 요청 Body의 SHA-256 해시값을 점(.)으로 결합한 문자열을 Secret Key로 HMAC-SHA256 해싱하여 생성됩니다.

timestamp.eventId.payloadSha256

해싱 결과(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;

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

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

대량의 링크·쿠폰·스탬프 이벤트를 처리하는 엔터프라이즈 환경에 최적화되어 있습니다.

고가용성 인프라와 안정적인 큐잉 메커니즘을 기반으로, 트래픽 급증 상황에서도 이벤트 유실 없이 귀사의 CRM, 결제, 분석 시스템과 안정적으로 연동됩니다.

엔터프라이즈 업그레이드