国際ECのStripe Checkout連携:例外ケースと教訓
Stripeのドキュメントは非常に優れており、初期の連携は通常、最初の試行で動作します。しかし、国際ECではクイックスタートガイドが扱わない問題が生じます。インフラの再起動時にもWebhookを確実に受信する方法、入金通貨とは異なる通貨で返金する際の細かな仕組み、複数環境でAPIキーを管理する運用負荷などです。この記事では、当社が遭遇した例外的なケースと、それを受けて変更した点を記録します。
Checkout Sessionモデルとその前提
Stripe Checkoutは、Stripeがホストする決済ページです。サーバーでセッションを作成し、顧客をそのページへリダイレクトすると、決済完了時にStripeがWebhookを呼び出します。複数通貨で運用したり、出荷処理時に決済オブジェクト全体を確認したりする必要がなければ、流れは単純です。
初期設定では、Checkout Sessionのレスポンスに含まれる決済情報は最小限です。payment_intentフィールドは文字列のIDであり、展開されたオブジェクトではありません。たとえばVATの判定に使うカード発行国を記録するために、出荷処理でpayment_intent.latest_charge.payment_method_detailsを読む必要がある場合、この点が重要です。(2022-11-15より前のAPIバージョンでは、パスはpayment_intent.charges.data[0]でした。StripeはこのバージョンでPaymentIntentからchargesプロパティを削除しました。)Webhookイベントには展開済みのオブジェクトが含まれないため、出荷処理時にこのデータを取得するには、イベントごとに追加のAPI呼び出しが必要です。
expandパラメータには、ドットで区切ったパスの配列を渡します。この指定が適用されるのは、指定したAPI呼び出しのレスポンスだけです。Stripeはイベント内のオブジェクトを常に最小限の形式で送信するため、セッション作成時に指定しても、後でWebhookに送られるセッションオブジェクトは変わりません。Webhookハンドラーでは、代わりに["payment_intent.latest_charge"]を展開するよう指定してセッションを取得します。その分、イベントごとに追加のAPI呼び出しが必要になり、レスポンスも大きくなるため、生のレスポンスをログストアへ書き込む場合には注意が必要です。
Webhookの信頼性
StripeのWebhook配信は再試行を前提としています。エンドポイントが2xxステータスを返さない場合やタイムアウトした場合、Stripeは本番モードでは最大3日間、指数バックオフで再試行します。サンドボックスのイベントは数時間のうちに3回再試行されます。優れた設計ですが、ハンドラーに冪等性がなければ処理の正確性に問題が生じます。注文作成、在庫確保、メール送信といった副作用は、それぞれ1回だけ実行される必要があります。
標準的な対策は、副作用を実行する前に、処理済みのイベントIDを記録することです。当社ではStripeのイベントIDを主キーにした単純なデータベーステーブルを使います。ハンドラーは受信したidフィールドを読み取り、挿入を試み、成功した場合だけ処理を続けます。重複するイベントIDは競合となり、ハンドラーはすぐに200を返します。安全に処理するには、挿入を副作用と同じデータベーストランザクションでコミットする必要があります(または、副作用が成功してから行を処理済みとして記録します)。そうしなければ、挿入後のクラッシュによりStripeの再試行が重複と見なされ、注文がいつまでも処理されなくなります。
ドキュメントで十分に強調されていないのは、checkout.session.completedイベントの受信と、Stripeの精算処理で実際に売上が確定するまでの時間差です。特定の決済手段、特に銀行振込やドイツとオランダで利用できる一部のBNPLでは、資金の確認前にCheckout Sessionが完了します。その場合、セッションはpayment_statusがunpaidの状態で完了します。使うべきイベントは、出荷処理を開始するためのcheckout.session.async_payment_succeededと、失敗に対応するためのcheckout.session.async_payment_failedです。payment_statusを確認せず、すべてのcheckout.session.completedで出荷処理を開始すると、後から決済が失敗する注文まで出荷してしまいます。
コンテナの再起動でも問題がありました。デプロイ中、旧コンテナが終了し、新コンテナが起動するタイミングに、処理中のWebhookリクエストが到着することがあります。Nginxは転送先の切り替え中も接続を保持しますが、短い時間帯にイベントが失われることを確認しました。対策として、Nginxの設定にkeepalive_timeoutとproxy_read_timeoutを明示的に追加しました。また、Webhook受信側でデプロイ時の停止を完全になくそうとするのではなく、Stripeの再試行機構を利用することにしました。
本番キーとテストキーの管理
Stripe連携で最もよく見かける誤りは、本番キーとテストキーを完全に分離していないことです。分かりやすい例は、テスト環境で誤って本番キーを使い、実際の請求を発生させることです。より見落としやすいのは、本番に近いデータを持つデータベースを共有するステージング環境で、テストモードのキーを使うケースです。
当社ではdevelopment、staging、productionの3環境それぞれに、Stripeの認証情報一式を用意します。開発環境はテストモードです。ステージング環境もテストモードですが、別の制限付きキーを使い、Stripeダッシュボードに専用のWebhookエンドポイントを登録します。本番環境は本番モードです。すべてのキーは実行時に環境変数として渡し、ソース管理へのコミットや、ビルド時のDockerイメージへの埋め込みは行いません。
Webhook署名用シークレットは、APIキーとは別の認証情報です。Stripeダッシュボードに登録した各エンドポイントには、それぞれ専用の署名用シークレットがあります。3環境なら3つ必要です。特に新しいデプロイを設定するときには、間違ったものをコピーしがちです。当社は内部の運用手順書に、Stripeアカウント、エンドポイント、署名用シークレット、キーのプレフィックスの対応関係を明示します。手間に見えますが、そうしないと、本番イベントの署名検証が気づかないうちに失敗する問題をデバッグすることになります。
Stripeの制限付きキー機能は、十分に活用されていません。すべてのサービスに権限の広いシークレットキーを渡す代わりに、そのサービスが必要とする操作だけに権限を限定したキーを作れます。PaymentIntentの読み取りと返金作成だけを行う出荷処理ワーカーに、商品やサブスクリプションの書き込み権限は不要です。当社はサービスごとに制限付きキーを発行し、メインのシークレットキーとは独立してローテーションします。
通貨の扱いと返金の問題
Stripeは135を超える決済通貨に対応していますが、入金通貨はStripeアカウントの所在国によって決まります。EURで入金されるアカウントで顧客がSGDで支払うと、Stripeは課金時に通貨を換算します。返金するまでは、特に意識せずに運用できます。
返金は、顧客が支払った決済通貨で処理されます。ただし、Stripe残高から差し引かれる金額には、元の課金時ではなく、返金時の為替レートが使われます。その間に為替が不利な方向へ動くと、入金通貨では受け取った額より多く返金することがあります。これは不具合ではなく、想定された動作です。Stripeの返金と不審請求の申し立てに関するガイドに記載されていますが、見落としやすく、複数通貨で利益率の低い事業を運用する場合には、実際のコストに影響します。(2026年10月時点では、StripeのAdaptive Pricingは例外で、返金には元の取引の為替レートが使われます。)
実用的な対策は、為替リスクを把握できる通貨だけを受け付けるか、為替変動に備えて資金の余裕を持つことです。取引量が多い場合、Stripeの複数通貨対応の入金口座を使えば、特定通貨の残高を保持し、通貨ごとに入金できます。対象通貨では返金時の換算が不要になりますが、資金管理は複雑になります。
当社の内部基準通貨とは異なる通貨で課金した注文の一部返金でも、例外的な問題に遭遇しました。(1つのCheckout Sessionで使う通貨は1つなので、注文の全明細で同じ通貨を使います。)一部返金では、元の課金の通貨で金額を指定する必要があります。受注管理システムが内部の基準通貨で金額を保存している場合、換算後の金額だけでなく、元の決済金額と通貨も注文記録に残す必要があります。最初の一部返金が分かりにくい検証エラーで失敗した後、当社は注文テーブルに両方のフィールドを追加しました。
国によって異なる決済手段
Stripe Checkoutの決済手段の動的選択機能は、顧客の所在地に応じた決済手段をフォームに表示します。便利ですが、注意が必要です。明示的なリストを渡さずに、ダッシュボードの設定に基づいてStripeに決済手段を選ばせる場合、利用可能な手段は表示時にStripeが評価する条件で変わるため、セッション作成時点では完全に予測できません。
これはテストスイートにも影響します。特定の決済手段が表示されることを確認するテストは、地域によって動作が変わります。当社は統合テストでは決済手段の動的選択を使わず、payment_method_typesパラメータで決済手段を明示し、一定の条件を確保しています。本番のCheckoutでは決済手段の動的選択を使います。この違いは意図的なもので、文書に記録しています。
SEPA Direct Debit、iDEAL、Bancontact、Przelewy24には、それぞれカード決済にはない要件があります。ホスト型Checkoutは、SEPAのIBANや口座振替への同意など、決済手段固有の情報を自ら収集します。ただし、ほかにも制約があります。CheckoutでSEPA Direct Debitを使うには全明細の価格をEURで設定する必要があり、決済結果の通知も遅れるため、前述の非同期イベントが適用されます。当社は有効にした各決済手段を、決済確認の段階だけでなく、購入フロー全体でテストします。
初期の本番トラブルを受けて変更したこと
連携を実際の取引で数か月運用した後、3つの構造的な変更を行いました。第1に、メモリ内の保存ではコンテナ再起動を越えて維持できないため、冪等性キーをデータベースへ保存するようにしました。第2に、エラーだけでなく、StripeイベントID、イベント種別、Webhookハンドラーの各呼び出し結果を明示的に記録しました。これにより、注文状態の食い違いを調べる際に、イベントの順序を再構成できるようになりました。第3に、Stripe Dashboardのイベントログを主なデバッグ手段とするのをやめ、自社のログを正本として扱うようにしました。
Stripe Dashboardはイベントを対話的に調べるには便利ですが、表示されるのはStripe側の配信状況であり、アプリケーション側の受信状況ではありません。ダッシュボードで配信成功と表示されるのは、当社のエンドポイントが2xxステータスを返したという意味にすぎません。そのレスポンスの後で処理が完了したかどうかは分かりません。当社のログは全体の状況を示していましたが、ダッシュボードだけでは全体像を把握できませんでした。