メインコンテンツへスキップ

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 は、注文、決済、サブスクリプション、返金などのイベントが発生したとき、サーバーにリアルタイム通知を配信します。
Event occurs → Waffo Pancake → fan-out → Your channel(s)
1 つのストアには複数の Webhook を設定でき、それぞれ異なるチャネルに配信されます。利用可能なチャネルは次のとおりです:
チャネル配信先ペイロードフォーマット
httpあなたの HTTPS エンドポイントRSA-SHA256 署名付き JSON エンベロープ
feishuLark / Feishu Bot 受信 Webhookインタラクティブカード(Lark フォーマット)
discordDiscord チャンネル WebhookEmbed メッセージ(Discord フォーマット)
telegramTelegram Bot sendMessage URLHTML 形式のテキスト
slackSlack 受信 Webhookmrkdwn フィールド付き Attachment
http チャネルは本ページで説明する JSON エンベロープと署名検証を使用します — 本ガイドの大部分はこのチャネルが対象です。IM チャネルでは各プラットフォーム固有のフォーマットが使われ、認証は URL のトークンによって行われます。署名の検証は不要です。
TypeScript をお使いですか? @waffo/pancake-ts SDK には公開鍵が組み込まれており、環境を自動検出します — 1 行で http チャネルの検証が完了します。

セットアップ

1

チャネルを選択し URL を準備

  • HTTP — POST リクエストを受け付け 200 を返すサーバーエンドポイントを構築します。
  • Feishu / Discord / Telegram / Slack — 対象プラットフォームで Bot または受信 Webhook を作成し、その URL をコピーします。Telegram の場合はメッセージを受信する chat ID も控えておきます。
2

(HTTP のみ)検証用公開鍵を取得

ダッシュボード → 設定 → Webhooks に移動し、対象環境(Test / Production)の Webhook Public Key をコピーします。
3

Webhook を登録

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

テストイベントを送信

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

署名を検証してイベントを処理

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

環境の隔離

各 Webhook は testMode フラグによって 1 つの環境に登録されます。Test と Production は完全に独立しています:
項目TestProduction
Webhook レコードtestMode: truetestMode: false
署名鍵(HTTP のみ)Test 鍵ペアProduction 鍵ペア
検証用公開鍵(HTTP のみ)ダッシュボード Test 鍵ダッシュボード Production 鍵
配信時のセレクタヘッダー X-Environment: testヘッダー X-Environment: prod(または省略)
各 HTTP ペイロードの mode フィールドはソース環境を示します: "test" または "prod"
常にイベントの mode に一致する公開鍵を使用してください。Test 鍵では Production イベントを検証できず、その逆も同様です。

ペイロードフォーマット

ヘッダー

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

ボディ

{
  "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": "buyer@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" },
    "productMetadata": {},
    "paymentId": "PAY_6eYCunG3IMmIgcQOnaXdoA",
    "paymentStatus": "succeeded",
    "paymentMethod": "card",
    "paymentLast4": "4242",
    "paymentDate": "2026-03-10"
  }
}

トップレベルフィールド

フィールド説明
idstringイベントエンティティ ID — ほとんどのイベントで eventId と同じ
timestampstringイベント時刻(ISO 8601 UTC)
eventTypestringイベントタイプ(イベントタイプを参照)
eventIdstringビジネスイベント識別子 — イベントタイプごとに異なるエンティティにマッピング(eventId マッピングを参照)
storeIdstringStore ID
storeNamestringストア名
modestring"test" または "prod"

data フィールド

data オブジェクトには取引の詳細が含まれます。一部のフィールドは常に存在し、その他は特定のイベントタイプまたはデータが利用可能な場合にのみ出現します。 常に存在:
フィールド説明
orderIdstring注文 ID
orderStatusstring注文ステータス(例:"completed""active""canceling"
buyerEmailstring購入者のメールアドレス
currencystringISO 4217 通貨コード(例:"USD""JPY"
amountstring税込みの取引合計額(表示フォーマット、例:"29.00"
taxAmountstring税額(表示フォーマット、例:"2.90"
productNamestring商品名
orderMetadataobjectチェックアウトセッションからの注文レベルメタデータ(マーチャント定義のキーバリューペア)
productMetadataobject商品の作成/更新時に設定された商品レベルメタデータ
値がある場合に含まれる:
フィールド条件説明
merchantProvidedBuyerIdentitystringチェックアウト時に設定マーチャントのカスタム購入者識別子
billingDetailobjectチェックアウト時に設定請求先住所(countryisBusiness など)
taxRatenumber税率が適用された場合税率の小数値(例:0.1 は 10%)
taxNamestring税率が適用された場合税名(例:"Consumption Tax"
subtotalstring利用可能時税抜き小計(表示フォーマット)
totalstring利用可能時税込み合計(表示フォーマット)
productDescriptionstring商品に設定済み商品説明
決済イベントorder.completedsubscription.payment_succeeded):
フィールド説明
paymentIdstring決済 ID
paymentStatusstring決済ステータス("succeeded""failed"
paymentMethodstring決済方法(例:"card"
paymentLast4string決済手段の末尾 4 桁
paymentDatestring決済日(ISO 8601 日付、例:"2026-03-10"
paymentFailureReasonstring失敗理由(決済が失敗した場合)
サブスクリプションイベントsubscription.*):
フィールド説明
billingPeriodstring"weekly""monthly""quarterly""yearly"
currentPeriodStartstring現在の請求期間の開始日(ISO 8601 日付)
currentPeriodEndstring現在の請求期間の終了日(ISO 8601 日付)
canceledAtstringキャンセルタイムスタンプ(ISO 8601、キャンセル中/キャンセル済みの場合に存在)
返金イベントrefund.succeededrefund.failed):
フィールド説明
refundStatusstring"succeeded" または "failed"
refundReasonstring返金理由
refundCreatedAtstring返金作成タイムスタンプ(ISO 8601)
paymentIdstring元の決済 ID
paymentStatusstring元の決済ステータス
paymentMethodstring元の決済方法
paymentLast4string元の決済の末尾 4 桁
paymentDatestring元の決済日
金額は表示フォーマット文字列であり、最小通貨単位からすでに変換されています。例えば、USD "29.00" = 2900 セント、JPY "4500" = 4500 円です。利用可能な場合は subtotaltotal を使用して明細表示できます。

eventId マッピング

eventId はイベントをトリガーしたビジネスエンティティを識別します。
イベントタイプeventId のマッピング先
order.completedPayment IDPAY_6eYCunG3IMmIgcQOnaXdoA
subscription.activatedOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.payment_succeededPayment IDPAY_6eYCunG3IMmIgcQOnaXdoA
subscription.cancelingOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.uncanceledOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.updatedOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.canceledOrder IDORD_5dXBtmF2HLlHfbPNm0Wcnz
subscription.past_dueOrder ID + 月ORD_5dXBtmF2HLlHfbPNm0Wcnz-2026-04
refund.succeededRefund IDREF_4cWAtlE1GKkGebONl9Xbnx
refund.failedRefund IDREF_4cWAtlE1GKkGebONl9Xbnx
subscription.past_due は eventId に -YYYY-MM を付加します。同一サブスクリプションにつき、カレンダー月ごとに最大 1 回の past_due イベントがトリガーされます。翌月もまだ滞納中の場合、新しいイベントが発行されます。

イベントタイプ

一覧

イベントトリガー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
subscription.uncanceledsubscription.updated のイベントテンプレートは準備済みで、対応する機能のリリース後に有効化されます。

イベント詳細

トリガー: 単発注文の決済が初めて成功した時。ペイロード:
  • data.amount — 決済金額(税込み)
  • data.orderId — 単発注文 ID
推奨アクション:
  • デジタル商品の配信(ライセンスキー、ダウンロードリンク、アクティベーションコード)
  • 注文管理システムの更新
  • 購入者への確認送信(Waffo の組み込みメールを使用しない場合)
同一注文で order.completed がトリガーされるのは 1 回のみです。返金は refund.succeeded / refund.failed で通知されます。
トリガー: 新規サブスクリプションの初回決済が成功した時(pendingactive)。ペイロード:
  • data.amount — 初回決済金額(税込み)
  • data.productName — サブスクリプション商品名
推奨アクション:
  • 加入者のアカウントをプロビジョニングしてアクセスを付与
  • サブスクリプション開始日を記録
サブスクリプションが初めて pending から active に遷移した時にのみ発行されます。以降の更新には subscription.payment_succeeded が使用されます。
トリガー: 定期更新決済が成功した時(初回決済以外)。ペイロード:
  • data.amount — 今期の更新金額(税込み)
  • data.orderId — サブスクリプション注文 ID
推奨アクション:
  • サービス期間の延長
  • この請求サイクルの請求書を生成
  • サブスクリプションが以前 past_due だった場合、フルアクセスを復元
トリガー: 購入者またはマーチャントがキャンセルをリクエスト。サブスクリプションは現在の支払い済み期間の終了まで有効です。推奨アクション:
  • 「サブスクリプションは [日付] に終了します」通知を表示
  • リテンションフロー(例:割引更新)を提供
  • アクセスを取り消さないでください — 購入者は現在の期間分を支払い済みです
購入者は期間終了前にキャンセルを取り消すことができます(subscription.uncanceled がトリガーされます)。
トリガー: 現在の期間が終了する前にキャンセルが取り消された時。推奨アクション:
  • 「まもなく終了」通知を削除
  • 自動更新ステータスを復元
トリガー: サブスクリプション商品が変更された時(アップグレードまたはダウングレード)。ペイロード:
  • data.productName — 変更後の新しい商品名
  • data.amount — 新しい金額
推奨アクション:
  • 購入者のアクセスレベルを更新(機能の追加/削除)
  • 請求記録を更新
トリガー: サブスクリプションが終了 — 支払い済み期間が終了し、これ以上の更新は行われません。推奨アクション:
  • アクセスを取り消す(または無料プランにダウングレード)
  • 猶予期間中データを保持(購入者が再登録する場合に備えて)
  • 「サブスクリプション終了」確認を送信
これは終了状態です。サブスクリプションは不可逆的に終了しています。
トリガー: 更新決済が失敗し、サブスクリプションが滞納状態に入った時。ペイロード:
  • data.amount — 今期の請求金額
  • eventId — フォーマット: {orderId}-YYYY-MM(月次重複排除)
推奨アクション:
  • 購入者に支払い方法の更新を通知
  • オプションでサービスを制限(完全に取り消すのではなく機能を制限)
  • すぐにアクセスを取り消さないでください — PSP が自動的に課金をリトライする場合があります
重複排除: サブスクリプションごとにカレンダー月あたり最大 1 回の past_due イベント。翌月もまだ滞納中の場合、新しいイベントが発行されます。
トリガー: 返金が完了し、資金が返還された時。ペイロード:
  • data.amount — 返金金額(税込み)
  • data.orderId — 元の注文 ID
推奨アクション:
  • 配信済みデジタル商品を取り消す(ライセンスの失効、ダウンロードの無効化)
  • 注文ステータスを「返金済み」に更新
トリガー: 返金処理が失敗した時。推奨アクション:
  • 手動確認のために障害をログに記録
  • 商品を取り消さないでください(返金は完了していません)

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

終了状態意味Webhook を発行?
canceledサブスクリプション終了(購入者/マーチャントによるキャンセルまたは滞納)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 は公開鍵を埋め込み、環境を自動検出し、フォーマットの正規化を処理します。
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 ドキュメントの詳細をご覧ください。

手動検証

TypeScript SDK を使用しない場合は、署名検証を手動で実装してください。
const crypto = require('crypto');

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

レスポンス要件

  • 2xx ステータスコードを返してください(推奨: 200
  • 10 秒以内に応答してください
  • レスポンスボディは問いません
// 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 の組み合わせ(システム内で一意制約あり)を使用してください。
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]
  );
}
同一ビジネスイベント(同一の eventType + eventId)は 1 つの配信レコードのみを作成します — 重複することはありません。ただし、リトライにより単一の配信がエンドポイントに複数回到達する場合があります。

ベストプラクティス

常に X-Waffo-Signature を検証してください。検証なしでは、誰でもエンドポイントに偽造リクエストを送信できます。
本番環境の Webhook URL は、転送中のデータを保護するために HTTPS を使用する必要があります。
すぐに 200 を返し、ビジネスロジックはバックグラウンドで処理してください。レスポンスが遅いと不要なリトライが発生します。
eventType + eventId の組み合わせで重複排除してください。同じ配信が複数回処理されても副作用がないようにしてください。
リプレイ攻撃を防ぐために、t タイムスタンプが現在時刻から 5 分以内であることを確認してください。
Test と Production は異なる鍵ペアを使用します。ペイロードの mode フィールドに公開鍵を一致させてください。
デバッグのために受信したペイロードを保存してください。ダッシュボードでも配信ログクエリを提供しています。
まだ必要でなくても、購読しているすべてのイベントに分岐を追加してください。未処理のイベントには 200 を返してください — エラーを返すと不要なリトライがトリガーされます。

テスト

テストイベントの送信(推奨)

ダッシュボードの「テストイベントを送信」ボタンを使用して、実際のトランザクションをトリガーせずにテストイベントを送信します。テストイベントは固定のサンプルデータ(amount 0、taxAmount 0、product “[TEST] Webhook Verification”)を使用し、常に Test 鍵で署名されます。 10 種類すべてのイベントタイプがサポートされています — 各イベントをテストしてハンドラーを検証してください。

Test モードを使用する

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

ローカル開発

トンネルを使用してローカルサーバーを公開します。
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 に切り替えてください — 鍵の選択とフォーマットの正規化を自動的に処理します

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

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

subscription.cancelingsubscription.canceled の違い

  • canceling: キャンセルがリクエストされましたが、現在の支払い済み期間がまだ終了していません。サブスクリプションはまだ有効で、購入者はキャンセルを取り消すことができます(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 — サブスクリプションの期間あたりの金額