비볼디 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을 등록해야 합니다.
재시도 & 중지
전송 실패 시 자동으로 재시도를 수행하며, 반복적인 실패가 발생하면 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-Type이GLOBAL이면 전역 Secret Key로,GROUP이면 해당 그룹 또는 스탬프 카드에 설정된 Secret Key로 서명을 검증합니다.
헤더 서명 검증 없이 Webhook을 처리해도 안전한가요?
기술적으로는 POST Body(Payload)만 수신하여 처리해도 동작하지만, 운영 환경에서는 헤더 검증을 반드시 수행해야 합니다.
헤더 검증을 생략하면 위조 요청, Payload 위·변조, 중복 처리, 추적 불가 등 심각한 보안 위험이 발생할 수 있습니다.
주요 위험:
-
위조 요청(스푸핑): 공격자가 비볼디 서버를 사칭해 위조된 요청을 전송할 수 있습니다.
헤더 검증이 구현되어 있지 않다면, 시스템은 이를 정상 요청으로 오인해 처리할 수 있습니다. - 데이터 변조: 네트워크 전송 구간에서 Payload가 조작되어도 서명 검증이 없으면 변조를 감지할 수 없습니다.
- 중복 처리: 재전송 공격으로 동일 이벤트가 반복 수신되어, 중복 처리나 이중 적립이 발생할 수 있습니다.
- 추적 불가: Request-Id 또는 Event-Id 헤더가 없으면 요청 추적, 오류 분석, 재현이 불가능해집니다.
Payload
이벤트 발생 시점
링크 Webhook은 단축 URL 클릭 이벤트 발생 시 설정된 Callback URL로 이벤트 정보를 전송합니다.
Webhook은 개별 링크 또는 링크 그룹에서 설정할 수 있습니다.
두 곳 모두 설정된 경우 링크 그룹 설정이 우선 적용되며,
전송 기준과 주기도 그룹 설정 값을 따릅니다. 동일한 이벤트는 중복 전송되지 않습니다.
X-Vivoldi-Action-Type 값은 CLICK입니다.
링크 그룹 Webhook은 엔터프라이즈 요금제 전용 기능입니다.
전송 기준은 클릭 수 또는 방문자 수 중에서 선택할 수 있으며, 설정한 누적 기준에 도달할 때마다 Webhook이 전송됩니다.
예를 들어 전송 기준을 클릭 수, 전송 주기를 100회마다로 설정하면 누적 클릭 수가 100회, 200회, 300회에 도달할 때마다 Webhook이 전송됩니다.
{
"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
- linkId string
- 링크 식별자 ID.
- domain string
- 링크 도메인.
- compIdx integer
-
조직 IDX.
헤더의
X-Vivoldi-Comp-Idx값과 동일합니다. - redirectType integer
-
Enum:
200301302
-
링크 이동 방식입니다.
200: 페이지 표시 방식
301: 영구 리다이렉트
302: 임시 리다이렉트
자세한 내용은 주요 용어 페이지에서 확인하세요. - url string
- 원본 URL.
- ttl string
- 링크 제목.
- description string
-
redirectType값이200인 경우 사용되는 메타 태그 description 값입니다. - metaImg string
-
redirectType값이200인 경우 사용되는 메타 태그 이미지 URL입니다. - memo string
- 링크 관리용 메모.
- grpIdx integer
-
링크 그룹 IDX입니다.
링크 그룹에 Webhook이 설정되어 있으면 개별 링크 설정보다 그룹 Webhook이 우선 적용됩니다. - grpNm string
- 링크 그룹 이름.
- strtYmdt datetime
- 링크 유효기간 시작 일시.
- endYmdt datetime
- 링크 유효기간 만료 일시.
- expireYn string
-
Enum:
YN
-
링크 유효기간 만료 여부입니다.
만료된 경우
Y로 전달됩니다. - expireUrl string
- 링크 만료 후 이동할 URL.
- acesCnt integer
-
누적 클릭 수입니다.
현재 클릭 이벤트가 포함된 값입니다.
전송 기준 판정도 이 값을 기준으로 하므로, 전송 주기를 100으로 설정하면 누적 클릭 수가 100, 200, 300에 도달할 때마다 Webhook이 전송됩니다. - pernCnt integer
- 누적 방문자 수(고유 사용자 수)입니다. 현재 클릭 이벤트가 포함된 값입니다.
- acesMaxCnt integer
-
최대 클릭 허용 수입니다.
0이면 제한이 없으며, 초과 시 링크 접속이 차단됩니다. - referer string
- 요청이 발생한 이전 페이지 URL.
- queryString string
- 단축 URL 접속 시 전달된 Query String.
- country string
- 접속 사용자 국가 코드(ISO-3166).
- language string
- 접속 사용자 언어 코드(ISO-639).
- regYmdt datetime
- 링크 생성 일시.
- modYmdt datetime
- 링크 수정 일시.
- payloadVersion string
- Payload 규격 버전입니다. 필드가 추가되어도 이 값이 변경되기 전까지 기존 필드의 의미와 동작은 유지됩니다.
이벤트 발생 시점
쿠폰 Webhook은 쿠폰 사용 이벤트 발생 시 설정된 Callback URL로 이벤트 정보를 전송합니다.
Webhook은 개별 쿠폰 또는 쿠폰 그룹에서 설정할 수 있습니다.
두 곳 모두 설정된 경우 쿠폰 그룹 설정이 우선 적용되며,
동일한 이벤트는 중복 전송되지 않습니다.
쿠폰 그룹 Webhook은 비즈니스 요금제 이상에서 사용할 수 있습니다.
쿠폰 사용이 처리되는 즉시 전송되며 X-Vivoldi-Action-Type 값은 USE입니다.
대시보드, API, 오프라인 사용 처리 등 어떤 경로로 쿠폰이 사용되어도 동일한 방식으로 전송됩니다.
호출 한도 초과 또는 재시도 대기 상황에서는 이벤트를 대기열에 보관한 후 순서대로 전달합니다.
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를 통한 적립·삭제·혜택 사용 이벤트에서는 변경 전 스탬프 수입니다. 변경 후 값은stamps와changedStamps를 이용해 계산할 수 있습니다.
(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, 결제, 분석 시스템과 안정적으로 연동됩니다.