Stripe で最低限のサブスクリプション機能を実装してみた
はじめに
こんにちは!PortalKey の渋谷です。
今回はサービスに課金機能を追加する必要があったので、Stripeを使ってサブスクリプション(定期課金)を実装しました。
Stripe には様々な機能がありますが、今回は最低限の実装で動かすことを目標に、以下のような方針で実装しました:
- ✅ Stripe 提供の決済画面を使う(Checkout Session & Customer Portal)
- ✅ バックエンドは最小限(決済画面の URL 生成と Webhook 受信のみ)
- ❌ 独自の決済画面は作らない
- ❌ 管理画面でのプラン管理は作らない
「とりあえず Stripe で課金を始めたい」という方の参考になればと思います。
開発環境
今回の実装環境は以下の通りです:
- 言語: Go 1.23
- ライブラリ: stripe-go v83
-
フレームワーク: 標準の
net/http(お好みのフレームワークで OK)
Go ではなく Node.js や Python を使う場合でも、基本的な考え方は同じです。
Stripe とは
Stripeは、オンライン決済を簡単に導入できるサービスです。
クレジットカード決済はもちろん、サブスクリプション(定期課金)やワンタイム決済など、様々な決済パターンに対応しています。
今回実装したサブスクリプションは、月額・年額などの定期的な支払いを自動で処理してくれる機能です。
実装の全体像
今回実装した機能は大きく分けて 3 つです:
1. Checkout Session(新規契約)
ユーザーがプランを選択すると、Stripe の決済画面に遷移します。
バックエンドは決済画面の URL を生成するだけです。
2. Customer Portal(契約管理)
契約後のプラン変更やキャンセルは、Stripe 提供の Customer Portal で行います。
これもバックエンドは URL を生成するだけ。
3. Webhook(イベント受信)
Stripe で決済が完了したり、サブスクリプションがキャンセルされたりすると、Webhook で通知が来ます。
これを受け取って DB を更新します。
Stripe Dashboard の設定
実装に入る前に、Stripe Dashboard でいくつか設定が必要です。
API キー

StripeClient を作る際に使用します。
環境変数などで設定できるよう控えておきましょう。(サンプルでは直接入れてます)
Product と Price の作成



- Stripe Dashboard > 商品カタログ > 商品を作成
- 名前、説明、価格を設定
- Recurring(定期課金)を選択し、月額 or 年額を設定
-
重要:
lookup_keyを設定(例:pro_monthly,business_plus_monthly)
lookup_key の重要性
lookup_keyを設定することで、テスト環境と本番環境で同じコードを使えるようになります。
Price ID は環境ごとに異なりますが、lookup_keyは自由に設定できるため:
- テスト環境:
price_test_xxxxx→lookup_key: "pro_monthly" - 本番環境:
price_live_yyyyy→lookup_key: "pro_monthly"
コードではlookup_keyを指定するだけで、環境に応じた正しい Price が自動で選ばれます。
Customer Portal の設定





- Stripe Dashboard > 設定 > Billing > カスタマーポータル
- テスト環境のリンクを有効化 をクリックして有効化(初期は無効)
- 顧客ができることを設定:
- プラン変更の許可(変更できるプランの選択)
Subscription の設定


- Stripe Dashboard > 設定 > Payments
- 顧客のサブスクリプションを1つに制限する を ON にする
これにより、1 つの Customer に対して複数の Subscription が作られることを防げます。
Webhook Endpoint の設定


-
Stripe Dashboard > 開発者 > Webhook > 送信先を追加する
-
イベントを選択:
-
checkout.session.completed- 決済完了 -
customer.subscription.created- サブスク作成 -
customer.subscription.updated- サブスク更新 -
customer.subscription.deleted- サブスクキャンセル
-
-
Endpoint URL を入力(例:
https://yourdomain.com/webhook/stripe) -
署名シークレット をコピーしておく(Webhook 検証に使用)
実装:Checkout Session の作成
ユーザーが「このプランを契約する」ボタンを押したときの処理です。
package main
import (
"context"
"log"
"github.com/stripe/stripe-go/v83"
)
// CheckoutSessionを作成して決済画面URLを返す
func CreateCheckoutSession(ctx context.Context, workspaceID string, priceLookupKey string, userEmail string) (string, error) {
// Stripe Clientの初期化
sc := stripe.NewClient("sk_test_...")
// DBからWorkspaceを取得
workspace := getWorkspace(workspaceID) // DB操作の例
var customerID string
// トランザクション内でCustomerの存在確認と作成
err := runInTransaction(func() error {
// Workspaceを再取得(ロックをかける)
workspace = getWorkspaceForUpdate(workspaceID)
if workspace.StripeCustomerID != "" {
// 既にCustomerが存在する場合はそれを使う
customerID = workspace.StripeCustomerID
return nil
}
// Customerを作成
customer, err := sc.V1Customers.Create(ctx, &stripe.CustomerCreateParams{
Email: stripe.String(userEmail),
Name: stripe.String(workspace.Name),
})
if err != nil {
return err
}
customerID = customer.ID
// DBに保存
workspace.StripeCustomerID = customerID
saveWorkspace(workspace)
return nil
})
if err != nil {
log.Printf("Failed to get or create customer: %v", err)
return "", err
}
// lookup_keyからPriceを取得
pricesIter := sc.V1Prices.List(ctx, &stripe.PriceListParams{
LookupKeys: []*string{stripe.String(priceLookupKey)},
})
var prices []*stripe.Price
for price, err := range pricesIter {
if err != nil {
log.Printf("Failed to list prices: %v", err)
return "", err
}
prices = append(prices, price)
}
if len(prices) != 1 {
log.Printf("Price not found for lookup_key: %s", priceLookupKey)
return "", errors.New("price not found")
}
priceID := prices[0].ID
// Checkout Session作成パラメータ
params := &stripe.CheckoutSessionCreateParams{
Customer: stripe.String(customerID),
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID), // Price IDを指定
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String("https://yourapp.com/success"),
CancelURL: stripe.String("https://yourapp.com/cancel"),
}
// Metadataに独自データを保存
params.Metadata = map[string]string{
"workspace_id": workspaceID,
}
// Checkout Session作成
session, err := sc.V1CheckoutSessions.Create(ctx, params)
if err != nil {
log.Printf("Failed to create checkout session: %v", err)
return "", err
}
return session.URL, nil // この URL にユーザーをリダイレクト
}
ポイント:
- トランザクション内で Customer 作成:同じワークスペースで複数の Customer が作られないよう、DB のトランザクション内で存在確認と作成を行う
-
lookup_key で Price を検索:
sc.V1Prices.List()でlookup_keyから Price ID を取得する。Price ID を直接ハードコードしなくて良いので、テスト環境と本番環境で同じコードが使える -
Metadataに独自データ(ワークスペース ID など)を保存できる - 返ってきた
session.URLにユーザーをリダイレクトすれば、Stripe の決済画面が表示される
実装:Customer Portal の作成
契約後のユーザーがプラン変更やキャンセルをする画面です。
package main
import (
"context"
"log"
"github.com/stripe/stripe-go/v83"
)
// Customer Portal のURLを作成
func CreateCustomerPortal(ctx context.Context, customerID string) (string, error) {
// Stripe Clientの初期化
sc := stripe.NewClient("sk_test_...")
// Customer Portal作成パラメータ
params := &stripe.BillingPortalSessionCreateParams{
Customer: stripe.String(customerID),
ReturnURL: stripe.String("https://yourapp.com/settings"),
}
// Customer Portal Session作成
session, err := sc.V1BillingPortalSessions.Create(ctx, params)
if err != nil {
log.Printf("Failed to create customer portal: %v", err)
return "", err
}
return session.URL, nil // この URL にユーザーをリダイレクト
}
ポイント:
-
customerIDが必要なので、初回決済時に Customer ID を DB に保存しておく -
ReturnURLでユーザーが戻ってくる先を指定
実装:Webhook の受信
Stripe からのイベント通知を受け取る処理です。
package main
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/stripe/stripe-go/v83"
"github.com/stripe/stripe-go/v83/webhook"
)
func HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
const MaxBodyBytes = int64(65536)
r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
// リクエストボディを読み取り
payload, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading request body: %v", err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
// Stripe署名を取得
signatureHeader := r.Header.Get("Stripe-Signature")
webhookSecret := "whsec_..." // Dashboardからコピーした Signing secret
// Webhookイベントを構築・検証
event, err := webhook.ConstructEvent(payload, signatureHeader, webhookSecret)
if err != nil {
log.Printf("Webhook signature verification failed: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
// イベントタイプごとに処理
switch event.Type {
case "checkout.session.completed":
handleCheckoutCompleted(event)
case "customer.subscription.created":
handleSubscriptionCreated(event)
case "customer.subscription.updated":
handleSubscriptionUpdated(event)
case "customer.subscription.deleted":
handleSubscriptionDeleted(event)
default:
log.Printf("Unhandled event type: %s", event.Type)
}
w.WriteHeader(http.StatusOK)
}
func handleCheckoutCompleted(event stripe.Event) {
var session stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
log.Printf("Error parsing webhook JSON: %v", err)
return
}
// CheckoutSessionからSubscription IDを取得
subscriptionID := session.Subscription.ID
// 共通処理を呼び出し
handleSubscriptionEvent(subscriptionID)
}
func handleSubscriptionCreated(event stripe.Event) {
var subscription stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil {
log.Printf("Error parsing webhook JSON: %v", err)
return
}
// 共通処理を呼び出し
handleSubscriptionEvent(subscription.ID)
}
func handleSubscriptionUpdated(event stripe.Event) {
var subscription stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil {
log.Printf("Error parsing webhook JSON: %v", err)
return
}
// 共通処理を呼び出し
handleSubscriptionEvent(subscription.ID)
}
func handleSubscriptionDeleted(event stripe.Event) {
var subscription stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil {
log.Printf("Error parsing webhook JSON: %v", err)
return
}
// 共通処理を呼び出し
handleSubscriptionEvent(subscription.ID)
}
// 全イベント共通のSubscription処理
func handleSubscriptionEvent(subscriptionID string) {
ctx := context.Background()
sc := stripe.NewClient("sk_test_...")
// StripeからSubscriptionを取得(常に最新の状態を取得)
subscription, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil)
if err != nil {
log.Printf("Failed to retrieve subscription: %v", err)
return
}
log.Printf("Processing subscription: %s, status: %s, customer: %s",
subscription.ID, subscription.Status, subscription.Customer.ID)
// DBに保存/更新
// saveOrUpdateSubscription(subscription)
}
ポイント:
-
Webhook 署名の検証は必須(
webhook.ConstructEventで自動検証) -
event.Typeでイベントの種類を判別 - Unmarshal の型だけ違う:CheckoutSession か Subscription か
- 処理は全イベント共通:Subscription ID を取得 → Stripe API で Retrieve → DB に保存
- 常に Stripe から最新データを取得:Webhook のペイロードをそのまま使わず、Retrieve で Stripe 側の最新状態を取得する
- エラーハンドリングをしっかり行う(署名検証失敗時は 400 を返す)
Subscription の Quantity(数量)を更新する
サブスクリプションの数量を更新する方法です。例えば、チームのメンバー数に応じて課金額を変更したい場合などに使います。
package main
import (
"context"
"log"
"github.com/stripe/stripe-go/v83"
)
// Subscriptionの数量を更新
func UpdateSubscriptionQuantity(ctx context.Context, subscriptionID string, newQuantity int64) error {
// Stripe Clientの初期化
sc := stripe.NewClient("sk_test_...")
// まずSubscriptionを取得してItem IDを確認
subscription, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil)
if err != nil {
log.Printf("Failed to retrieve subscription: %v", err)
return err
}
// Subscription Itemの更新(最初のItemを更新する例)
if len(subscription.Items.Data) == 0 {
log.Printf("No subscription items found")
return nil
}
itemID := subscription.Items.Data[0].ID
// 数量更新パラメータ
params := &stripe.SubscriptionUpdateParams{
Items: []*stripe.SubscriptionItemsParams{
{
ID: stripe.String(itemID),
Quantity: stripe.Int64(newQuantity),
},
},
ProrationBehavior: stripe.String("create_prorations"), // 日割り計算する
}
// Subscription更新
_, err = sc.V1Subscriptions.Update(ctx, subscriptionID, params)
if err != nil {
log.Printf("Failed to update subscription quantity: %v", err)
return err
}
log.Printf("Updated subscription %s quantity to %d", subscriptionID, newQuantity)
return nil
}
ポイント:
-
ProrationBehavior: "create_prorations"で日割り計算が有効になる - 数量を減らした場合はクレジット(返金)が発生し、増やした場合は追加請求される
- 更新タイミングはアプリケーション次第(メンバー追加時、定期バッチなど)
ローカル環境での Webhook テスト
本番環境では Stripe から直接 Webhook が飛んできますが、ローカル開発ではどうすればいいでしょうか?
Stripe CLIを使うと、ローカルマシンで Webhook を受信できます。
Stripe CLI のインストール
# macOS
brew install stripe/stripe-cli/stripe
# その他のOSはこちら
# https://stripe.com/docs/stripe-cli
ログイン
stripe login
ブラウザが開いて認証を求められるので、許可します。
Webhook のリッスン
stripe listen --forward-to localhost:8080/webhook/stripe
これで、Stripe のテストイベントがローカルのlocalhost:8080/webhook/stripeに転送されます。
重要:コマンド実行時に表示されるwhsec_...の値を、コード内のwebhookSecretとして使用してください。
実際に Checkout Session や Customer Portal を操作してテスト
stripe listenを起動した状態で、実際にアプリケーションを操作してみましょう:
- アプリで「プランを契約」ボタンをクリック
- Checkout Session の決済画面が表示される
- テストカード(
4242 4242 4242 4242)で決済 -
checkout.session.completedやcustomer.subscription.createdイベントがローカルに届く
ターミナルにリアルタイムでイベントが表示されるので、デバッグがとても楽です。
stripe trigger でイベントを直接発行
実際の操作をせずに、イベントだけを発行することもできます:
# サブスクリプション作成イベントを送信
stripe trigger customer.subscription.created
# 決済完了イベントを送信
stripe trigger checkout.session.completed
これは特定のイベントハンドラだけをテストしたい場合などに便利です。
DB 設計のポイント
最低限、以下のデータを保存しておくと良いでしょう:
Workspace(または Organization)テーブル
-
stripe_customer_id- Stripe の Customer ID -
billing_plan- 現在のプラン(FREE/PRO/BUSINESS 等)
Subscription テーブル(任意)
workspace_id-
stripe_subscription_id- Subscription ID -
status- active, canceled, past_due 等 -
current_period_end- 次回更新日
Subscription 情報は、Customer Portal でユーザーがプランをいつでも変更できるため、常に Stripe 側が正とし、Webhook で DB を更新する形が良いです。
ハマったポイント
1. Customer が重複して作られる
同時に複数のユーザーが Checkout Session を作成しようとすると、同じワークスペースに対して複数の Stripe Customer が作られてしまう可能性があります。
対策:
- DB のトランザクション内で Customer の存在確認と作成を行う
- または、Workspace 作成時に空の Customer を先に作っておく
今回の実装では、トランザクション内でgetWorkspaceForUpdate()を使ってロックをかけることで、1 つのワークスペースに対して 1 つの Customer のみが作られるようにしています。
2. Subscription が複数作られる
Dashboard 設定で 顧客のサブスクリプションを1つに制限する を ON にしないと、1 つの Customer に対して複数の Subscription が作られてしまいます。
必ず設定を確認しましょう。
3. Webhook の順序は保証されない
Stripe は Webhook イベントを並列で送信するため、イベントの到着順序は保証されません。
例えば、以下のような順序で届く可能性があります:
-
customer.subscription.updatedが先に届く -
customer.subscription.createdが後から届く
対策:
今回の実装では、Webhook のペイロードをそのまま使わず、毎回 sc.V1Subscriptions.Retrieve() で Stripe 側の最新状態を取得しています。
func handleSubscriptionEvent(subscriptionID string) {
// 常に最新の状態をStripeから取得
subscription, _ := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil)
// これで順序が前後しても、常に最新の状態が保存される
}
これにより、イベントの到着順序に関係なく冪等性が保たれます。
4. Subscription の Status が色々ある
Subscription には様々な Status があります:
今回の設定で発生する Status:
-
active- 正常に課金中 -
past_due- 支払いに失敗(リトライ中) -
canceled- キャンセル済み
設定を変えると発生する Status:
-
incomplete- 初回決済が未完了 -
incomplete_expired- 初回決済が期限切れ -
trialing- 無料トライアル期間中 -
unpaid- 支払い失敗でサービス停止中
今回の実装では Checkout Session のデフォルト設定を使っているため、incomplete 系は発生しません。
Dashboard 設定やパラメータによってどの Status が発生するかが変わるので、実際に使用する Status に応じた処理を実装しましょう。
まとめ
Stripe を使えば、決済画面を自作せずにサブスクリプション機能を導入できました。
今回実装したのは:
- ✅ Checkout Session で Stripe 決済画面に遷移
- ✅ Customer Portal でユーザー自身がプラン管理
- ✅ Webhook でサブスクリプション状態を同期
- ✅ Subscription Quantity の更新でメンバー数課金に対応
- ✅ stripe listen でローカル開発
- ✅ Customer/Subscription 重複防止の設定
この実装だけで、最低限の課金機能が動きます。
独自の決済画面やプラン管理画面を作ろうとすると大変ですが、Stripe の提供する画面を使えばバックエンドの実装は数時間で完了します。
「まずは課金機能を導入したい」という方は、ぜひ Stripe の Checkout Session と Customer Portal から始めてみてください!
Discussion