Next.js × Rails|Stripe Connect によるショップのオンボーディング実装
前回は、ステートレスなカート機能の実装について解説しました。
カートに商品を入れられるようになったら、
次に必要なのは 決済処理 です。
今回の自作アプリでは、決済に Stripe を採用しています。
お金が動かないテストモードを使った実装を解説します。
今回のゴールは、Stripe Connectを使って、
ショップオーナーが登録、更新できるようにすることです。
Stripeにはさまざまな機能がありますが、このアプリでは大きく次の 2つ を使用しています。
1つ目:Stripe Connect(マーケットプレイス向け)
プラットフォーム運営者として、
出品者(ショップオーナー)にStripeアカウントを登録してもらい、
アプリ側で管理しつつ、手数料を取る仕組みを Stripe Connect で実現しています。
本記事では、この Stripe Connect の仕組みと実装の流れ を中心に解説します。
2つ目:Stripe Checkout(購入者向け決済)
ユーザーがカートの商品を購入できるように、
決済画面をStripeに委譲する Stripe Checkout を利用しています。
Checkoutの実装については、次回の記事で詳しく取り上げます
バージョン情報
フロントエンド
- TypeScript 5.9.2
- React 19.1.0
- Next.js 15.5.0
- Auth.js 5.0.0-beta.29
- TailwindCSS 4.1.12
バックエンド
- Ruby 3.3.9
- Rails 8.0.2.1
- Postgresql 16.10
- Docker 28.4.0
Stripeとは
Stripe は、オンライン決済を簡単に導入できるサービスです。
クレジットカードやApple Payなどの支払いを、
開発者が複雑な金融処理を気にせずに実装できます。
セキュリティや決済の承認・失敗処理、
請求書の作成、決済画面のホスティングなど、
お金のやり取りに関する面倒な部分を Stripe がすべて肩代わりしてくれるため、
ECサイト・サブスク・寄付サイトなど幅広い場面で利用されています。
Stripe Connectとは
Stripe Connect は、
「複数の販売者が存在するプラットフォーム向け」の決済ソリューションです。
ECモールやスキルマーケットのように、出品者(ショップオーナー)へ売上を分配したり、
プラットフォーム側が手数料を自動で差し引く仕組みを、
ほぼコードだけで構築できます。
出品者ごとにStripeアカウントを紐付けたり、
決済後の売上を自動送金したりと、
通常は金融業務レベルの処理を、
シンプルなAPIで安全に実現できるのが特徴です。
RailsのStripeの導入
gem "stripe", "~> 17.0"
stripeのgemを入れて、
コンテナをbuildし直します。
Rails.application.routes.draw do
namespace :api do
# 略
resource :stripe_accounts, only: [ :create ]
resource :stripe_webhooks, only: [ :create ]
end
end
stripe_accountsのcreateと、
stripe_webhooksのcreateを作ります。
stripe_accounts という名前にしているのは、
Stripe の “Account(接続アカウント)” を Rails 側で扱うためのエンドポイントだからです。
ショップオーナーが Stripe アカウントを登録したり、更新したりする処理を担当します。
Rails から Stripe に情報を送るのは通常の API 呼び出しで、
Stripe が処理した結果を Rails に通知してくれる仕組みが Webhook です。
api/stripe_accounts_controller.rbの解説
class Api::StripeAccountsController < ActionController::API
include RailsJwtAuth
def create
shop = current_user.shop
return render json: { error: "Shop not found" }, status: :not_found unless shop
base_url = ENV["NEXT_URL"]
return render json: { error: "NEXT_URL is not set" }, status: :unprocessable_entity unless base_url.present?
begin
account_id = shop.stripe_connect_account_id
unless account_id.present?
account = Stripe::Account.create({
type: "express",
country: "JP",
capabilities: {
card_payments: { requested: true },
transfers: { requested: true }
}
})
shop.update!(stripe_connect_account_id: account.id)
account_id = account.id
end
account = Stripe::Account.retrieve(account_id)
# 常にStripeの最新状態で判定(DBのラグに依存しない)
onboarded = account.charges_enabled || account.details_submitted
if onboarded
login_link = Stripe::Account.create_login_link(account_id)
render json: { login_url: login_link.url }, status: :ok
else
account_link = Stripe::AccountLink.create({
account: account_id,
refresh_url: "#{base_url}/dashboard/shop#refresh",
return_url: "#{base_url}/dashboard/shop/refresh",
type: "account_onboarding"
})
render json: { onboarding_url: account_link.url }, status: :ok
end
rescue Stripe::StripeError => e
Rails.logger.error("Stripe error: #{e.message}")
render json: { error: "Stripe連携エラー: #{e.message}" }, status: :unprocessable_entity
end
end
end
解説します。
class Api::StripeAccountsController < ActionController::API
include RailsJwtAuth
def create
RailsJwtAuthをincludeすることで、
ログインしたユーザーしか処理できないようにします。
認証の詳細はこちら
createを定義します。
このコントローラーは、createだけです。
shop = current_user.shop
return render json: { error: "Shop not found" }, status: :not_found unless shop
今ログインしているユーザーのショップを取ってshopに代入します。
shopが取れなかったらjsonを返して処理を終わらせます。
base_url = ENV["NEXT_URL"]
return render json: { error: "NEXT_URL is not set" }, status: :unprocessable_entity unless base_url.present?
.envにフロントエンドのNEXT_URLを環境変数として設定しています。
base_urlに代入します。
取れなければフロントに422を返します。
begin
beginを使って、例外がある場合下の方でキャッチします。
account_id = shop.stripe_connect_account_id
shop.stripe_connect_account_idを取得します。
最初は存在しないので、下のunlessが実行されます。
2回目以降は存在するので、unlessが実行されません。
unless account_id.present?
account = Stripe::Account.create({
type: "express",
country: "JP",
capabilities: {
card_payments: { requested: true },
transfers: { requested: true }
}
})
shop.update!(stripe_connect_account_id: account.id)
account_id = account.id
end
初回はaccount_idが取れないので実行されます。
Stripe::Account.create()を使って、
ショップオーナー用のアカウントを作ります。
typeはexpressを指定します。
マーケットプレイスを作る場合はexpressを使います。
countryにJPを指定します。
capabilitesは何ができるようになるかを設定します。
card_paymentsのtrueはカードによる支払いを有効化します。
transfersのtrueは、Stripeがお金を受け取った後、
ショップオーナーに送金できるようにします。
Stripe::Accountのインスタンスが返ってくるので、
をaccountに代入します。
shop.update!(stripe_connect_account_id: account.id)
account_id = account.id
end
account.idに作られたStripeのアカウントのidがあるので
自分のDBのshopの情報をアップデートします。
2回目以降は新規作成せずに、
このstripe_connect_account_idを取得して使うので、
新規作成はされません。
account = Stripe::Account.retrieve(account_id)
account_idをStripe::Account.retrieve()に渡して、
Stripe::Accountのインスタンスが返ってくるので受け取ります。
# 常にStripeの最新状態で判定(DBのラグに依存しない)
onboarded = account.charges_enabled || account.details_submitted
これはオンボーディングが完了しているかのフラグです。
DBにもオンボーディングのフラグがありますが、
Stripeの方のものを使います。
account.details_submittedは、詳細を記入したけど、審査待ちです。
account.charges_enabledは、審査が終了し、入金可能になっている状態です。
どちらかがtrueならonboardedはtrueになります。
if onboarded
login_link = Stripe::Account.create_login_link(account_id)
render json: { login_url: login_link.url }, status: :ok
else
account_link = Stripe::AccountLink.create({
account: account_id,
refresh_url: "#{base_url}/dashboard/shop#refresh",
return_url: "#{base_url}/dashboard/shop/refresh",
type: "account_onboarding"
})
render json: { onboarding_url: account_link.url }, status: :ok
end
onboardedがtrueなら、
expressのダッシュボードにログインできるリンクを作って、
ログインリンクをフロントに返します。
ダッシュボードで動いた金額を見たり、
ショップオーナーの情報をアップデートしたりできます。
onboardedがfalseなら、
account_idを元に、
オンボーディングの新規のリンクを作ってフロントに返します。
typeはaccount_onboardingを指定します。
refresh_urlは、途中でエラー・キャンセルしたときに戻ってくるURLです。
return_urlは、オンボーディング完了後に戻ってくるURLです。
rescue Stripe::StripeError => e
Rails.logger.error("Stripe error: #{e.message}")
render json: { error: "Stripe連携エラー: #{e.message}" }, status: :unprocessable_entity
end
途中で例外でStripe::StripeErrorが発生した場合
ログに記入して、エラーをフロントに返します。
Next.jsのStripeボタン
"use client";
import { Button } from "@/components/ui/button";
export default function StripeButton() {
return (
<Button
className="w-full sm:w-auto"
onClick={async () => {
const res = await fetch("/api/stripe_accounts", {
method: "POST",
cache: "no-store",
});
if (!res.ok) return;
const data = await res.json();
const url = data.onboarding_url || data.login_url;
if (url) window.location.href = url;
}}
>
<span className="font-bold">Stripe連携</span>
</Button>
);
}
src/app/dashboard/shop/StripeButton.tsxの中身です。
このボタンをショップのダッシュボードに設置しているので、
これを押すことで、Stripeに飛びます。
"use client";
クライアントコンポーネントとして扱います。
import { Button } from "@/components/ui/button";
ボタンをインポートします。
export default function StripeButton() {
return (
<Button
className="w-full sm:w-auto"
onClick={async () => {
const res = await fetch("/api/stripe_accounts", {
method: "POST",
cache: "no-store",
});
クリックするとBFFのProxyを経由して、
先ほどのstripe_accounts_controllerのcreateにアクセスします。
BFFのProxyの詳細はこちら
if (!res.ok) return;
const data = await res.json();
const url = data.onboarding_url || data.login_url;
if (url) window.location.href = url;
}}
>
<span className="font-bold">Stripe連携</span>
</Button>
);
}
resが取れなかった場合は中断します。
resを取れた場合、json()でオブジェクト化してdataに入れます。
Railsから帰ってきた、onboarding_urlかlogin_urlを
urlに代入して、そこへ飛びます。
これでストライプのオンボーディング画面か、
ログイン画面に飛べます。
Webhookとは
Webhook は、Stripe が処理したイベントを Rails に自動で通知してくれる仕組みです。
Stripe Connect では、
「アカウントの審査が進んだ」「情報が更新された」「支払いが成功した」
などの状態が Stripe 内で発生します。
サーバー側(Rails)はこれを自分で取りに行く必要はなく、
Stripe が POST リクエストとして Rails に送ってくることで状態を同期できます。
Rails → Stripe:通常の API でリクエスト
Stripe → Rails:Webhook で通知
このように Webhook は、Stripe 側の状態変化を受け取り、
DB を更新したり、フラグを変更するための必須の仕組みです。
RailsのStripe Webhookの処理
class Api::StripeWebhooksController < ActionController::API
# Stripe Webhook endpoint: verify signature and dispatch by event type
def create
payload = request.body.read
signature = request.env["HTTP_STRIPE_SIGNATURE"]
secret = ENV["STRIPE_WEBHOOK_SECRET"]
unless secret.present?
Rails.logger.warn("Stripe webhook secret is not set")
return head :bad_request
end
begin
event = Stripe::Webhook.construct_event(payload, signature, secret)
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
Rails.logger.warn("Stripe webhook error: #{e.message}")
return head :bad_request
end
case event.type
when "account.updated"
account = event.data.object
account_id = account["id"]
onboarded = account["charges_enabled"] || account["details_submitted"]
if (shop = Shop.find_by(stripe_connect_account_id: account_id))
# update_columnsでコールバックなし・高速反映
shop.update_columns(stripe_onboarded: onboarded)
else
Rails.logger.warn(
"[StripeWebhook] Shop not found for account_id=#{account_id}"
)
end
when "checkout.session.completed"
# 略
end
head :ok
end
end
stripe_webhooks_controller.rbの中身はこのようになっています。
class Api::StripeWebhooksController < ActionController::API
def create
コントローラの中身はcreateだけです。
payload = request.body.read
これはStripeがRailsへ送ったrequestの本体です。
この時点ではまだパースしていません。
signature = request.env["HTTP_STRIPE_SIGNATURE"]
StripeからRailsに送信したrequestの署名です。
本物であることを確認するためにつかいます。
secret = ENV["STRIPE_WEBHOOK_SECRET"]
これはStripeのダッシュボードから取ってきます。
送られてきた署名が本物であることを確認するのに使います。
unless secret.present?
Rails.logger.warn("Stripe webhook secret is not set")
return head :bad_request
end
secretがなければ、ログに記入して、returnします。
begin
event = Stripe::Webhook.construct_event(payload, signature, secret)
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
Rails.logger.warn("Stripe webhook error: #{e.message}")
return head :bad_request
end
beginの中で書いて、エラーがあった場合はrescueでキャッチします。
Stripe::Webhook.construct_event()に対して、
payload, signature, secretを渡します。
本物かどうか検証して、本物ならばパースしたものをeventにいれます。
失敗した場合は、ログに記入して、returnします。
case event.type
when "account.updated"
account = event.data.object
account_id = account["id"]
onboarded = account["charges_enabled"] || account["details_submitted"]
if (shop = Shop.find_by(stripe_connect_account_id: account_id))
# update_columnsでコールバックなし・高速反映
shop.update_columns(stripe_onboarded: onboarded)
else
Rails.logger.warn(
"[StripeWebhook] Shop not found for account_id=#{account_id}"
)
end
case文でevent.typeで場合分けします。
when “account.updated”の場合の処理をします。
オンボーディングが成功するとStripeはRailsに対してこれを送信します。
account = event.data.object
account_id = account["id"]
onboarded = account["charges_enabled"] || account["details_submitted"]
event.data.objectをaccountに代入します。
account[“id”]をaccount_idに代入します。
charges_enabledかdetails_submittedのどちらかがtrueならば、
onboardedをtrueにします。
if (shop = Shop.find_by(stripe_connect_account_id: account_id))
# update_columnsでコールバックなし・高速反映
shop.update_columns(stripe_onboarded: onboarded)
else
Rails.logger.warn(
"[StripeWebhook] Shop not found for account_id=#{account_id}"
)
end
account_idを使って、DBの該当するshopを見つけます。
もし見つかれば、
shopのstripe_onboardedカラムにtrueを挿入します。
見つからなければ、ログに記入します。
when "checkout.session.completed"
# 略
end
head :ok
end
end
こちらはStripe CheckoutのWebhookなので、
今回は割愛します。
最後にレスポンスとして、head :okを返します。
これでユーザーがオンボーディング完了した場合、
DBのオンボーディングフラグをtrueにする処理を、
Webhookを通じて作ることができました。
ローカル環境でWebhookを受け取る
Stripe の Webhook は、Stripe のサーバーから
Rails の /api/stripe_webhooks に向けてHTTPリクエストを送る仕組みです。
しかし通常、ローカル環境は外部から直接アクセスできないため、
Stripe の通知をそのまま受け取ることはできません。
これを解決するのが、Stripe CLI の “トンネリング”(フォワード)機能です。
brew install stripe/stripe-cli/stripe
brewを使ってStripe CLIをインストールします。
stripe login
Stripeにログインします。
stripe listen --forward-to localhost:3000/api/stripe_webhooks
フォワード機能を使います。
listenすると、シークレットが返ってくるので、
これを、STRIPE_WEBHOOK_SECRETとして、envに入れます。
これはターミナルで、起動したままにすると、
その間にローカルでもWebhookを受信できます。
本番環境でWebhookを受け取る
ブラウザからStripeのサイトにログインして、
ダッシュボードに入ります。
プロジェクトがサンドボックスにテストモードとしてあるので、
モードをプロジェクトのものに切り替えます。

下に開発者ボタンがあるので押して、更にWebhookを選びます。
イベントの送信を追加します。

イベントのリッスン先は右側の、
連結アカウントとv2アカウントを選びます。
APIのバージョンはそのまま
イベントは
account.updatedを選びます。
次回のStripe Checkoutのために、
checkout.session.completedも選択しておきます。

Webhookエンドポイントを選びます。

名前をつけて、
エンドポイントのURLを入れて、
説明を記入して、送信先を作成します。
署名シークレットというのがあるので、
これをAWSなどのパネルから入れます。
AWSならばシークレットマネージャーがおすすめです。
設定が完了すると、
本番環境でWebhookを受け取れるようになります。
オンボーディング完了時のリフレッシュ
import { NextResponse, NextRequest } from "next/server";
import requireAuth from "@/lib/requireAuth";
import requireShopOrAdmin from "@/lib/requireShopOrAdmin";
export async function GET(request: NextRequest) {
await requireAuth();
await requireShopOrAdmin();
const res = await fetch(`${process.env.NEXT_URL}/api/shop`, {
method: "GET",
headers: {
cookie: request.headers.get("cookie") ?? "",
},
cache: "no-store",
});
const redirect = NextResponse.redirect(
new URL("/dashboard/shop", request.url)
);
const setCookie = res.headers.get("set-cookie");
if (setCookie) redirect.headers.set("set-cookie", setCookie);
return redirect;
}
オンボーディングが完了したときの処理です。
これをやることで、クッキーを更新します。
return_url: "#{base_url}/dashboard/shop/refresh",
Rails側でこのように指定したので、
オンボーディングが完了するとここに飛びます。
import { NextResponse, NextRequest } from "next/server";
import requireAuth from "@/lib/requireAuth";
import requireShopOrAdmin from "@/lib/requireShopOrAdmin";
next/serverからNextResponseとNextRequestをimportします。
自作のrequireAuthとrequireShopOrAdminもimportします。
export async function GET(request: NextRequest) {
await requireAuth();
await requireShopOrAdmin();
ログインして、かつロールを満たす必要があります。
const res = await fetch(`${process.env.NEXT_URL}/api/shop`, {
method: "GET",
headers: {
cookie: request.headers.get("cookie") ?? "",
},
cache: "no-store",
});
/api/shopにGETを送ります。
headersのcookieを明記しているのは、
今回はサーバーサイドからfetchしているからです。
client componentからのfetchは、
cookieは自動挿入ですが、
サーバーサイドからやる場合は、明記する必要があります。
const redirect = NextResponse.redirect(
new URL("/dashboard/shop", request.url)
);
NextResponse.redirect()からredirectを生成します。
const setCookie = res.headers.get("set-cookie");
こちらもサーバーサイドからやっているので、
手動でセットし直す必要があります。
headersからset-cookieを取ってきてsetCookieに代入します。
if (setCookie) redirect.headers.set("set-cookie", setCookie);
return redirect;
setCookieがtrueなら先ほど定義したredirectのheadersにセットします。
こうすることで、
オンボーディングが成功すると、クッキーを自動更新して
出品ボタンから商品の新規出品ができるようになります。
2回目以降のStripeボタンの挙動
1回目はオンボーディングに登録しますが、
2回目以降はログインします。
売上を見たり、ショップオーナーの情報の設定を更新できます。
まとめ
本記事では、Stripe Connect を使って
出品者のオンボーディングを実現する仕組みを解説しました。
- Rails 側では Stripe Account の作成・取得・状態判定を行い
- Next.js 側は BFF 経由で Rails を呼び出し
- Stripe 側で審査や情報更新が行われた場合は Webhook を通して Rails に通知
- 通知を受けて DB のフラグを更新し、出品機能を解放する
という一連のフローをコードベースで構築しました。
特に Stripe Connect は、
マーケットプレイス構築で避けて通れない複雑な送金処理を、
最小限のコードで安全に扱えるのが大きな強みです。
次回の記事では、
実際の決済処理を Stripe Checkout で実装する方法を詳しく解説します。
カートの内容を元に Rails 側で Checkout Session を生成し、
購入者が Stripe の画面で安全に支払えるようにする仕組みを紹介します。