Skip to main content

概要

Webhook は、注文、決済、サブスクリプション、返金などのイベントが発生したとき、サーバーにリアルタイム通知を配信します。
1 つのストアには複数の Webhook を設定でき、それぞれ異なるチャネルに配信されます。利用可能なチャネルは次のとおりです: 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 のみ)検証用公開鍵をコピー

Waffo は環境ごと(Test / Production)に 1 組の固定鍵ペアを使用しており、すべてのストアで共通です。Webhook 登録時に公開鍵が返されるわけではなく、ダッシュボードから自分でコピーする方式です。ダッシュボード を開き、任意のストア → 設定 → Webhooks に移動して、連携する環境(Test または Production)の Webhook Public Key をコピーし、サーバーの設定に保存してください。以降すべての HTTP webhook はこの鍵で検証します。
どのストアのダッシュボードでも Test 公開鍵は同じ、Production 公開鍵も同じです —— プラットフォーム共通の鍵です。Webhook URL を追加・編集・削除しても変わりません。
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 は完全に独立しています: 各 HTTP ペイロードの mode フィールドはソース環境を示します: "test" または "prod"。
常にイベントの mode に一致する公開鍵を使用してください。Test 鍵では Production イベントを検証できず、その逆も同様です。

ペイロードフォーマット

ヘッダー

ボディ

トップレベルフィールド

data フィールド

下表は各 data フィールドの型、出現条件、意味を定義します。特定のイベントの正確なフィールド構成は、下記「イベント詳細」にある各イベントの完全な body を参照してください。 条件列の「決済イベント」は order.completed と subscription.payment_succeeded、「サブスクリプションイベント」は subscription.payment_succeeded を除くすべての subscription.* イベントを指します。subscription.payment_succeeded は純粋な決済イベントで、その 1 回の課金だけを表し、サブスクリプションの期間もステータスも含みません。

非推奨の金額フィールド

amount / total / subtotal / taxAmount / taxRate / taxName は 1 つの名前で 3 つの対象を指しています —— 決済イベントではその課金、返金イベントではその返金、サブスクリプションステータスイベントではプランの表示価格です。各フィールドには、対象を名前で明示した代替フィールドが用意されました。
決済イベントの amount は 2026-09-21 に値が変わります。 同日以降、order.completed と subscription.payment_succeeded の amount は決済チャネルが実際に課金した金額(chargedAmount と同値)になります。これまでは注文時に記録された表示価格でした。両者が異なるのは日割りクレジットやゼロ円のカード確認がある場合だけで、それ以外の課金では値は同一です。その課金についてチャネルが金額を報告しなかった場合、amount は表示価格の合計にフォールバックし、chargedAmount はキーごと省略されます。amount == total で突合している場合、その前提は同日から決済イベントで成立しなくなります。chargedAmount(実際に課金された額)と listPrice.total(表示価格)に切り替えてください。他の値は一切変わりません。 refund.* とサブスクリプションイベントの amount はこれまでと同一で、すべてのイベントの total / subtotal / taxAmount / taxRate / taxName も変わりません。
削除は 2026-09-20 の本告知から 12 か月以内には行わず(2027-09-20 以降)、次のメジャーバージョンで実施します。 それまでは 6 フィールドとも従来どおり配信され、型もトリガー条件も変わりません。新規の連携では代替フィールドを、既存の連携はそのままで動作します。 非推奨は実行時には通知されません:ペイロードに非推奨メタデータは含まれず、Deprecation / Sunset ヘッダーも送信されません。本ページと SDK の型定義(TypeScript の @deprecated、Go の // Deprecated:)が唯一の記載場所です。
periodNumber は決済チャネル自身が報告する請求期間の番号をそのまま透過したものです —— Waffo 側では計算もカウントも推測も行いません。
  • 初回請求は 1、N 回目の更新は N です。
  • 請求が失敗した場合も 1 期間を消費します。 成功した請求の回数ではないため、チャネル側と突き合わせる期間番号との整合に使用し、決済件数として扱わないでください。
  • 0 はチャネルが承認済みでまだ請求していない状態を表し、予約されたプラン変更の切替時刻より前に現れます。
  • subscription.payment_succeeded ではその請求が属する期間を示し、同イベントが持つ唯一のサブスクリプション側フィールドです。
  • refund.* では返金対象となった請求が属する期間であり、返金が発生した時点の期間ではありません。
  • subscription.canceling、subscription.uncanceled、subscription.plan_change_failed ではチャネル自身の通知が発生しないため、チャネルが最後に報告した現在の期間番号になります。
  • 重複排除キーとしては使用できません。 同一期間の複数イベントが同じ番号を持ちます。重複排除には id または eventId を使用してください。
  • 一回限りの注文とその返金には付きません。本フィールド提供開始前に作成されたサブスクリプションと決済にも付きません。
同じ概念が 3 つの名前を持ちます。それぞれがアンカーに対応しており、3 つの異なる数値ではなく、同一のチャネル値を異なる地点で読んだ値です:GraphQL のサブスクリプション注文側は意図的に異なる名前にしています。3 回更新されたサブスクリプションは 現在 3 期目ですが、その初回課金は永久に periodNumber: 1 です。この 2 つを読み違えることこそ、 名前を分けて防ごうとしている誤りです。
金額は表示フォーマット文字列であり、最小通貨単位からすでに変換されています。例えば、USD "29.00" = 2900 セント、JPY "4500" = 4500 円です。ゼロも正当な値(ゼロ円のカード確認)で、同じ形式で出力されます —— USD は "0.00"、JPY は "0"。明細表示には、そのイベントに対応するブロック(listPrice / originalPayment / planPrice)を使用してください。
Webhook ペイロードの taxRate はパーセント値です —— 10 は 10% を意味します。この単位の表記は API 全体で統一を進めており、他の箇所(TypeScript SDK の型定義)では現在も小数値として記述されています。税の徴収は未開始のため、現時点ではすべての注文で taxRate は 0 です。単位を決め打ちせず、ペイロードの値を読み取ってください。

GraphQL の決済金額

これは契約変更ではなく不具合修正です。 GraphQL の Payment.amount は当初から「購入者が課金された合計額」と定義されていましたが、実装は注文時に記録された表示価格のスナップショットを読んでいました。現在は決済チャネルが実際に課金した金額を読みます —— 金額フィルター、金額ソート、「全額返金済みか」の判定も同様です。4 か所が単一のソースを読むため、一覧が片方の数値でソートされ詳細が別の数値を表示する状態は起こりません。値が変わる決済。 両者が異なるものだけで、その起点は 2026-09-07 にリリースされたプランのアップグレード・ダウングレードです —— その日より前は、いずれの課金でも表示価格と実際の課金額が乖離することはありませんでした。現時点で live 側 7 件、test 側 111 件 です。それ以外の決済は以前とまったく同じ値を返します。2026-09-07 以降にこれらの課金を GraphQL で照合していた場合、その金額はチャネルが実際に課金した額に変わり、すでに全額返金済みの決済は全額返金済みと報告されます。ある課金についてチャネルが金額を報告しなかった場合、このフィールドは表示価格の合計にフォールバックし、そのフォールバックはサーバー側にログとして記録されます。フィルターとソートも同じ規則でフォールバックするため、その決済がフィルターやソートの結果から黙って欠落することはありません。Webhook ペイロードには影響しません。 このページのどのフィールドも値は変わらず、追加されるのは返金イベントの originalChargedAmount だけです。
ダッシュボードと管理画面の一部は引き続き表示価格を表示します。 これらはこのフィールドではなく表示価格のスナップショットを直接読んでおり、今回は未対応です: 購入者向け請求書ページ、管理画面の決済一覧、管理画面の PSP 照合ページ、加盟店の売上ページ、購入者ポータル、サイドバー、および分析ページのフォールバック。対応までの間、同じ決済が 2 つの異なる数値で表示されることがあります —— GraphQL API と webhook ペイロードは課金された額を、これらのページは表示価格を報告します。照合はページではなく API を基準にしてください。
実際に受け取っていない決済は「全額返金済み」と判定されます。 「全額返金済みか」は「成功した返金の累計 ≥ その決済が受け取った金額」で判定されるため、受取額が 0 の決済は返金がまったく発生していなくてもこの条件を満たします: isFullyRefunded は true になり、「全額返金済み」のフィルターにも現れます。これらはもともとお金を受け取る意図のない課金です —— ゼロ円のカード確認、無料トライアルの初回課金、クレジットやクーポンで全額が相殺された注文です。これは購入者が実際には課金されていないという意味で、返金が発生したという意味ではありません。両者を区別するには、実際の返金があるかどうかを見てください: refundedAmount がゼロより大きい、refunds リストが空でない、または refundStatus が refunded である —— 最後のものは成功した返金の有無だけで決まり、本修正の影響を受けません。ルール自体は新しいものではありません: 表示価格そのものが 0 の注文は、以前からこのように報告されていました。本修正が広げるのはその範囲です —— 受取額が 0 で表示価格がゼロでない決済が、新たにこの集合に入ります。これは live 側で影響を受ける 7 件のうち 3 件(test モードでは 111 件のうち 39 件)です。本修正によってこの集合から外れる決済はありません。

eventId マッピング

eventId はイベントをトリガーしたビジネスエンティティを識別します。
一部のイベントは同一サブスクリプション上で複数回発生しうるため、eventId にサフィックスが付き、発生ごとに独立した配信になります。4 つのイベントはイベント時刻(完全な ISO 8601)を付加します: subscription.canceling、subscription.uncanceled、subscription.past_due、subscription.recovered —— キャンセルと取り消し、滞納と回復は何度でも繰り返せるためです。subscription.renewed は代わりに繰り越し後の期間終了日を付加します。連続する更新がそれぞれ独立したキーになります。このサフィックスは日単位(YYYY-MM-DD)の日付なので、同一暦日に 2 回繰り越しが起きると 1 件の配信にまとまります —— 圧縮した課金サイクルでのテストでのみ到達し、実際の週次・月次課金では起こりません。それ以外のイベントはエンティティ ID をそのまま使います — 同一エンティティで高々 1 回しか発生しないためです。

支払いと請求期間の紐付け

更新のたびに、互いに独立した 2 つの配信が発生します:subscription.payment_succeeded(課金をキーとする)と subscription.renewed(注文をキーとする)。同じバッチで送出されますが、順序も間隔も保証されないため、受信時点で結合しないでください。それぞれを個別に保存したうえで、data.orderId + data.periodNumber で紐付けます。第 N 期の課金と第 N 期への更新は通常同じ組を持ち、順序の入れ替わり・遅延・リトライがあっても紐付けに影響しません。どちらの値もチャネル自身の期番号をそのまま透過したものなので、この一貫性はチャネルの採番によるもので Waffo の保証ではありません —— まれに一致しない場合に備え、下記の timestamp によるフォールバックは残してください。同じ組で subscription.activated は初回課金(periodNumber: 1)に、subscription.recovered は滞納期間を解消した課金に結び付きます。 このフィールドの提供前に作成されたサブスクリプションには periodNumber がありません。その場合は data.orderId とトップレベルの timestamp(イベント生成時刻。リトライで変わりません)を数分以内で近接照合してください。いずれの場合も、紐付けの前に各イベントタイプを id または eventId で重複排除してください。

イベントタイプ

一覧

subscription.plan_changed はサブスクリプションが別のプランに切り替わったときに送信されます。direction は upgrade / downgrade / same_price のいずれかです。planChange.chargedAmount は今回の実請求額で、日割り控除や加盟店指定額がある場合はプラン表示価格と一致しません。subscription.plan_change_scheduled は変更が確定したものの翌期から有効になる場合に、subscription.plan_change_failed は変更が完了しなかった場合に送信されます。3 つすべて本番で稼働しています — 3 つすべてを処理してください。処理しないと結果を取りこぼします。どれが届くかは適用タイミングによって決まります。つまり翌期変更は 1 回の変更で 2 つのイベントを生成し、プランが実際に有効になるのは 2 つ目のときだけです。plan_change_scheduled の時点で新プランの権限を付与しないでください。
各イベントが注文・サブスクリプション・返金のライフサイクルのどこに位置するか、どの遷移が何も発行しないかは ライフサイクルとトリガータイミング を参照してください。

イベント詳細

トリガー: 単発注文の決済が初めて成功した時。Body:
このイベントでは amount は chargedAmount と同値です —— チャネルが実際に課金した金額です。listPrice は注文時の表示価格で、この例では日割りクレジットが適用されたため両者は一致しません。サブスクリプションおよび返金フィールドは含まれません。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4、chargedAmount、listPrice。推奨アクション:
  • デジタル商品の配信(ライセンスキー、ダウンロードリンク、アクティベーションコード)
  • 注文管理システムの更新
  • customer への確認送信(Waffo の組み込みメールを使用しない場合)
同一注文で order.completed がトリガーされるのは 1 回のみです。返金は refund.succeeded / refund.failed で通知されます。
トリガー: 新規サブスクリプションの初回決済が成功した時(pending → active)。Body:
amount はサブスクリプションの通常期価格であり、実際の課金額ではありません。トライアルや初回割引がある場合、初回の課金額は異なります — 実際に回収された金額は subscription.payment_succeeded を使用してください。
このイベントは個別の課金ではなく注文に紐づくため、paymentId を含む決済フィールドは一切含まれません。期間の保存とイベント発行は同一リクエスト内で完了するため、billingPeriod、currentPeriodStart、currentPeriodEnd は必ず含まれます。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、planPrice。推奨アクション:
  • 加入者のアカウントをプロビジョニングしてアクセスを付与
  • サブスクリプション開始日を記録
サブスクリプションが初めて pending から active に遷移した時にのみ発行されます。同じ初回決済は課金自体に対して subscription.payment_succeeded も発行します — サブスクリプションライフサイクル を参照してください。
トリガー: サブスクリプション課金の成功 — 初回決済、各更新、滞納期間を解消する課金を含みます。Body:
他の subscription.* イベントと異なり、このイベントは課金に紐づくため決済フィールドを含みます。amount は今期の実際の課金額で、chargedAmount と同値です。listPrice はその期の表示価格で、日割りクレジットやゼロ円のカード確認があるとこちらが高くなります。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4、chargedAmount、listPrice。
これは純粋な決済イベントです。 billingPeriod、currentPeriodStart、currentPeriodEnd、canceledAt、orderStatus は含まれません — これら 5 つは課金ではなくサブスクリプションを表すもので、現在はサブスクリプションドメインのイベントだけが運びます。そちらは保存とイベント発行が同一リクエスト内で完了するため、値が欠けることはありません。ハンドラーがこのイベントから上記 5 つのいずれかを読んでいる場合:periodNumber は例外です。これはこの課金を表すため本イベントに残り、いま支払われたのが何期目かを示します。決済フィールドと orderId しか使っていないハンドラーは変更不要です。
推奨アクション:
  • この課金の受領記録を残す
  • この請求サイクルの請求書を生成
  • サービス期間の延長は subscription.renewed で、フルアクセスの復元は subscription.recovered で行う
トリガー: 現在の請求期間が繰り越されたとき — 支払い済みの期間が終わり、次の期間が始まったタイミング。サブスクリプションの初回期間は更新ではないため、このイベントは送信されません。Body:
このイベントに決済フィールドはありません — 報告するのは新しい期間であって、その課金ではありません。amount はサブスクリプションの 1 期間あたりの価格です。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、planPrice。推奨アクション:
  • サービス期間を新しい currentPeriodEnd まで延長
重複排除: eventId に繰り越し後の期間終了日が入るため、同一サブスクリプションの連続する更新はそれぞれ独立した配信になり、決済代行からの通知が再送されても 2 通目にはなりません。
トリガー: 滞納中のサブスクリプションで再試行の課金が成功し、active に戻ったとき(past_due → active)。subscription.past_due で開いたループを閉じます。初回決済と通常の更新では送信されません。Body:
このイベントに決済フィールドはありません — 課金そのものは、同じ再試行に対して独立に配信される subscription.payment_succeeded が報告します。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、planPrice。推奨アクション:
  • subscription.past_due で制限したアクセスを元に戻す
重複排除: eventId にイベント時刻が入るため、同一サブスクリプションが滞納と復帰を繰り返しても、復帰ごとに 1 通ずつ配信されます。
トリガー: customer またはマーチャントがキャンセルをリクエスト。サブスクリプションは現在の支払い済み期間の終了まで有効です。Body:
canceledAt はキャンセルがリクエストされた時刻であり、アクセスが終了する時刻ではありません。customer は currentPeriodEnd まで支払い済みです — canceledAt でアクセスを取り消すと、支払い済みのサービスを打ち切ることになります。
このイベントに決済フィールドは含まれません。amount はサブスクリプションの期間あたりの価格であり、個別の課金額ではありません。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、planPrice。推奨アクション:
  • 「サブスクリプションは [日付] に終了します」通知を表示
  • リテンションフロー(例:割引更新)を提供
  • アクセスを取り消さないでください — customer は現在の期間分を支払い済みです
customer は期間終了前にキャンセルを取り消すことができます(subscription.uncanceled がトリガーされます)。
トリガー: 現在の期間が終了する前にキャンセルが取り消された時。Body:
このイベントに決済フィールドは含まれません。キャンセルが取り消されると canceledAt はクリアされるため、現れません。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、planPrice。推奨アクション:
  • 「まもなく終了」通知を削除
  • 自動更新ステータスを復元
トリガー: サブスクリプション商品が変更された時(アップグレードまたはダウングレード)。Body:
トップレベルのフィールドは変更後の状態を表します。data.planChange に変更前後の対比が含まれるため、変更前のプランを自身で記録する必要はありません。direction は upgrade / downgrade / same_price のいずれかです。amount は購入者が実際に支払った金額ではありません。 トップレベルの amount / total / subtotal / taxAmount は新プランの表示価格で、今回の実請求額は planChange.chargedAmount です。プラン変更では両者は必ず一致しません — 日割り控除か加盟店指定額のいずれかが適用されるためです。照合には chargedAmount を使用してください。planChange はプラン変更イベントにのみ含まれ、他のイベントの data にはこのキー自体が存在しません。内部の各フィールドは値がない場合 null になります: 決済成功前は chargedAmount が null、即時変更では effectiveDate が null になることがあり、direction は未定義の値を透過せず null になります。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、planPrice。推奨アクション:
  • customer のアクセスレベルを更新(機能の追加/削除)
  • 請求記録を更新
トリガー: プラン変更が確定したが、次の請求期間の開始時に有効になります。それまで customer は現在のプランのままです。Body:
この時点で変更はまだ有効になっていません。 effectiveDate は新プランが開始する未来の時刻、orderStatus は pending で、currentPeriodStart / currentPeriodEnd は出現しません — それまで新しいサブスクリプションには請求期間がないためです。chargedAmount は null です:翌期変更は切り替え時刻に請求され、確定時には請求されません。このイベントで新プランの権限を付与せず、subscription.plan_changed を待ってください。id と eventId は新しいサブスクリプション注文で、切り替え時刻の subscription.plan_changed が持つ注文と同一です。customer が現在利用中のサブスクリプションは別の注文で、このペイロードには現れません。その注文はこの時点で切り替え待ちとしてマークされますが、subscription.canceling は発行されません — このイベントが唯一のシグナルです。切り替え前に確定通知が複数届いた場合、配信は 1 回だけです — eventType + eventId で収束します。後続の plan_changed はイベントタイプが異なるため、これに吸収されることはありません。推奨アクション:
  • 保留中の変更とその effectiveDate を記録
  • 現在のプランの権限は一切変更しない
  • 任意:新プランの開始時期を customer に通知
トリガー: プラン変更が完了しなかった — 決済が承認されなかった、または決済チャネルが失敗を報告しました。元のプランが継続します。Body:
このイベントの planChange はほとんどが null です。これは設計どおりです。 何も有効にならなかったため、変更方向も適用時刻も報告できる金額もありません:値を持つのは oldPlanName と newPlanName だけで、残りの 5 フィールドは常に null です。subscription.plan_changed の body に合わせて書いたハンドラーはここで null を読みます — これらのフィールドに触れる前に eventType で分岐してください。
orderStatus は canceled ですが、customer はサブスクリプションを失っていません。 キャンセルされたのは開始に失敗した新しい注文です。元のサブスクリプションは手つかずで、今も有効です。このイベントで権限を取り消すのはバグです。本当の解約は必ず subscription.canceled として届きます。
再試行すると新しいサブスクリプション注文が作成されるため、2 回目の失敗は異なる id / eventId で届き、これに収束することはありません。推奨アクション:
  • customer の権限は現状のまま一切変更しない
  • subscription.plan_change_scheduled で記録した保留中の変更を消去
  • 任意:変更を再度試すよう customer に案内
トリガー: サブスクリプションが終了 — 支払い済み期間が終了し、これ以上の更新は行われません。Body:
このイベントに決済フィールドは含まれません。canceledAt はキャンセルがリクエストされた時刻で、本イベントより前になります — サブスクリプションはそれ以降も currentPeriodEnd まで有効でした。amount は期間あたりの価格であり、最後の課金額ではありません。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、canceledAt、planPrice。推奨アクション:
  • アクセスを取り消す(または無料プランにダウングレード)
  • 猶予期間中データを保持(customer が再登録する場合に備えて)
  • 「サブスクリプション終了」確認を送信
これは終了状態です。サブスクリプションは不可逆的に終了しています。
トリガー: 更新決済が失敗し、サブスクリプションが滞納状態に入った時。Body:
eventId にはイベント時刻がサフィックスとして付くため、滞納状態に入るたびに独立した配信になります。決済フィールドを含まないため、課金が失敗した理由はペイロードからは分かりません。amount は請求金額です。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、taxRate、taxName、subtotal、total、currentPeriodStart、currentPeriodEnd、planPrice。推奨アクション:
  • customer に支払い方法の更新を通知
  • オプションでサービスを制限(完全に取り消すのではなく機能を制限)
  • すぐにアクセスを取り消さないでください — PSP が自動的に課金をリトライする場合があります
重複排除: 滞納状態に入るごとに 1 通配信されます。サブスクリプションが再び past_due に入るのは、課金成功で active に戻った(subscription.recovered が発行された)後だけなので、同じ滞納期間中に再試行が繰り返し失敗しても複数のイベントには広がりません。
トリガー: 返金が完了し、資金が返還された時。Body(部分返金の例: 元の決済 29.00 に対して 10.00 の返金):
amount は今回の返金額、total は元の決済金額です。部分返金では両者が一致しません — 計上には refundedAmount(または非推奨の amount)を使用し、total は使用しないでください。
subtotal / total / taxRate / taxName は元の決済基準で、比較用に保持されています。返金には税の内訳がないため taxAmount は常に "0" です —— 元の注文の実際の税額は originalPayment.taxAmount にあります。返金額は refundedAmount、返金対象の元の決済は originalPayment を参照してください。その決済がまだいくら返金できるかは、originalPayment.total ではなく originalChargedAmount から計算してください —— 前者は購入者が実際に課金された金額であり、両者が異なる場合に表示価格まで返金しようとするとチャネルに拒否されます(表示価格 50.00 に対して 5.00 課金された決済は、最大 5.00 までしか受け付けられません)。paymentId は返金対象の課金を指します — サブスクリプションの場合は特定の期の課金であり、どの期が返金されたかはこれで判断できます。ペイロードにはその決済の累計返金額は含まれないため、1 つの決済が複数回返金される可能性がある場合は自身で記録してください。値がない場合に省略されるフィールド: productDescription、merchantProvidedBuyerIdentity、billingDetail、orderMerchantExternalId、refundTicketMerchantExternalId、taxRate、taxName、subtotal、total、paymentMethod、paymentLast4、refundReason、refundedAmount、originalChargedAmount、originalPayment。推奨アクション:
  • 配信済みデジタル商品を取り消す(ライセンスの失効、ダウンロードの無効化)
  • 注文ステータスを「返金済み」に更新
トリガー: 返金処理が失敗した時。Body:
資金は移動していません。refundedAmount(および非推奨の amount)は試行された返金額です — 返金として計上しないでください。
その他の構造は refund.succeeded と同じです。2 つのイベントを区別できるのは refundStatus のみなので、フィールドの有無ではなくこれで分岐してください。ペイロードに失敗理由は含まれないため、ダッシュボードで返金チケットを確認してください。値がない場合に省略されるフィールド: refund.succeeded と同じ。推奨アクション:
  • 手動確認のために障害をログに記録
  • 商品を取り消さないでください(返金は完了していません)

ライフサイクルとトリガータイミング

イベントは購入者のクリック時ではなく、背後のビジネス事実が確定した時点で発行されます。決済系イベントは決済プロバイダーが課金を確認した後、サブスクリプション状態系イベントはプロバイダーが状態変更を確認した後に発行されます。発行後、エンドポイントへの配信は通常数秒以内に完了します。 timestamp フィールドはイベントが生成された時刻を保持します。リトライでも同じ値が使われ、配信試行ごとに変化することはありません。

配信タイムライン

決済が確定した地点は、チェックアウトのリダイレクトではなくイベントです。購入者がリダイレクト前にブラウザを閉じても、イベントは届きます。

一回限りの注文のライフサイクル

決済が拒否された注文は pending のまま残り、購入者は同じ注文で再度支払えます。order.completed は注文につき 1 回だけ — 最終的に成功した決済が発行します。

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

初回決済は2 つのイベントを発行します。subscription.activated(注文に紐づく)と subscription.payment_succeeded(課金に紐づく)です。両者は独立して配信されるため、順序も間隔も保証されません。activated でアクセスをプロビジョニングし、payment_succeeded で受領を記録してください — 両方を「新規加入者」として扱うと、オンボーディングが 2 回実行されます。
キャンセルは 2 つの時点に分かれます。subscription.canceling はキャンセルがリクエストされた瞬間に発行され、終了自体は支払い済み期間の終わりに予約されます。subscription.canceled は決済プロバイダーが終了を確認した後 — リクエスト時点ではなく currentPeriodEnd の前後 — に発行されます。その間もサブスクリプションは有効で、customer はキャンセルを取り消せます。 滞納中のサブスクリプションは次の更新失敗で終了します。1 回目の拒否で active から past_due に移って subscription.past_due を発行し、past_due 状態でさらに拒否されるとサブスクリプションが終了して subscription.canceled を発行します。

返金のライフサイクル

返金イベントは最後のステップ — 決済プロバイダーが結果を確定した時点 — でのみ発行されます。返金チケットの作成・承認・却下・再提出では webhook は発生しないため、審査中のチケットは確定するまでエンドポイントからは見えません。API 経由の返金は自動承認され、そのまま processing に入ります。チケットの状態と照会方法は 返金エンドポイント を参照してください。

イベントが発行されないケース


署名検証

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

アルゴリズム

SDK の使用(推奨)

SDK は公開鍵を埋め込み、環境を自動検出し、フォーマットの正規化を処理します。
SDK Webhook ドキュメントの詳細をご覧ください。

手動検証

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

レスポンス要件

  • 2xx ステータスコードを返してください(推奨: 200)
  • 10 秒以内に応答してください
  • レスポンスボディは問いません
非 2xx レスポンスまたはタイムアウトはリトライをトリガーします。

リトライポリシー

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

配信ステータス

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

重複の処理

ネットワークの問題により、同じイベントが複数回配信される場合があります。イベント処理が冪等であることを確認してください。 重複排除には eventType + eventId の組み合わせ(システム内で一意制約あり)を使用してください。
同一ビジネスイベント(同一の eventType + eventId)は 1 つの配信レコードのみを作成します — 重複することはありません。ただし、リトライにより単一の配信がエンドポイントに複数回到達する場合があります。

ベストプラクティス

常に X-Waffo-Signature を検証してください。検証なしでは、誰でもエンドポイントに偽造リクエストを送信できます。
本番環境の Webhook URL は、転送中のデータを保護するために HTTPS を使用する必要があります。
すぐに 200 を返し、ビジネスロジックはバックグラウンドで処理してください。レスポンスが遅いと不要なリトライが発生します。
eventType + eventId の組み合わせで重複排除してください。同じ配信が複数回処理されても副作用がないようにしてください。
t は最大 45 分まで許容してください。リトライは最初の署名ヘッダーをそのままリプレイし、t は再発行されません。最後のリトライは初回配信から 31 分以上経過して到達するため、5 分ウィンドウでは自分自身の障害復旧中に発生した正当なリトライを 401 で拒否してしまいます。リプレイ対策はこのウィンドウを狭めるのではなく、payload の id で冪等に重複排除してください。id はイベントごとに一意で、リトライを跨いで変わりません。
Test と Production は異なる鍵ペアを使用します。ペイロードの mode フィールドに公開鍵を一致させてください。
デバッグのために受信したペイロードを保存してください。ダッシュボードでも配信ログクエリを提供しています。
まだ必要でなくても、購読しているすべてのイベントに分岐を追加してください。未処理のイベントには 200 を返してください — エラーを返すと不要なリトライがトリガーされます。

テスト

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

ダッシュボードの「テストイベントを送信」ボタンを使用して、実際のトランザクションをトリガーせずにテストイベントを送信します。テストイベントは固定のサンプルデータ(amount 0、taxAmount 0、product “[TEST] Webhook Verification”)を使用し、常に Test 鍵で署名されます。 10 種類すべてのイベントタイプがサポートされています — 各イベントをテストしてハンドラーを検証してください。
periodNumber はテストイベントと本番環境で挙動が異なります。テスト送信には実際のサブスクリプションが存在しないため、返金サンプルはどの期間の返金かを示せません。 本番環境では、サブスクリプション課金の返金イベントは periodNumber を含みます —— 返金対象となった課金が属する期間です。テストイベントから「サブスクリプションの返金にはこの フィールドがない」と判断しないでください。ハンドラーはフィールドが存在する場合に読み取るよう 実装してください。

Test モードを使用する

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

ローカル開発

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

配信ログ

ダッシュボードで 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 の組み合わせを使用してください。

サブスクリプションの初回決済で 2 つのイベントが届くのはなぜですか?

初回の課金は 2 つの事実を同時に確定させます。サブスクリプションがアクティブになったこと(subscription.activated、注文に紐づく)と、支払いを受領したこと(subscription.payment_succeeded、課金に紐づく)です。両者は独立して配信されるため、順序も間隔も保証されません。activated でアクセスをプロビジョニングし、payment_succeeded で受領を記録すれば、初回決済の処理はちょうど 1 回だけ実行されます。

subscription.canceled は正確にはいつ届きますか?

currentPeriodEnd の前後です — キャンセルがリクエストされた時点ではありません。キャンセルのリクエストは即座に subscription.canceling を発行し、終了を支払い済み期間の終わりに予約します。決済プロバイダーが終了を確認した後に subscription.canceled が続きます。アクセスをいつ終了すべきかは、canceling ペイロードの currentPeriodEnd で判断してください。

subscription.canceling と subscription.canceled の違い

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

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

すべてのイベント: data.amount はそのイベントに対する取引金額(税込み)です。
  • order.completed / subscription.activated — 決済金額
  • subscription.payment_succeeded — 今期の実際の課金額
  • subscription.renewed / subscription.recovered — サブスクリプションの 1 期間あたりの金額
  • subscription.past_due — 今期の請求金額
  • refund.succeeded / refund.failed — 返金金額
  • subscription.canceling / subscription.canceled / subscription.uncanceled — サブスクリプションの期間あたりの金額