Vivoldi Webhook API・HMAC署名検証ガイド
安全なWebhook連携を実現するうえで重要なのが、HTTPヘッダーによる署名検証です。
すべてのVivoldi Webhookリクエストには、X-Vivoldi-Request-Id, X-Vivoldi-Event-Id, X-Vivoldi-Signature などのヘッダーが含まれています。
これらを検証することで、不正なリクエストを防ぎ、リンク・クーポン・スタンプ関連のイベントを安全に処理できます。
本ガイドでは、各ヘッダーの役割、HMAC署名の検証フロー、Java・PHP・Node.jsによる実装例をわかりやすく解説します。
HTTP Header
Vivoldi 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配信・レスポンス・リトライポリシー
Vivoldi Webhookでは、安定したイベント配信を実現するために、成功レスポンスの条件、自動リトライ、エンドポイント無効化ポリシーを明確に定義しています。
これらのポリシーを理解することで、重複処理やイベントの取りこぼしを防ぐことができます。
成功基準
Webhookリクエストの成功可否は、受信サーバーが返すHTTPレスポンスステータスコードを基準に判定します。
-
HTTP 2xxレスポンスの場合、成功として処理します。
200、202、204など、すべての2xxレスポンスを許可します。 レスポンス本文の内容は検証しません。 -
レスポンス待機時間は5秒です。
署名検証後、すぐに2xxレスポンスを返し、実際の処理は非同期で実行することを推奨します。 -
HTTPリダイレクトには従いません。
301、302などのレスポンスも失敗として扱われるため、最終的なCallback URLを登録してください。
リトライ & 無効化
送信に失敗した場合、Webhookは自動的にリトライを実行します。繰り返し失敗が発生すると、不要な再送信を防ぐため、Webhookの状態をシステム停止に変更します。
-
すべてのHTTPレスポンスコードに対してリトライを実行します。
400、404、401などのレスポンスにも同じリトライポリシーが適用されます。 -
リトライ中も
X-Vivoldi-Event-Idは同じ値を維持します。 受信サーバーでは、この値を使用してイベントの重複処理を防止してください。 - 5回のリトライに失敗した場合でも、Webhookはすぐには停止されません。 先にメールで通知し、その後60分間の猶予期間を設けます。この期間内に復旧しない場合、Webhookの状態をシステム停止に変更します。
システム停止状態の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)のみを受信してWebhookを処理することも可能です。
ただし、本番環境ではヘッダー検証を必ず実施する必要があります。ヘッダー検証を省略すると、不正リクエスト、Payload改ざん、重複処理、追跡不能など、深刻なセキュリティリスクにつながる可能性があります。
主なリスク:
-
不正リクエスト(スプーフィング): 攻撃者がVivoldiサーバーになりすまして偽のWebhookリクエストを送信する可能性があります。
ヘッダー検証が実装されていない場合、システムが正規リクエストとして誤って処理してしまう恐れがあります。 - データ改ざん: ネットワーク転送中に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
-
累計クリック数です。
現在のクリックイベントを含む値です。
Webhook送信条件の判定もこの値を基準に行われます。 例えば、送信間隔を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または顧客識別値を使用します。
未設定の場合、Vivoldiが自動的に生成します。 - 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・決済・分析プラットフォームと安定して連携できます。