オンライン決済実装研究所
実装ガイド

Stripe で決済機能を実装する手順をサンプルコード付きで追う

Checkout Sessions と Webhook を使って、商品選択から決済完了反映までを Node.js サンプルコード付きで段階的に説明します。

この記事の要点

最初の実装は hosted checkout がいちばん事故りにくいです。金額は必ずサーバーで決め、注文確定は Webhook を正とし、フロントはリダイレクトと結果表示に責務を絞ると安定します。

公開日: 2026/4/12 更新日: 2026/4/12 著者: オンライン決済実装研究所 編集部

結論

Stripe を初めて組み込むなら、Checkout Sessions + Webhook の組み合わせがいちばん事故りにくいです。

フロントは「決済ページへ飛ばす」、サーバーは「金額を決めてセッションを発行する」、Webhook は「最終的な注文確定を反映する」と責務を分けてください。

全体フロー

1. ユーザーが購入ボタンを押す
2. 自社サーバーが注文IDを作る
3. サーバーが Stripe Checkout Session を作る
4. ブラウザを Stripe Checkout へリダイレクトする
5. 決済完了後、ユーザーは success_url に戻る
6. Stripe から webhook が届く
7. webhook を正として注文を paid に更新する

この構成なら、ユーザーが success ページを閉じても、Webhook さえ受けられれば内部状態を正しく保てます。

Step 1: サーバー側に商品マスターと注文モデルを置く

最初にやるべきことは、金額をクライアントに決めさせない ことです。

type Plan = {
  name: string;
  amount: number;
  currency: 'jpy';
};

const PLANS: Record<string, Plan> = {
  starter: { name: 'Starter', amount: 1200, currency: 'jpy' },
  pro: { name: 'Pro', amount: 3500, currency: 'jpy' }
};

さらに、自社 DB 側では最低限これだけ持っておくと後で楽です。

カラム用途
id自社の注文 ID
statuspending paid failed refunded など
amount自社で確定した請求額
currency通貨
stripeSessionIdCheckout Session との対応付け
stripePaymentIntentId後で追跡するための参照
paidAt反映時刻

Step 2: Checkout Session を作る API を用意する

以下は Node.js + Express を想定した最小構成です。
フレームワークは違っても、考え方は同じです。

import express from 'express';
import Stripe from 'stripe';

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

app.post('/api/checkout/session', express.json(), async (req, res) => {
  const { planId, userId } = req.body;
  const plan = PLANS[planId];

  if (!plan) {
    return res.status(400).json({ error: 'invalid_plan' });
  }

  const order = await db.orders.insert({
    userId,
    planId,
    amount: plan.amount,
    currency: plan.currency,
    status: 'pending'
  });

  const session = await stripe.checkout.sessions.create(
    {
      mode: 'payment',
      line_items: [
        {
          price_data: {
            currency: plan.currency,
            unit_amount: plan.amount,
            product_data: {
              name: plan.name
            }
          },
          quantity: 1
        }
      ],
      success_url: `${process.env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.APP_URL}/checkout/cancel`,
      client_reference_id: order.id,
      metadata: {
        orderId: order.id
      }
    },
    {
      idempotencyKey: `checkout-session:${order.id}`
    }
  );

  await db.orders.update(order.id, {
    stripeSessionId: session.id
  });

  return res.json({ url: session.url });
});

ポイントは3つです。

Step 3: フロントは API を叩いてリダイレクトするだけにする

const buy = async (planId: string) => {
  const response = await fetch('/api/checkout/session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ planId, userId: currentUser.id })
  });

  const data = await response.json();

  if (!response.ok || !data.url) {
    throw new Error('checkout_session_create_failed');
  }

  window.location.assign(data.url);
};

フロントでやるべきことはここまでです。
金額計算、割引適用、注文確定まで全部フロントでやる設計は壊れやすくなります。

Step 4: success ページは「結果表示」に徹する

success ページでは、session_id を受け取って注文内容を表示して構いません。
ただし、success ページが表示されたことをもって受注確定にしない でください。

理由は単純で、ユーザーが戻り先に来ないケースがあるからです。
Stripe 公式も、支払いの履行は Webhook を使う前提で説明しています。

Step 5: Webhook で注文確定を反映する

Webhook は raw body のまま署名検証する必要があります。
JSON にパースした後では署名検証に失敗します。

Express では、Webhook ルートだけ express.raw() を使い、共通の JSON parser を先に当てない のが基本です。

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

app.post(
  '/api/stripe/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'];

    let event: Stripe.Event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        signature as string,
        endpointSecret
      );
    } catch (error) {
      return res.status(400).send('invalid_signature');
    }

    if (
      event.type === 'checkout.session.completed' ||
      event.type === 'checkout.session.async_payment_succeeded'
    ) {
      const checkoutSession = event.data.object as Stripe.Checkout.Session;
      const orderId = checkoutSession.metadata?.orderId;

      if (!orderId) {
        return res.status(400).send('missing_order_id');
      }

      await db.transaction(async () => {
        const order = await db.orders.findById(orderId);
        if (!order || order.status === 'paid') return;

        await db.orders.update(orderId, {
          status: 'paid',
          stripePaymentIntentId: String(checkoutSession.payment_intent ?? ''),
          paidAt: new Date()
        });
      });
    }

    if (event.type === 'checkout.session.async_payment_failed') {
      const checkoutSession = event.data.object as Stripe.Checkout.Session;
      const orderId = checkoutSession.metadata?.orderId;

      if (orderId) {
        await db.orders.update(orderId, { status: 'failed' });
      }
    }

    return res.json({ received: true });
  }
);

ここで重要なのは、同じイベントが再送されても壊れないこと です。
Webhook は一回だけ届く前提で組んではいけません。

Step 6: ローカルでテストする

Stripe CLI を使うとローカルでかなり確認しやすくなります。

stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger checkout.session.completed

CLI の listen で出た whsec_...STRIPE_WEBHOOK_SECRET に使ってください。
Dashboard のエンドポイント用 secret と混ぜると、署名検証が通らなくなります。

つまづきポイント

1. クライアントから送られた金額をそのまま使う

これは最初にやりがちな事故です。価格改ざんにも、プラン不整合にも弱くなります。

2. success ページで受注確定してしまう

戻り先ページは補助導線です。確定の責務は Webhook に寄せてください。

3. express.json() を webhook より先に適用する

Stripe は raw body を要求します。
Express では middleware の順番がずれるだけで署名検証が失敗します。
Webhook ルートは route-specific に express.raw() を使うと事故が減ります。

4. 遅延決済のイベントを見ない

カードだけなら目立ちませんが、銀行振込など即時でない手段を有効にすると checkout.session.async_payment_succeeded を見ない設計は危険です。

5. テスト鍵と本番鍵を混ぜる

sk_test_sk_live_ の混在は非常に多いです。
Webhook secret も test と live で別物です。

6. 内部注文 ID と Stripe オブジェクトを紐づけない

metadata.orderId がないと、本番障害時にダッシュボードと自社 DB を横断して追うのが難しくなります。

Checkout から Payment Element に進むタイミング

次の要件が出たら、Payment Element への移行を考える価値があります。

逆に、最初の公開段階では無理に移らなくて構いません。

併読推奨

よくある質問

初学者は Payment Element から始めるべきですか?

最初の1本なら Checkout の方が安全です。要件が固まってから Payment Element へ進んでも遅くありません。

成功画面に戻ってきたら注文確定して大丈夫ですか?

それだけでは不十分です。注文確定の正は Webhook に寄せてください。

Webhook で何のイベントを拾えばよいですか?

まずは checkout.session.completed を基準にし、遅延決済を使うなら checkout.session.async_payment_succeeded も見ます。

次に読む記事

実装比較

Stripe Checkout と Payment Element の違いを実装目線で比較する

Stripe Checkout と Payment Element の違いを、導入速度、UI自由度、保守運用、失敗しやすさの4軸で整理します。

最短で公開したいなら Checkout、ブランド体験や独自フローを強く作りたいなら Payment Element です。判断を4軸に固定すると、案件ごとの迷いがかなり減ります。

2026/4/10 Stripe / Checkout / Payment Element
実装ガイド

PaymentIntent の状態遷移を実装事故ベースで理解する

PaymentIntent の状態遷移を、作成、認証、確定、失敗、再試行の流れに沿って整理します。Webhook 連携の観点にも触れます。

PaymentIntent は単なる決済オブジェクトではなく、決済の途中状態を扱う設計の中心です。状態遷移を理解しておくと、二重計上や webhook の処理漏れをかなり防げます。

2026/4/10 Stripe / PaymentIntent / Webhook
障害運用

Stripe Webhook で起きやすい失敗を運用視点で10個に整理する

Stripe Webhook で起きやすい失敗を、署名検証、再送、冪等性、順不同イベント、監視不足の観点から整理します。

Webhook障害の多くは、イベントを一回だけ来る前提で処理していることが原因です。署名、保存、冪等性、再処理導線までを最初から含めると、後の運用が大きく楽になります。

2026/4/10 Stripe / Webhook / 冪等性