> ## Documentation Index
> Fetch the complete documentation index at: https://docs.waffo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook

> リアルタイムのイベント通知を受信する

## 概要

Webhook は、注文、決済、サブスクリプション、返金などのイベントが発生したとき、サーバーにリアルタイム通知を配信します。

```
Event occurs → Waffo Pancake → fan-out → Your channel(s)
```

1 つのストアには**複数の Webhook** を設定でき、それぞれ異なるチャネルに配信されます。利用可能なチャネルは次のとおりです：

| チャネル       | 配信先                            | ペイロードフォーマット                 |
| ---------- | ------------------------------ | --------------------------- |
| `http`     | あなたの HTTPS エンドポイント             | RSA-SHA256 署名付き JSON エンベロープ |
| `feishu`   | Lark / Feishu Bot 受信 Webhook   | インタラクティブカード（Lark フォーマット）    |
| `discord`  | Discord チャンネル Webhook          | Embed メッセージ（Discord フォーマット） |
| `telegram` | Telegram Bot `sendMessage` URL | HTML 形式のテキスト                |
| `slack`    | Slack 受信 Webhook               | mrkdwn フィールド付き Attachment   |

`http` チャネルは本ページで説明する JSON エンベロープと署名検証を使用します -- 本ガイドの大部分はこのチャネルが対象です。IM チャネルでは各プラットフォーム固有のフォーマットが使われ、認証は URL のトークンによって行われます。署名の検証は不要です。

<Tip>
  **TypeScript をお使いですか？** [@waffo/pancake-ts](https://www.npmjs.com/package/@waffo/pancake-ts) SDK には公開鍵が組み込まれており、環境を自動検出します -- 1 行で `http` チャネルの検証が完了します。
</Tip>

***

## セットアップ

<Steps>
  <Step title="チャネルを選択し URL を準備">
    * **HTTP** -- POST リクエストを受け付け `200` を返すサーバーエンドポイントを構築します。
    * **Feishu / Discord / Telegram / Slack** -- 対象プラットフォームで Bot または受信 Webhook を作成し、その URL をコピーします。Telegram の場合はメッセージを受信する chat ID も控えておきます。
  </Step>

  <Step title="（HTTP のみ）検証用公開鍵をコピー">
    Waffo は環境ごと（Test / Production）に 1 組の固定鍵ペアを使用しており、**すべてのストアで共通**です。Webhook 登録時に公開鍵が返されるわけではなく、ダッシュボードから自分でコピーする方式です。

    [ダッシュボード](https://pancake.waffo.ai/merchant/dashboard) を開き、任意のストア → **設定 → Webhooks** に移動して、連携する環境（Test または Production）の **Webhook Public Key** をコピーし、サーバーの設定に保存してください。以降すべての HTTP webhook はこの鍵で検証します。

    <Note>どのストアのダッシュボードでも Test 公開鍵は同じ、Production 公開鍵も同じです —— プラットフォーム共通の鍵です。Webhook URL を追加・編集・削除しても変わりません。</Note>
  </Step>

  <Step title="Webhook を登録">
    **ダッシュボード → 設定 → Webhooks** から追加するか、[`POST /v1/actions/store/add-webhook`](/ja/api-reference/endpoints/webhooks/add-webhook) を呼び出します。各 Webhook レコードは 1 つのチャネル、1 つの URL、購読イベント、対象環境（Test の場合は `testMode: true`、Production の場合は `false`）を指定します。1 つのストアに複数の Webhook を登録できます。
  </Step>

  <Step title="テストイベントを送信">
    ダッシュボードの「テストイベントを送信」ボタンを使用して、登録した 1 つまたはすべての Webhook にサンプルイベントを配信します。
  </Step>

  <Step title="署名を検証してイベントを処理">
    HTTP チャネルでは、以下のコード例を使ってイベント処理前に署名を検証します。IM チャネルは事前レンダリングされたメッセージを配信するため、サーバー側の処理は不要です。
  </Step>
</Steps>

***

## 環境の隔離

各 Webhook は `testMode` フラグによって 1 つの環境に登録されます。Test と Production は完全に独立しています：

| 項目              | Test                       | Production                        |
| --------------- | -------------------------- | --------------------------------- |
| Webhook レコード    | `testMode: true`           | `testMode: false`                 |
| 署名鍵（HTTP のみ）    | Test 鍵ペア                   | Production 鍵ペア                    |
| 検証用公開鍵（HTTP のみ） | ダッシュボード Test 鍵             | ダッシュボード Production 鍵              |
| 配信時のセレクタ        | ヘッダー `X-Environment: test` | ヘッダー `X-Environment: prod`（または省略） |

各 HTTP ペイロードの `mode` フィールドはソース環境を示します: `"test"` または `"prod"`。

<Warning>
  常にイベントの `mode` に一致する公開鍵を使用してください。Test 鍵では Production イベントを検証できず、その逆も同様です。
</Warning>

***

## ペイロードフォーマット

### ヘッダー

| ヘッダー                | 説明                                    |
| ------------------- | ------------------------------------- |
| `Content-Type`      | `application/json`                    |
| `X-Waffo-Signature` | 署名文字列: `t=<timestamp>,v1=<signature>` |
| `X-Waffo-Event`     | イベントタイプ（例：`order.completed`）          |

### ボディ

```json theme={"system"}
{
  "id": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "timestamp": "2026-03-10T08:30:00.000Z",
  "eventType": "order.completed",
  "eventId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "storeId": "STO_3bVzrkD0FJjFdZNLk8Ualx",
  "storeName": "My Store",
  "mode": "prod",
  "data": {
    "orderId": "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
    "orderStatus": "completed",
    "buyerEmail": "customer@example.com",
    "currency": "USD",
    "amount": "29.00",
    "taxAmount": "2.90",
    "taxRate": 0.1,
    "taxName": "Consumption Tax",
    "subtotal": "26.10",
    "total": "29.00",
    "productName": "Pro Plan",
    "orderMetadata": { "planId": "pro" },
    "orderMerchantExternalId": "ORDER-2026-00891",
    "productMetadata": {},
    "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
    "paymentStatus": "succeeded",
    "paymentMethod": "card",
    "paymentLast4": "4242",
    "paymentDate": "2026-03-10"
  }
}
```

### トップレベルフィールド

| フィールド       | 型      | 説明                                                                           |
| ----------- | ------ | ---------------------------------------------------------------------------- |
| `id`        | string | イベントエンティティ ID -- ほとんどのイベントで `eventId` と同じ                                    |
| `timestamp` | string | イベント時刻（ISO 8601 UTC）                                                         |
| `eventType` | string | イベントタイプ（[イベントタイプ](#イベントタイプ)を参照）                                              |
| `eventId`   | string | ビジネスイベント識別子 -- イベントタイプごとに異なるエンティティにマッピング（[eventId マッピング](#eventid-マッピング)を参照） |
| `storeId`   | string | Store ID                                                                     |
| `storeName` | string | ストア名                                                                         |
| `mode`      | string | `"test"` または `"prod"`                                                        |

### `data` フィールド

`data` オブジェクトには取引の詳細が含まれます。一部のフィールドは常に存在し、その他は特定のイベントタイプまたはデータが利用可能な場合にのみ出現します。

**常に存在：**

| フィールド             | 型      | 説明                                                |
| ----------------- | ------ | ------------------------------------------------- |
| `orderId`         | string | 注文 ID                                             |
| `orderStatus`     | string | 注文ステータス（例：`"completed"`、`"active"`、`"canceling"`） |
| `buyerEmail`      | string | customer のメールアドレス                                 |
| `currency`        | string | ISO 4217 通貨コード（例：`"USD"`、`"JPY"`）                 |
| `amount`          | string | 税込みの取引合計額（表示フォーマット、例：`"29.00"`）                   |
| `taxAmount`       | string | 税額（表示フォーマット、例：`"2.90"`）                           |
| `productName`     | string | 商品名                                               |
| `orderMetadata`   | object | チェックアウトセッションからの注文レベルメタデータ（マーチャント定義のキーバリューペア）      |
| `productMetadata` | object | 商品の作成/更新時に設定された商品レベルメタデータ                         |

**値がある場合に含まれる：**

| フィールド                            | 型      | 条件           | 説明                                                                                        |
| -------------------------------- | ------ | ------------ | ----------------------------------------------------------------------------------------- |
| `merchantProvidedBuyerIdentity`  | string | チェックアウト時に設定  | マーチャントのカスタム customer 識別子                                                                  |
| `orderMerchantExternalId`        | string | チェックアウト時に設定  | 注文のマーチャントビジネス側識別子（checkout 作成時に設定、最大 128 文字）。注文イベントに存在し、`refund.*` イベントにも存在します（元の注文から継承）。 |
| `refundTicketMerchantExternalId` | string | 返金チケット作成時に設定 | 返金チケットのマーチャントビジネス側識別子（最大 128 文字）。`refund.succeeded` / `refund.failed` イベントのみに存在します。       |
| `billingDetail`                  | object | チェックアウト時に設定  | 請求先住所（`country`、`isBusiness` など）                                                          |
| `taxRate`                        | number | 税率が適用された場合   | 税率の小数値（例：`0.1` は 10%）                                                                     |
| `taxName`                        | string | 税率が適用された場合   | 税名（例：`"Consumption Tax"`）                                                                 |
| `subtotal`                       | string | 利用可能時        | 税抜き小計（表示フォーマット）                                                                           |
| `total`                          | string | 利用可能時        | 税込み合計（表示フォーマット）                                                                           |
| `productDescription`             | string | 商品に設定済み      | 商品説明                                                                                      |

**決済イベント**（`order.completed`、`subscription.payment_succeeded`）：

| フィールド                  | 型      | 説明                                |
| ---------------------- | ------ | --------------------------------- |
| `paymentId`            | string | 決済 ID                             |
| `paymentStatus`        | string | 決済ステータス（`"succeeded"`、`"failed"`） |
| `paymentMethod`        | string | 決済方法（例：`"card"`）                  |
| `paymentLast4`         | string | 決済手段の末尾 4 桁                       |
| `paymentDate`          | string | 決済日（ISO 8601 日付、例：`"2026-03-10"`） |
| `paymentFailureReason` | string | 失敗理由（決済が失敗した場合）                   |

**サブスクリプションイベント**（`subscription.*`）：

| フィールド                | 型      | 説明                                              |
| -------------------- | ------ | ----------------------------------------------- |
| `billingPeriod`      | string | `"weekly"`、`"monthly"`、`"quarterly"`、`"yearly"` |
| `currentPeriodStart` | string | 現在の請求期間の開始日（ISO 8601 日付）                        |
| `currentPeriodEnd`   | string | 現在の請求期間の終了日（ISO 8601 日付）                        |
| `canceledAt`         | string | キャンセルタイムスタンプ（ISO 8601、キャンセル中/キャンセル済みの場合に存在）     |

**返金イベント**（`refund.succeeded`、`refund.failed`）：

| フィールド                            | 型      | 説明                                        |
| -------------------------------- | ------ | ----------------------------------------- |
| `refundStatus`                   | string | `"succeeded"` または `"failed"`              |
| `refundReason`                   | string | 返金理由                                      |
| `refundCreatedAt`                | string | 返金作成タイムスタンプ（ISO 8601）                     |
| `orderMerchantExternalId`        | string | 注文のマーチャントビジネス側識別子（元の注文から継承、設定時に存在）        |
| `refundTicketMerchantExternalId` | string | 返金チケットのマーチャントビジネス側識別子（チケット作成時に設定された場合に存在） |
| `paymentId`                      | string | 元の決済 ID                                   |
| `paymentStatus`                  | string | 元の決済ステータス                                 |
| `paymentMethod`                  | string | 元の決済方法                                    |
| `paymentLast4`                   | string | 元の決済の末尾 4 桁                               |
| `paymentDate`                    | string | 元の決済日                                     |

`refund.succeeded` イベントの `data` 例（返金関連フィールドのみを表示）：

```json theme={"system"}
{
  "refundStatus": "succeeded",
  "refundReason": "Customer requested refund",
  "refundCreatedAt": "2026-03-12T09:15:00.000Z",
  "orderMerchantExternalId": "ORDER-2026-00891",
  "refundTicketMerchantExternalId": "REF-2026-00891",
  "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
  "paymentStatus": "succeeded",
  "paymentMethod": "card",
  "paymentLast4": "4242",
  "paymentDate": "2026-03-10"
}
```

<Note>
  金額は**表示フォーマット文字列**であり、最小通貨単位からすでに変換されています。例えば、USD `"29.00"` = 2900 セント、JPY `"4500"` = 4500 円です。利用可能な場合は `subtotal` と `total` を使用して明細表示できます。
</Note>

### eventId マッピング

`eventId` はイベントをトリガーしたビジネスエンティティを識別します。

| イベントタイプ                          | eventId のマッピング先 | 例                                    |
| -------------------------------- | --------------- | ------------------------------------ |
| `order.completed`                | Payment ID      | `PAY_6eYCunG3IMmIgcQOnaXdoA`         |
| `subscription.activated`         | Order ID        | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.payment_succeeded` | Payment ID      | `PAY_6eYCunG3IMmIgcQOnaXdoA`         |
| `subscription.canceling`         | Order ID        | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.uncanceled`        | Order ID        | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.updated`           | Order ID        | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.canceled`          | Order ID        | `ORD_5dXBtmF2HLlHfbPNm0Wcnz`         |
| `subscription.past_due`          | Order ID + 月    | `ORD_5dXBtmF2HLlHfbPNm0Wcnz-2026-04` |
| `refund.succeeded`               | Refund ID       | `REF_4cWAtlE1GKkGebONl9Xbnx`         |
| `refund.failed`                  | Refund ID       | `REF_4cWAtlE1GKkGebONl9Xbnx`         |

<Note>
  `subscription.past_due` は eventId に `-YYYY-MM` を付加します。同一サブスクリプションにつき、カレンダー月ごとに最大 1 回の `past_due` イベントがトリガーされます。翌月もまだ滞納中の場合、新しいイベントが発行されます。
</Note>

***

## イベントタイプ

### 一覧

| イベント                             | トリガー                       | eventId      |
| -------------------------------- | -------------------------- | ------------ |
| `order.completed`                | 単発注文の決済が成功                 | Payment ID   |
| `subscription.activated`         | サブスクリプションの初回決済が成功          | Order ID     |
| `subscription.payment_succeeded` | 更新決済が成功（初回以外）              | Payment ID   |
| `subscription.canceling`         | キャンセルがリクエストされた -- 期間終了まで有効 | Order ID     |
| `subscription.uncanceled`        | キャンセルが取り消された               | Order ID     |
| `subscription.updated`           | 商品が変更された（アップグレード/ダウングレード）  | Order ID     |
| `subscription.canceled`          | サブスクリプションが終了（期間終了）         | Order ID     |
| `subscription.past_due`          | 更新決済が失敗                    | Order ID + 月 |
| `refund.succeeded`               | 返金が完了                      | Refund ID    |
| `refund.failed`                  | 返金が失敗                      | Refund ID    |

<Note>
  `subscription.uncanceled` と `subscription.updated` のイベントテンプレートは準備済みで、対応する機能のリリース後に有効化されます。
</Note>

### イベント詳細

<AccordionGroup>
  <Accordion title="order.completed" icon="check">
    **トリガー**: 単発注文の決済が初めて成功した時。

    **ペイロード**:

    * `data.amount` -- 決済金額（税込み）
    * `data.orderId` -- 単発注文 ID

    **推奨アクション**:

    * デジタル商品の配信（ライセンスキー、ダウンロードリンク、アクティベーションコード）
    * 注文管理システムの更新
    * customer への確認送信（Waffo の組み込みメールを使用しない場合）

    同一注文で `order.completed` がトリガーされるのは 1 回のみです。返金は `refund.succeeded` / `refund.failed` で通知されます。
  </Accordion>

  <Accordion title="subscription.activated" icon="play">
    **トリガー**: 新規サブスクリプションの初回決済が成功した時（`pending` → `active`）。

    **ペイロード**:

    * `data.amount` -- 初回決済金額（税込み）
    * `data.productName` -- サブスクリプション商品名

    **推奨アクション**:

    * 加入者のアカウントをプロビジョニングしてアクセスを付与
    * サブスクリプション開始日を記録

    サブスクリプションが初めて `pending` から `active` に遷移した時にのみ発行されます。以降の更新には `subscription.payment_succeeded` が使用されます。
  </Accordion>

  <Accordion title="subscription.payment_succeeded" icon="credit-card">
    **トリガー**: 定期更新決済が成功した時（初回決済以外）。

    **ペイロード**:

    * `data.amount` -- 今期の更新金額（税込み）
    * `data.orderId` -- サブスクリプション注文 ID

    **推奨アクション**:

    * サービス期間の延長
    * この請求サイクルの請求書を生成
    * サブスクリプションが以前 `past_due` だった場合、フルアクセスを復元
  </Accordion>

  <Accordion title="subscription.canceling" icon="clock">
    **トリガー**: customer またはマーチャントがキャンセルをリクエスト。サブスクリプションは現在の支払い済み期間の終了まで有効です。

    **推奨アクション**:

    * 「サブスクリプションは \[日付] に終了します」通知を表示
    * リテンションフロー（例：割引更新）を提供
    * アクセスを取り消さ**ないでください** -- customer は現在の期間分を支払い済みです

    customer は期間終了前にキャンセルを取り消すことができます（`subscription.uncanceled` がトリガーされます）。
  </Accordion>

  <Accordion title="subscription.uncanceled" icon="rotate-left">
    **トリガー**: 現在の期間が終了する前にキャンセルが取り消された時。

    **推奨アクション**:

    * 「まもなく終了」通知を削除
    * 自動更新ステータスを復元
  </Accordion>

  <Accordion title="subscription.updated" icon="arrows-rotate">
    **トリガー**: サブスクリプション商品が変更された時（アップグレードまたはダウングレード）。

    **ペイロード**:

    * `data.productName` -- 変更後の新しい商品名
    * `data.amount` -- 新しい金額

    **推奨アクション**:

    * customer のアクセスレベルを更新（機能の追加/削除）
    * 請求記録を更新
  </Accordion>

  <Accordion title="subscription.canceled" icon="xmark">
    **トリガー**: サブスクリプションが終了 -- 支払い済み期間が終了し、これ以上の更新は行われません。

    **推奨アクション**:

    * アクセスを取り消す（または無料プランにダウングレード）
    * 猶予期間中データを保持（customer が再登録する場合に備えて）
    * 「サブスクリプション終了」確認を送信

    これは終了状態です。サブスクリプションは不可逆的に終了しています。
  </Accordion>

  <Accordion title="subscription.past_due" icon="triangle-exclamation">
    **トリガー**: 更新決済が失敗し、サブスクリプションが滞納状態に入った時。

    **ペイロード**:

    * `data.amount` -- 今期の請求金額
    * `eventId` -- フォーマット: `{orderId}-YYYY-MM`（月次重複排除）

    **推奨アクション**:

    * customer に支払い方法の更新を通知
    * オプションでサービスを制限（完全に取り消すのではなく機能を制限）
    * すぐにアクセスを取り消さ**ないでください** -- PSP が自動的に課金をリトライする場合があります

    **重複排除**: サブスクリプションごとにカレンダー月あたり最大 1 回の `past_due` イベント。翌月もまだ滞納中の場合、新しいイベントが発行されます。
  </Accordion>

  <Accordion title="refund.succeeded" icon="money-bill-transfer">
    **トリガー**: 返金が完了し、資金が返還された時。

    **ペイロード**:

    * `data.amount` -- 返金金額（税込み）
    * `data.orderId` -- 元の注文 ID

    **推奨アクション**:

    * 配信済みデジタル商品を取り消す（ライセンスの失効、ダウンロードの無効化）
    * 注文ステータスを「返金済み」に更新
  </Accordion>

  <Accordion title="refund.failed" icon="circle-exclamation">
    **トリガー**: 返金処理が失敗した時。

    **推奨アクション**:

    * 手動確認のために障害をログに記録
    * 商品を取り消さないでください（返金は完了していません）
  </Accordion>
</AccordionGroup>

### サブスクリプションライフサイクル

```mermaid theme={"system"}
stateDiagram-v2
    [*] --> pending: Order created
    pending --> active: First payment succeeds<br/>subscription.activated
    pending --> closed: Payment timeout

    active --> canceling: Cancellation requested<br/>subscription.canceling
    active --> past_due: Renewal failed<br/>subscription.past_due
    active --> active: Renewal succeeded<br/>subscription.payment_succeeded
    active --> active: Product changed<br/>subscription.updated

    canceling --> active: Cancellation withdrawn<br/>subscription.uncanceled
    canceling --> canceled: Period ended<br/>subscription.canceled

    past_due --> active: Overdue payment recovered<br/>subscription.payment_succeeded
    past_due --> canceled: Final cancellation<br/>subscription.canceled

    active --> expired: Term ended

    canceled --> [*]
    closed --> [*]
    expired --> [*]
```

| 終了状態       | 意味                                        |       Webhook を発行？      |
| ---------- | ----------------------------------------- | :---------------------: |
| `canceled` | サブスクリプション終了（customer/マーチャントによるキャンセルまたは滞納） | `subscription.canceled` |
| `closed`   | アクティブ化されなかった -- 決済がタイムアウト                 |            No           |
| `expired`  | 定期サブスクリプションが自然に終了                         |            No           |

***

## 署名検証

**本番環境では必ず署名を検証してください。** 検証なしでは、誰でもエンドポイントに偽造リクエストを送信できます。

### アルゴリズム

```
1. Parse t (timestamp in ms) and v1 (Base64 signature) from X-Waffo-Signature header
2. Build signature input: `${t}.${rawRequestBody}`
3. Verify v1 using RSA-SHA256 with the Waffo public key
4. (Recommended) Check that t is within 5 minutes of current time to prevent replay attacks
```

### SDK の使用（推奨）

SDK は公開鍵を埋め込み、環境を自動検出し、フォーマットの正規化を処理します。

```typescript theme={"system"}
import { verifyWebhook, WebhookEventType } from "@waffo/pancake-ts";

app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verifyWebhook(
      req.body.toString("utf-8"),
      req.headers["x-waffo-signature"] as string,
    );

    res.status(200).send("OK");

    switch (event.eventType) {
      case WebhookEventType.OrderCompleted:
        // Deliver digital goods
        break;
      case WebhookEventType.SubscriptionActivated:
        // Provision subscription access
        break;
      case WebhookEventType.SubscriptionCanceled:
        // Revoke access
        break;
    }
  } catch {
    res.status(401).send("Invalid signature");
  }
});
```

[SDK Webhook ドキュメントの詳細](/ja/integrate/webhooks)をご覧ください。

### 手動検証

TypeScript SDK を使用しない場合は、署名検証を手動で実装してください。

<CodeGroup>
  ```javascript Node.js (Express) theme={"system"}
  const crypto = require('crypto');

  // From Dashboard → API と開発 → Webhook Public Key (PEM format)
  const WAFFO_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
  -----END PUBLIC KEY-----`;

  function parseSignatureHeader(header) {
    const parts = {};
    for (const pair of header.split(',')) {
      const [key, ...rest] = pair.split('=');
      parts[key.trim()] = rest.join('=').trim();
    }
    return parts;
  }

  function verifyWebhookSignature(rawBody, signatureHeader, publicKey) {
    const { t, v1 } = parseSignatureHeader(signatureHeader);
    if (!t || !v1) return false;

    // Replay protection: 5-minute tolerance
    const tolerance = 5 * 60 * 1000;
    if (Math.abs(Date.now() - Number(t)) > tolerance) return false;

    // Verify RSA-SHA256 signature
    const signatureInput = `${t}.${rawBody}`;
    const verifier = crypto.createVerify('RSA-SHA256');
    verifier.update(signatureInput);
    return verifier.verify(publicKey, v1, 'base64');
  }

  app.post('/webhooks',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const sig = req.headers['x-waffo-signature'];
      const rawBody = req.body.toString('utf-8');

      if (!sig || !verifyWebhookSignature(rawBody, sig, WAFFO_WEBHOOK_PUBLIC_KEY)) {
        return res.status(401).send('Invalid signature');
      }

      const event = JSON.parse(rawBody);
      res.status(200).send('OK');

      // Process asynchronously
      handleEvent(event).catch(console.error);
    }
  );

  async function handleEvent(event) {
    switch (event.eventType) {
      case 'order.completed':
        await grantAccess(event.data.buyerEmail, event.data.productName);
        break;
      case 'subscription.activated':
        await createSubscription(event.data.buyerEmail, event.data.orderId);
        break;
      case 'subscription.payment_succeeded':
        await extendSubscription(event.data.orderId);
        break;
      case 'subscription.canceling':
        await markCanceling(event.data.orderId);
        break;
      case 'subscription.canceled':
        await revokeAccess(event.data.orderId);
        break;
      case 'subscription.past_due':
        await notifyPastDue(event.data.buyerEmail, event.data.orderId);
        break;
      case 'refund.succeeded':
        await revokeAccess(event.data.orderId);
        break;
      case 'refund.failed':
        await flagForReview(event.data.orderId);
        break;
    }
  }
  ```

  ```python Python (Flask) theme={"system"}
  import json, time
  from base64 import b64decode
  from cryptography.hazmat.primitives import hashes, serialization
  from cryptography.hazmat.primitives.asymmetric import padding
  from flask import Flask, request

  app = Flask(__name__)

  WAFFO_WEBHOOK_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
  -----END PUBLIC KEY-----"""

  def verify_webhook_signature(raw_body, signature_header, public_key_pem):
      parts = dict(p.split("=", 1) for p in signature_header.split(",") if "=" in p)
      t, v1 = parts.get("t"), parts.get("v1")
      if not t or not v1:
          return False

      # Replay protection: 5-minute tolerance
      if abs(int(time.time() * 1000) - int(t)) > 5 * 60 * 1000:
          return False

      signature_input = f"{t}.{raw_body}".encode("utf-8")
      public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
      try:
          public_key.verify(b64decode(v1), signature_input, padding.PKCS1v15(), hashes.SHA256())
          return True
      except Exception:
          return False

  @app.route("/webhooks", methods=["POST"])
  def handle_webhook():
      sig = request.headers.get("X-Waffo-Signature", "")
      raw_body = request.get_data(as_text=True)

      if not verify_webhook_signature(raw_body, sig, WAFFO_WEBHOOK_PUBLIC_KEY):
          return "Invalid signature", 401

      event = json.loads(raw_body)
      # Process event...
      return "OK", 200
  ```

  ```go Go (net/http) theme={"system"}
  package main

  import (
  	"crypto"
  	"crypto/rsa"
  	"crypto/sha256"
  	"crypto/x509"
  	"encoding/base64"
  	"encoding/pem"
  	"fmt"
  	"io"
  	"math"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  const waffoPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
  -----END PUBLIC KEY-----`

  func verifyWebhookSignature(rawBody []byte, signatureHeader string) bool {
  	var t, v1 string
  	for _, pair := range strings.Split(signatureHeader, ",") {
  		parts := strings.SplitN(pair, "=", 2)
  		if len(parts) != 2 { continue }
  		switch strings.TrimSpace(parts[0]) {
  		case "t": t = strings.TrimSpace(parts[1])
  		case "v1": v1 = strings.TrimSpace(parts[1])
  		}
  	}
  	if t == "" || v1 == "" { return false }

  	ts, err := strconv.ParseInt(t, 10, 64)
  	if err != nil || math.Abs(float64(time.Now().UnixMilli()-ts)) > 5*60*1000 {
  		return false
  	}

  	block, _ := pem.Decode([]byte(waffoPublicKeyPEM))
  	if block == nil { return false }
  	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
  	if err != nil { return false }
  	rsaPub, ok := pub.(*rsa.PublicKey)
  	if !ok { return false }

  	hash := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", t, string(rawBody))))
  	sig, err := base64.StdEncoding.DecodeString(v1)
  	if err != nil { return false }
  	return rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, hash[:], sig) == nil
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	rawBody, _ := io.ReadAll(r.Body)
  	if !verifyWebhookSignature(rawBody, r.Header.Get("X-Waffo-Signature")) {
  		http.Error(w, "Invalid signature", http.StatusUnauthorized)
  		return
  	}
  	w.WriteHeader(http.StatusOK)
  	w.Write([]byte("OK"))
  }

  func main() {
  	http.HandleFunc("/webhooks", webhookHandler)
  	http.ListenAndServe(":8080", nil)
  }
  ```
</CodeGroup>

<Warning>
  **署名検証には生のリクエストボディを使用する必要があります。** フレームワークが自動的に JSON をパースする場合、署名チェックは失敗します。検証前に未変更の生の文字列を取得してください。
</Warning>

***

## レスポンス要件

* **2xx** ステータスコードを返してください（推奨: `200`）
* **10 秒以内**に応答してください
* レスポンスボディは問いません

```javascript theme={"system"}
// Recommended: respond immediately, process async
app.post('/webhooks', (req, res) => {
  res.status(200).send('OK');
  processEventAsync(req.body);
});
```

非 2xx レスポンスまたはタイムアウトはリトライをトリガーします。

***

## リトライポリシー

配信失敗時は指数バックオフで自動的にリトライされます。

| 項目     | 詳細                                    |
| ------ | ------------------------------------- |
| リトライ回数 | 最大 3 回（初回を含めて合計 4 回）                  |
| 戦略     | 指数バックオフ                               |
| タイムアウト | 非 2xx またはタイムアウトウィンドウ内にレスポンスなし         |
| 最終失敗   | すべてのリトライが尽きた後、配信は `failed` としてマークされます |

### 配信ステータス

| ステータス     | 説明                   |
| --------- | -------------------- |
| `pending` | 作成済み、配信待ちまたはリトライ中    |
| `success` | 配信成功（サーバーが 2xx を返した） |
| `failed`  | すべてのリトライが尽きた         |

ダッシュボードの Webhook ログで配信履歴を確認できます。ステータス、HTTP レスポンスコード、レスポンスボディ（1000 文字に切り詰め）が含まれます。

***

## 重複の処理

ネットワークの問題により、同じイベントが複数回配信される場合があります。**イベント処理が冪等であることを確認してください。**

重複排除には `eventType` + `eventId` の組み合わせ（システム内で一意制約あり）を使用してください。

```javascript theme={"system"}
async function handleEvent(event) {
  const exists = await db.query(
    'SELECT 1 FROM processed_webhooks WHERE event_type = $1 AND event_id = $2',
    [event.eventType, event.eventId]
  );
  if (exists.rows.length > 0) return; // Already processed

  await processEventLogic(event);

  await db.query(
    'INSERT INTO processed_webhooks (event_type, event_id, processed_at) VALUES ($1, $2, NOW())',
    [event.eventType, event.eventId]
  );
}
```

<Note>
  同一ビジネスイベント（同一の `eventType` + `eventId`）は 1 つの配信レコードのみを作成します -- 重複することはありません。ただし、リトライにより単一の配信がエンドポイントに複数回到達する場合があります。
</Note>

***

## ベストプラクティス

<AccordionGroup>
  <Accordion title="必ず署名を検証する">
    常に `X-Waffo-Signature` を検証してください。検証なしでは、誰でもエンドポイントに偽造リクエストを送信できます。
  </Accordion>

  <Accordion title="HTTPS を使用する">
    本番環境の Webhook URL は、転送中のデータを保護するために HTTPS を使用する必要があります。
  </Accordion>

  <Accordion title="即座にレスポンスし、非同期で処理する">
    すぐに `200` を返し、ビジネスロジックはバックグラウンドで処理してください。レスポンスが遅いと不要なリトライが発生します。
  </Accordion>

  <Accordion title="eventType + eventId で重複排除する">
    `eventType` + `eventId` の組み合わせで重複排除してください。同じ配信が複数回処理されても副作用がないようにしてください。
  </Accordion>

  <Accordion title="タイムスタンプを確認する">
    リプレイ攻撃を防ぐために、`t` タイムスタンプが現在時刻から 5 分以内であることを確認してください。
  </Accordion>

  <Accordion title="正しい環境の鍵を使用する">
    Test と Production は異なる鍵ペアを使用します。ペイロードの `mode` フィールドに公開鍵を一致させてください。
  </Accordion>

  <Accordion title="受信ペイロードをログに記録する">
    デバッグのために受信したペイロードを保存してください。ダッシュボードでも配信ログクエリを提供しています。
  </Accordion>

  <Accordion title="購読したすべてのイベントを処理する">
    まだ必要でなくても、購読しているすべてのイベントに分岐を追加してください。未処理のイベントには `200` を返してください -- エラーを返すと不要なリトライがトリガーされます。
  </Accordion>
</AccordionGroup>

***

## テスト

### テストイベントの送信（推奨）

ダッシュボードの「テストイベントを送信」ボタンを使用して、実際のトランザクションをトリガーせずにテストイベントを送信します。テストイベントは固定のサンプルデータ（amount 0、taxAmount 0、product "\[TEST] Webhook Verification"）を使用し、常に Test 鍵で署名されます。

10 種類すべてのイベントタイプがサポートされています -- 各イベントをテストしてハンドラーを検証してください。

### Test モードを使用する

1. ダッシュボードで Test 環境の Webhook URL とイベントを設定します
2. Test モードで実際の操作を行います（注文の作成、決済の処理）
3. イベントは Test 署名鍵を使用して Test Webhook URL に送信されます

### ローカル開発

トンネルを使用してローカルサーバーを公開します。

```bash theme={"system"}
ngrok http 8080
# Use the generated URL as your Test Webhook URL
# e.g., https://abc123.ngrok.io/webhooks
```

***

## 配信ログ

ダッシュボードで Webhook 配信履歴を確認できます。

* **ステータス**: pending / success / failed
* **HTTP ステータスコード**: サーバーのレスポンスコード
* **レスポンスボディ**: サーバーのレスポンス（1000 文字に切り詰め）
* **タイムスタンプ**: 最終配信試行

***

## FAQ

### Webhook が受信できない

1. ダッシュボードで Webhook URL が設定され、公開アクセス可能であることを確認してください
2. 正しいイベントタイプを購読していることを確認してください
3. 正しい環境（Test / Production）を使用していることを確認してください
4. ファイアウォールが Waffo からのリクエストを許可していることを確認してください
5. ダッシュボードの「テストイベントを送信」で問題を切り分けてください

### 署名検証が失敗する

1. 正しい環境の公開鍵（Test vs Production）を使用していることを確認してください
2. **生のリクエストボディ**を使用していることを確認してください -- パースされた JSON オブジェクトではありません
3. ミドルウェアやプロキシがリクエストボディを変更していないか確認してください
4. 署名入力の形式が `${t}.${rawBody}`（タイムスタンプ + ドット + 生のボディ）であることを確認してください
5. TypeScript を使用している場合は、[@waffo/pancake-ts SDK](/ja/integrate/webhooks) に切り替えてください -- 鍵の選択とフォーマットの正規化を自動的に処理します

### 重複イベントが受信される

これは通常のリトライ動作です。エンドポイントが非 2xx を返したかタイムアウトした場合、システムがリトライします。ハンドラーが冪等であることを確認してください -- 重複排除には `eventType` + `eventId` の組み合わせを使用してください。

### `subscription.canceling` と `subscription.canceled` の違い

* **`canceling`**: キャンセルがリクエストされましたが、現在の支払い済み期間がまだ終了していません。サブスクリプションはまだ有効で、customer はキャンセルを取り消すことができます（`uncanceled` がトリガーされます）。**アクセスを取り消さないでください。**
* **`canceled`**: サブスクリプションは終了しています。これは不可逆です -- アクセスを取り消すか権限をダウングレードしてください。

### 各イベントの `data.amount` の意味

すべてのイベント: `data.amount` は**そのイベントに対する取引金額**（税込み）です。

* `order.completed` / `subscription.activated` -- 決済金額
* `subscription.payment_succeeded` -- 今期の更新金額
* `subscription.past_due` -- 今期の請求金額
* `refund.succeeded` / `refund.failed` -- 返金金額
* `subscription.canceling` / `subscription.canceled` / `subscription.uncanceled` -- サブスクリプションの期間あたりの金額
