普段から EC サイトや決済まわりの実装・設計に関わることが多いのですが、とあるタイミングで途中参画した EC プロジェクトで、購入完了画面(いわゆる complete)のコントローラ・アクションのコードを眺めていてヒヤっとする場面がありました。
画面自体はごく普通の「ご購入ありがとうございました」ページに見えるものの、その裏側で外部システム連携や会員情報まわりの重要な処理などが、complete アクションの中だけで行われていたものがあったのです...!
フロントを見ているだけですと、「まあ動いているし問題なさそう」に見える実装ですが、
よくよく考えると、ユーザーがその画面にたどり着かないケース(トンネルに入って圏外・ブラウザやアプリが落ちる・電池が切れる・決済代行からのリダイレクトに失敗する等)はいくらでもあります。
その瞬間に、「決済は通っているのに、システムでは注文も在庫も更新されていない」というような、最悪のパターンが普通に起こりい得ます。
本記事は、そのときの気付きを「二度と同じ罠を踏まないためのメモ」として整理したものです。
当たり前のことかもしれませんが...「購入完了画面に決済後の重要処理を書いてしまう」という設計がなぜ危険なのか、
どのように分離しておくと後から楽か、いったん自分の頭の中をそのまま文章にしておきます。
1. 購入完了画面は「結果を見せる場所」であり「処理をする場所」ではない
結論として、購入完了画面は ただただ結果を見せる場所 くらいに扱っておきたいです。
(ユーザーに見せるための「おまけ画面」ぐらいの温度感でいたい...)
-
NG
- 決済後に必ず実行すべき処理(受注登録、在庫引当、ポイント付与、チケット発行、注文メール送信など)を
「/complete(購入完了画面)が表示されたタイミング」で行う 設計
- 決済後に必ず実行すべき処理(受注登録、在庫引当、ポイント付与、チケット発行、注文メール送信など)を
-
OK
- 上記のような処理は サーバ側の決済イベント(Webhook や API での決済結果取得) をトリガーにして行い、
/completeは「すでに完了した結果を見せるだけ」の画面にしておく設計
- 上記のような処理は サーバ側の決済イベント(Webhook や API での決済結果取得) をトリガーにして行い、
理由は単純で、
スマホ前提のいまどきだと「ユーザーが complete までたどり着かない」こと自体は日常茶飯事なのに、
決済側だけはちゃんと通っているケースが普通に起き得るからです。
このあたりは、Stripe をはじめとした複数の決済サービスが、公式ドキュメントの中で
success_url/完了画面だけに依存するな、という趣旨でわりとストレートに注意喚起してくれています。
https://docs.stripe.com/payments/accept-a-payment?locale=ja-JP
Checkout のランディングページからのみフルフィルメントをトリガーする場合、確実性に欠けます
https://docs.stripe.com/checkout/fulfillment
Webhook は必須 顧客は Checkout ランディングページにアクセスできることを保証されていない
https://docs.stripe.com/payments/checkout/custom-success-page
フルフィルメントには Webhook が必要です 顧客は決済のランディングページにアクセスできることを保証されていないため、ランディングページに限定してフルフィルメントをトリガーすることはできません。たとえば、決済が完了した後、ランディングページが読み込まれる前にインターネットの接続が失われることがあります。
https://doc.komoju.com/docs/hosted-page-integration-guide#step-4-handle-webhook
It is possible that the redirect in step 3 fails, possibly due to the user closing their browser, network issues, etc. Or, as shown above, that the capture will only take place later on, such as
with Convenience Store payments. To account for this, we recommend also creating a webhook to listen for payment capture.
ここまで書かれているので、「success_url, return_url に来たから OK」というような設計は、
少なくとも新規実装では避けておいた方がいいよな...というのが正直な感想です。
2. 「フロント」と「バック」をちゃんと分けて考える
決済のフローをざっくり二つに切り分けて考えると、一気に整理されます。
-
フロント(ユーザーのブラウザ側の流れ)
- EC サイト → 決済画面(または決済処理) → 完了後に
/completeやsuccess_urlへリダイレクト - ここには「通信断」「タブを閉じる」「ブラウザクラッシュ」など、どうしても制御できない要素が大量に入る
- EC サイト → 決済画面(または決済処理) → 完了後に
-
バック(サーバ間の通信)
- 決済サービス → EC サイトの Webhook エンドポイントにイベント送信
- 決済結果を API / Webhook 経由でサーバ同士がやり取りする
- 通知失敗時はリトライ・署名検証などもセットで設計されている
Stripe の Checkout / PaymentIntents のガイドでも、
- 「支払いの確認は Webhook かサーバ側で PaymentIntent を取得して行う。ユーザーのリダイレクトだけに頼ってはいけない」
- 「Checkout のランディングページだけでフルフィルメントをトリガーするのは信頼できない」
と明記されています。
つまり、
- 決済が「本当に通ったかどうか」の一次情報源はバックエンド(Webhook / API)側にある
- フロント(/complete)は、あくまでその結果を見やすく整えてあげるだけ
という前提で設計した方が、あとから自分たちが楽になります。
3. complete に到達しないのはレアケースではなく日常
「そんなに起きる?」と思われるかもしれないので、スマホ前提でよくあるパターンを並べてみます。
3-1. スマホで普通に起きるやつ
- 電車でトンネルに入り圏外になった
- Wi-Fi と 4G/5G の切り替えで一瞬オフラインになった
- 決済画面が少し重く、「戻る」やタブクローズを押されてしまった
- バッテリー切れで電源が落ちた
- ブラウザクラッシュ・アプリクラッシュで画面が閉じた
このとき、よくあるのが
- 決済代行側では決済は成功している
- でもブラウザは /complete に戻ってこない
というパターンです。
運用に入ると、こういう問い合わせがじわじわ効いてきます...。
3-2. 決済サービス側も「リダイレクトだけを信用するな」と書いている
Stripe のドキュメントには、かなりストレートにこう書かれています。
-
success_urlのリダイレクトだけで支払いを検知してはいけない - 悪意あるユーザーは、支払いなしに
success_urlに直接アクセスできる - ユーザーはタブを閉じるなどして、
success_urlに到達しないことがある
そして、一般的な「Accept a payment」ガイドでも、
Checkout のランディングページだけからフルフィルメントをトリガーするのは信頼できない
→ Webhook イベントを listen して処理せよ
とハッキリ書かれています。
4. Stripe が前提にしているもの
Stripe のドキュメントを読んでいると、「彼らが前提にしている世界観」はだいたいこんな感じだと理解しています。
4-1. Webhook が「支払い確認の第一候補」
公式 docs では繰り返し、
- Webhook は支払いが完了したときに通知を受け取る最も信頼できる方法である
- 配信に失敗した場合は、Stripe 側が自動で複数回リトライする
と書かれています。
つまり「自サーバでポーリングしまくれ」ではなく、「イベントを素直に受け取りなさい」という思想です。
4-2. 「リダイレクトだけに頼るな」の背景
Stripe がわざわざ「リダイレクトだけに頼るな」と書いているのは、だいたい次のような理由だと思います。
- ユーザーが
success_urlに到達しないかもしれない(タブを閉じる等) - 悪意あるユーザーが
success_urlを直接叩けてしまう可能性がある - 非同期決済(銀行振込・後払いなど)は、その場では成功が確定しない
このあたりを踏まえて、「サーバ側で PaymentIntent のステータス確認 or Webhook 受信を必須にしてほしい」と書いているわけですね。
4-3. Checkout / Payment Links でも話は同じ
Stripe Checkout / Payment Links のフルフィルメントガイドでは、
- サーバ側で
fulfill_checkout関数のような「後処理のかたまり」を用意する - それは Webhook によってトリガーされる
- この関数の中で注文確定・在庫引当・メール送信・配送開始を行う
というパターンが標準で説明されています。
「Checkout のランディングページだけでフルフィルメントを行うのは信頼できない」
と言われているので、
Stripe 前提の実装で /complete に依存するのは、公式推奨と真逆の方向と言ってしまってよいと思います。
5. KOMOJU も「return_url と Webhook の二段構え」が前提
弊社の案件でもよく登場する KOMOJU はどうかというと、こちらも考え方は近いです。
5-1. Hosted Page / セッション方式のざっくり流れ
KOMOJU の Hosted Page Integration Guide では、だいたいこんな流れが書かれています。
- 自サーバから KOMOJU API に Session を作成し、
return_urlを指定 - ユーザーを KOMOJU の
session_urlにリダイレクト - 支払い完了 or キャンセル時に、KOMOJU が
return_urlにsession_idを付けてリダイレクト - 自サーバは
session_idを使って決済ステータスを取得
さらに日本語ドキュメントでは、
- 決済完了後、
return_urlにリダイレクトされる - 同時に
payment.capturedという Webhook が送られてくる - アプリケーション側は Webhook を処理して支払いを確定として記録する必要がある
と明記されています。
5-2. KOMOJU 公式の参考 URL (読み物メモ)
あとから自分で見返す用のメモも兼ねて貼っておきます。
https://ja.doc.komoju.com/docs/payment-details
https://ja.doc.komoju.com/docs/webhooks
https://doc.komoju.com/docs/hosted-page-integration-guide
https://doc.komoju.com/docs/creating-payments-directly
Stripe と同様に、「ユーザーを戻す return_url」と「サーバ通知(Webhook)」の二本立てが前提であり、
ビジネスロジックは Webhook 側で完結させるべきという思想はかなり近いです。
6. NGパターン:/complete で重要処理をやってしまう実装
やってはいけないアンチパターンを、雑に PHP っぽい疑似コードで書いてみます。
// /complete アクション(アンチパターン例)
public function complete(Request $request)
{
// 決済結果をセッションやクエリから取得
$paymentResult = $request->get('payment_result');
if ($paymentResult->isSuccess()) {
// ここで初めて注文を作成
$order = $this->orderService->createOrder($paymentResult);
// もしくは既存注文のステータスを更新
// $order = $this->orderService->updateOrderStatus($paymentResult);
// 在庫を減らす
$this->stockService->allocate($order);
// 注文メールを送信
$this->mailService->sendOrderMail($order);
// ポイント付与やチケット発行など
$this->rewardService->givePoints($order);
$this->ticketService->issueTickets($order);
}
return $this->render('order/complete.html.twig', [
'order' => $order ?? null,
]);
}
この構造だと、だいたいこうなります。
- ユーザーが complete に到達しなかった場合 → 何も起きない
- /complete をリロード or 直リンクで複数回叩かれた場合 → 二重注文・二重在庫引当のリスク
- テンプレートや JS のバグが、そのまま決済コアに直結する
実際、途中参画した案件でこれにかなり近い構造を見かけて、
「これを本番でずっと回していたのか...!? 今までどうやって事故を避けてきたんだ...?」
と、ちょっと焦りました...。
7. OKパターン:Webhook で完結させて、/complete は「表示専用」にする
では、どういう設計にしておくと安心できるか。
ここからは KOMOJU を例に、「Webhook 側に処理を寄せる」構成をざっくり擬似コード付きで書いてみます。
- 決済後処理は Webhook(サーバ側イベント)で完結させる
-
/completeはあくまで「結果を見せるだけ」の画面にとどめる
という構成です。
※ コードはあくまで「構造のイメージ」を伝えるためのサンプルと考えてください!
※ 実運用ではこの他にもいろいろ考慮が必要です!
KOMOJU の Webhook 仕様そのものは公式ドキュメントにまとまっているので、細かい仕様はそちらを参照ください。
- Webhooks(イベント一覧・ヘッダ・署名・リトライなど)
7-1. Webhook 側(バックエンド):決済後処理を集約する
まずは KOMOJU からの Webhook を受ける側です。
KOMOJU では payment.captured(決済完了)や payment.authorized(仮売上)など、
支払いステータスの変化ごとにイベントが飛んできます。
ざっくりイメージとしてはこんなコントローラになります。
<?php
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* KOMOJU Webhook 受信用コントローラ(擬似コード)
*
* 実際のプロダクションコードでは、ここに加えて:
* - 詳細な例外ハンドリング
* - DB トランザクション制御
* - 監視・メトリクス連携
* などが必要になります。
*/
final class KomojuWebhookController
{
public function __construct(
// 実際には .env / 設定ファイルから DI 経由で渡す想定
private string $webhookSecret,
private OrderService $orderService,
private LoggerInterface $logger,
) {}
public function __invoke(Request $request): Response
{
$payload = $request->getContent(); // 生のボディ(再エンコードしない)
$signature = $request->headers->get('X-Komoju-Signature', '');
$eventName = $request->headers->get('X-Komoju-Event', '');
// 1) 署名検証(SHA-256 HMAC)
$expected = hash_hmac('sha256', $payload, $this->webhookSecret);
// タイミング攻撃対策として、必ず時間一定な比較関数を使う
if (!hash_equals($expected, $signature)) {
$this->logger->warning('KOMOJU webhook signature verification failed', [
'expected' => $expected,
'signature' => $signature,
]);
// 署名が不正な場合は 400 を返し、「リトライ不要」と伝える
return new Response('Bad Request', Response::HTTP_BAD_REQUEST);
}
// 2) JSON パース
try {
/** @var array{id:string,type:string,data:array} $event */
$event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
$this->logger->error('KOMOJU webhook JSON parse error', [
'exception' => $e,
]);
return new Response('Bad Request', Response::HTTP_BAD_REQUEST);
}
// 3) 想定しているイベントタイプのみ処理する(ホワイトリスト)
if ($event['type'] !== 'payment.captured') {
return new Response('OK', Response::HTTP_OK);
}
// data.id が KOMOJU 側の payment ID
$paymentId = $event['data']['id'] ?? null;
if (!is_string($paymentId)) {
$this->logger->error('KOMOJU webhook missing payment id', [
'event' => $event,
]);
return new Response('Bad Request', Response::HTTP_BAD_REQUEST);
}
// 4) 冪等性チェック:同じ payment を二重処理しない
if ($this->orderService->existsForKomojuPaymentId($paymentId)) {
// すでに受注作成済みなら、何もせず 200
return new Response('OK', Response::HTTP_OK);
}
// 5) ここで「決済成功後に必要なこと」をすべて完結させる
$this->orderService->handleCapturedPaymentFromKomoju($event['data']);
return new Response('OK', Response::HTTP_OK);
}
}
ここでやりたいことをざっくり言うと:
/completeではなく Webhook 側にビジネスロジックを寄せる- 署名検証・イベントタイプのホワイトリスト・冪等性チェックを入口で済ませておく
という二点です。
大事なのは、決済が成功した時点で、画面とは無関係に受注・在庫・メールが完結しているという状態にしておくことです。
7-2. Webhook のレスポンスは「即時リターン+裏側処理」にする
決済システム連携でよくハマるのが、Webhook 内で欲張りすぎてタイムアウトするパターンです。
決済システム側には、通常は数秒以内に
200 OKを返す必要がある
(タイムアウトすると「失敗」とみなされたり、リトライが多発する)
KOMOJU の Webhook ドキュメントでも、
- 正常なステータスコードを返さない場合や通信に失敗した場合は、最大 25 回リトライ
- リトライ間隔は徐々に長くなり、最終的には初回送信から 25 日後で打ち切り
と書かれています。
そのため Webhook 内で、
- 大量の外部 API 呼び出し
- 重いバッチ処理や大規模 CSV 出力
- 画像変換や動画エンコード
などを全部同期でやってしまうと、シンプルに危険です。
この点については、別記事で EC-CUBE / Symfony の KernelEvents::TERMINATE を使った「レスポンス後の後処理」パターンをまとめています。
EC-CUBEで「KernelEvents::TERMINATE」を利用して重たい処理を裏で処理したい
https://qiita.com/Alfredo/items/e0ea75590e96fbf8a173
ここで書いている通り、
- Webhook 受信時には まず数秒以内に
200 OKを返すことを最優先 - 後続の重い処理は
KernelEvents::TERMINATE- Symfony Messenger / キュー(SQS, RabbitMQ など)
- ワーカー・バッチ
に逃がす、という二段構成にしておくと安定します。
本記事の主題は /complete に依存しない設計ですが、
- 「決済結果は KOMOJU の Webhook で受ける」
- 「Webhook は即時に 200 を返し、後続処理は裏に投げる」
というセットで考えておくと、現場での事故率がだいぶ下がります。
7-3. /complete 側(フロント):結果を「見せるだけ」に徹する
一方で、ユーザーがブラウザでアクセスする /complete 側は「表示専用」に割り切ります。
<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class OrderCompleteController
{
public function __construct(
private OrderRepository $orderRepository,
) {}
public function __invoke(Request $request): Response
{
// セッションやクエリパラメータから注文番号を特定
$orderNumber = $request->get('order_no');
// すでに Webhook 側で作成済みの注文を取得
$order = $this->orderRepository->findByOrderNumber($orderNumber);
// 「見つからない」ケースの扱いはプロジェクト依存ですが、
// ここではあくまで「表示」の責務にとどめます。
return $this->render('order/complete.html.twig', [
'order' => $order,
]);
}
private function render(string $template, array $params): Response
{
// 実際にはフレームワークのレンダラーに委譲する想定
}
}
ここで意図しているのは、
-
/completeでは DB への書き込みや外部 API 呼び出しを一切しない - ユーザーが
/completeに到達しなくても、決済と注文処理は Webhook 側だけで完結している -
/completeのテンプレートを壊して 500 を返したとしても、「お金だけ取られて注文がない」状態にはならない
という構造にしておくことです。
7-4. 実運用でほぼ必ず出てくる細かい話
上のコードは「責務の分離」を見せるための最低限の例で、実運用だともう少しディテールが増えます。
たとえば、次のようなところはだいたい必要になります。
- DB トランザクション
- 受注作成・在庫引当・ポイント付与などを 1 トランザクションにまとめる
- 冪等性の実装
- KOMOJU の
payment.idにユニーク制約を張る - 「すでに同じ ID があれば INSERT しない」などのガード
- KOMOJU の
- エラー時の扱い
- 署名不正(永続的エラー)は 400 で即終了
- 一時障害(DB ダウン等)は 500 / 503 を返し、KOMOJU 側のリトライに期待する
- 設定値の扱い
- Webhook シークレットや API キーは .env + 設定クラス + DI で取り回す
- 重い処理の逃し先
- EC-CUBE / Symfony なら
KernelEvents::TERMINATEや Messenger キューなど
- EC-CUBE / Symfony なら
このあたりはプロジェクトごとに作り込みが変わるので、この記事では「ここまでやれるとだいぶ安心」といったレベル感で留めておきます。
8. 非同期決済(コンビニ払い・銀行振込)では complete 依存がさらに危険
ここまではカード決済中心の話でしたが、
コンビニ払い・銀行振込・Pay-easy・後払いなどの非同期決済が入ってくると、complete 依存はほぼ成り立ちません。
- 決済完了=ユーザーがその場で完了させる、とは限らない
- KOMOJU のように「決済が完了したタイミングで
payment.capturedWebhook が飛んでくる」設計のサービスも多い
この場合、
-
/completeは「申込受付」レベルの画面に過ぎない - 本当の「支払完了」は、後から飛んでくる Webhook / API によって確定される
という前提になります。
したがって、なおさら complete 画面からビジネスロジックを排除する ことが重要です。
非同期決済をまともに扱おうとすると、/complete に処理を書くメリットはほぼ消えます。
9. ログ・冪等性・監視まで含めた実務的なポイント
実際の案件で「あとから自分の首を絞めない」ために、最低限おさえておきたいポイントもメモしておきます。
9-1. フロントとバックで共通の「キー」を決める
たとえば次のような共通キーを 1 つ決めておくと楽です。
-
payment_intent_id(Stripe) -
session_id(KOMOJU Hosted Page) - 自前の
order_token/checkout_tokenなど
このキーを、
-
/completeではそれを受け取り、DB から注文を引いて表示する - Webhook 側ではそれに紐づく注文作成・更新を行う
という両方で使うようにしておくと、ログ解析や障害調査がかなりやりやすくなります。
9-2. Webhook は必ず冪等にしておく
- 同じイベントが何度も送られてくる前提で設計する(Stripe / KOMOJU はリトライする)
- PaymentIntent ID や payment ID をキーに、「既に処理したか」をチェックする
- 中途半端な状態を残さないようにトランザクションを張る
といいった設計を入れておくと、「謎の二重注文・謎の二重在庫」のような事故を避けやすくなります。
9-3. 監視とアラートも最初からセットで
最低限、このあたりは見られるようにしておくと安心でと考えています!
- Webhook エンドポイントの 5xx / タイムアウト率
- 決済サービス側から見た「Webhook 配信失敗アラート」
- 「決済は成功しているのに注文が無い」件数(外部管理画面との突合)
complete 画面の障害とは切り離して、決済まわりの健全性を見られるようにしておくと、運用時のストレスがかなり違います...!
10. 既存 EC-CUBE 等をリファクタリングするときの「とりあえずこれだけは見る」チェック
EC-CUBE のような既存パッケージを触るとき、私ならまずここを見ます!
というチェックを挙げておきます。
-
/complete にビジネスロジックが書かれていないか?
- ここだけは最初に見るポイントです
- 受注作成・在庫引当・メール送信・ポイント・チケット発行 などが complete に入っていないか
-
決済成功の一次ソースはどこになっているか?
- 信じてよい情報源が「決済システムからの通知」になっているか
- Webhook / API のステータス取得を一次ソースにしているか
-
success_url/return_urlだけに頼っていないか
-
セッション前提になっていないか?
-
/completeを直叩きすると落ちる、あるいは挙動が変わるような実装になっていないか
-
-
非同期決済にも耐えられる構造か?
- コンビニ払い・銀行振込など、「後から支払完了が飛んでくる」決済手段を追加できる設計になっているか
-
画面とロジックの責務分離ができているか?
- コントローラ/サービス層のどこまでが「決済結果処理」で、どこからが「画面表示」なのかが、ぱっと見でわかるかどうか
-
Webhook が冪等で、かつ監視しやすいか?
- 同じイベント再送で INSERT が二重に走るような構造になっていないか
- エラー時のログが追いやすいいか、アラートにつながるようになっているか
このあたりをさらっとレビューするだけでも、「これは今のうちに直しておいた方がいい」箇所がだいたい浮き上がってきます。
11. まとめ:画面が開かれなくても、決済と業務が回るようにしておく
最後にもう一度だけ、言いたいことを一文にまとめると、
購入完了画面は「ユーザーに結果を見せる場所」であって、
決済後の重要処理を実行する場所ではない。
ということです。
Stripe も KOMOJU もスタンスはほぼ同じで、
- 決済の一次情報源は Webhook / API
- リダイレクト先の
success_url/return_urlは、あくまで「画面用のおまけ」
くらいの立ち位置で捉えておくと、安全側に倒せます。
一方で、現実の運用では「Webhook が遅れて飛んでくる/一時的に届かない」といったケースもあります。
そのため、実装によっては Webhook と return_url の両方から同じ「決済後処理ユースケース」をキックし、payment_id 等をキーに「先に来た方だけが実処理を行い、後から来た方は冪等チェックで何もしない」 という二重トリガー構成を取ることもあります。
ここで守りたいポイントは変わりません。
- ビジネスロジックの重心はあくまでサーバ側イベント(Webhook / API)に置く
-
return_urlからキックする処理も「画面表示」とは分離し、ユースケースとして共通化する - Webhook/
return_urlのどちらが先行しても整合性が崩れないよう、冪等性とキー設計をきちんとしておく
新しい実装でもレガシー改修でも、設計レビューのときに、
- 「ユーザーが complete を一度も開かなくても業務が成立するか?」
- 「complete 側のコードを全部コメントアウトしても、決済とデータは壊れないか?」
- 「Webhook と
return_urlのどちらか一方だけ飛んだ/片方が遅延した場合でも、最終的に一貫した状態に落ち着くか?」
という 3 つの問いに「はい」と言える状態になっているかどうかを、ひとつのゴールラインにしておくと、
将来の自分やチームがだいぶ楽になると思います。