個人開発SaaSのプロモーションコード設計2026 — Stripe Coupon × Supabase で限定割引を仕組み化する
結論:5ファイルでプロモーションコードが動く
| ファイル | 役割 |
|---|---|
| Stripe Dashboard / API | Coupon + PromotionCode を作成 |
app/api/promo/create/route.ts | 管理者がコードを発行するエンドポイント |
app/api/promo/validate/route.ts | ユーザーがコードを検証・適用するエンドポイント |
app/api/stripe/webhook/route.ts | 使用済みイベントを Supabase に記録 |
components/PromoCodeInput.tsx | Checkout 前に入力するUIコンポーネント |
Stripe の Coupon → PromotionCode の2レイヤー設計を理解すれば、コードの発行・制限・計測が正確に制御できます。
masatoman.net で Claude Crew Lab(Free MVP)を運用中です。Standard(¥1,980)・Premium(¥4,980)はローンチ準備段階のため、実際のプロモーションコード使用データはまだありません。この記事の実装パターンは Stripe 公式ドキュメントおよびテスト環境での動作確認をベースにしています。
この記事でわかること:
- Stripe の Coupon と PromotionCode の違いと使い分け
- API でプロモーションコードを動的に発行する方法
- Checkout Session にコードを適用する実装パターン
- 使用履歴を Supabase に記録して効果を計測する方法
- 「で、どう稼ぐ?」— プロモーションコードを収益に繋げる考え方
Stripe の Coupon と PromotionCode の違い
プロモーションコードを実装する前に、Stripe の設計を理解しておく必要があります。
| 概念 | 説明 | 例 |
|---|---|---|
| Coupon | 割引ルール本体(割引率・金額・期間) | 「初月50%OFF」「3ヶ月¥500引き」 |
| PromotionCode | Coupon に紐づくコード文字列(ユーザーが入力する) | WELCOME50、FRIEND2026 |
1つの Coupon に複数の PromotionCode を紐づけられます。紹介コードを用途別に発行しつつ、同じ割引率を適用するケースに便利です。
Step 0:Stripe Dashboard で Coupon を作成する
まず割引ルール(Coupon)を作成します。
| 項目 | 設定例 |
|---|---|
| 割引タイプ | パーセンテージ(50%) or 固定金額(¥500) |
| 適用回数 | 初月のみ(duration: once)/ 3ヶ月(duration: repeating, duration_in_months: 3)/ 永久(forever) |
| 利用上限 | 全体で最大100回(max_redemptions: 100) |
| 有効期限 | redeem_by(Unix タイムスタンプ) |
// Coupon を API で作成する場合(管理スクリプト等)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-01-27.acacia' })
const coupon = await stripe.coupons.create({
percent_off: 50,
duration: 'once',
name: '初月50%OFF',
max_redemptions: 200,
})
console.log('Coupon ID:', coupon.id)
Step 1:管理者がプロモーションコードを発行するAPI
// app/api/promo/create/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-01-27.acacia' })
export async function POST(req: NextRequest) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user?.id)
.single()
if (profile?.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { couponId, code, maxRedemptions } = await req.json()
const promoCode = await stripe.promotionCodes.create({
coupon: couponId,
code: code.toUpperCase(),
max_redemptions: maxRedemptions ?? 1,
})
await supabase.from('promo_codes').insert({
stripe_promo_code_id: promoCode.id,
code: promoCode.code,
coupon_id: couponId,
max_redemptions: maxRedemptions ?? 1,
times_redeemed: 0,
active: true,
})
return NextResponse.json({ promoCode: promoCode.code })
}
Step 2:Supabase に promo_codes テーブルを作成
CREATE TABLE promo_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stripe_promo_code_id TEXT NOT NULL UNIQUE,
code TEXT NOT NULL UNIQUE,
coupon_id TEXT NOT NULL,
max_redemptions INT,
times_redeemed INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE promo_code_usages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
promo_code_id UUID NOT NULL REFERENCES promo_codes(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
subscription_id TEXT,
discount_amount INT,
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Step 3:コード検証API(Checkout 前に呼ぶ)
// app/api/promo/validate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-01-27.acacia' })
export async function POST(req: NextRequest) {
const { code } = await req.json()
const promoCodes = await stripe.promotionCodes.list({
code: code.toUpperCase(),
active: true,
limit: 1,
})
if (promoCodes.data.length === 0) {
return NextResponse.json({ valid: false, message: 'コードが見つかりません' })
}
const promo = promoCodes.data[0]
const coupon = promo.coupon
const discountLabel = coupon.percent_off
? `${coupon.percent_off}%OFF`
: `¥${(coupon.amount_off ?? 0).toLocaleString()}引き`
const durationLabel =
coupon.duration === 'once' ? '初月のみ' :
coupon.duration === 'repeating' ? `${coupon.duration_in_months}ヶ月間` :
'ずっと適用'
return NextResponse.json({
valid: true,
promoCodeId: promo.id,
discountLabel,
durationLabel,
message: `${discountLabel}(${durationLabel})が適用されます`,
})
}
Step 4:Checkout Session にプロモーションコードを適用
// app/api/subscription/checkout/route.ts の一部
export async function POST(req: NextRequest) {
const { priceId, promoCodeId } = await req.json()
const sessionParams: Stripe.Checkout.SessionCreateParams = {
mode: 'subscription',
customer: stripeCustomerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
}
if (promoCodeId) {
sessionParams.discounts = [{ promotion_code: promoCodeId }]
} else {
sessionParams.allow_promotion_codes = true
}
const session = await stripe.checkout.sessions.create(sessionParams)
return NextResponse.json({ checkoutUrl: session.url })
}
discounts を明示すると、Checkout 画面のコード入力欄が非表示になり、サーバーサイドで検証済みコードを確実に適用できます。
Step 5:Webhook でコード使用を Supabase に記録
// app/api/stripe/webhook/route.ts — customer.subscription.created に追加
case 'customer.subscription.created': {
const subscription = event.data.object as Stripe.Subscription
const discount = subscription.discount
if (discount?.promotion_code) {
const promoCodeId = typeof discount.promotion_code === 'string'
? discount.promotion_code
: discount.promotion_code.id
const { data: promoRow } = await supabase
.from('promo_codes')
.select('id')
.eq('stripe_promo_code_id', promoCodeId)
.single()
if (promoRow) {
const discountAmount = discount.coupon.percent_off
? Math.floor(subscription.items.data[0]?.price?.unit_amount ?? 0) *
(discount.coupon.percent_off / 100)
: (discount.coupon.amount_off ?? 0)
await supabase.from('promo_code_usages').insert({
promo_code_id: promoRow.id,
user_id: userId,
subscription_id: subscription.id,
discount_amount: discountAmount,
})
await supabase.rpc('increment_promo_times_redeemed', {
p_stripe_promo_code_id: promoCodeId,
})
}
}
break
}
increment_promo_times_redeemed は Supabase RPC で atomic に +1 するための関数です:
CREATE OR REPLACE FUNCTION increment_promo_times_redeemed(p_stripe_promo_code_id TEXT)
RETURNS VOID AS $$
UPDATE promo_codes
SET times_redeemed = times_redeemed + 1
WHERE stripe_promo_code_id = p_stripe_promo_code_id;
$$ LANGUAGE sql;
Step 6:フロントエンドのプロモーションコード入力UI
// components/PromoCodeInput.tsx
'use client'
import { useState } from 'react'
interface Props {
onApply: (promoCodeId: string, label: string) => void
}
export function PromoCodeInput({ onApply }: Props) {
const [code, setCode] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [applied, setApplied] = useState(false)
const handleValidate = async () => {
if (!code.trim()) return
setLoading(true)
setMessage('')
const res = await fetch('/api/promo/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})
const data = await res.json()
if (data.valid) {
setMessage(data.message)
setApplied(true)
onApply(data.promoCodeId, data.discountLabel)
} else {
setMessage(data.message ?? '無効なコードです')
}
setLoading(false)
}
return (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">
プロモーションコード(お持ちの方)
</label>
<div className="mt-1 flex gap-2">
<input
type="text"
value={code}
onChange={e => setCode(e.target.value.toUpperCase())}
placeholder="WELCOME50"
disabled={applied}
className="flex-1 rounded border border-gray-300 px-3 py-2 text-sm uppercase disabled:bg-gray-50"
/>
<button
onClick={handleValidate}
disabled={loading || applied}
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
{loading ? '確認中...' : applied ? '適用済み' : '適用'}
</button>
</div>
{message && (
<p className={`mt-1 text-sm ${applied ? 'text-green-600' : 'text-red-500'}`}>
{message}
</p>
)}
</div>
)
}
で、どう稼ぐ?
プロモーションコードは「安売り」ではなく「転換のトリガー」として設計します。
使い方3パターンと収益インパクト
1. ローンチ限定コード(初月50%OFF)
Free MVP 公開と同時に「最初の10人限定コード」を X でポスト。
max_redemptions: 10 で厳密に管理し、「残り○枠」という希少性を演出します。
初月半額でも2ヶ月目以降は正規料金になるため、転換コストを下げて長期ユーザーを獲得する手段です。
2. 紹介コード(紹介者・被紹介者ともに特典)
既存ユーザーにパーソナルなコードを発行し、知人を紹介してもらいます。 被紹介者は初月割引、紹介者は次月無料(Stripe クレジット)の二重特典設計が SaaS 業界では一般的です(参考: Dropbox の紹介プログラム事例)。 実装はリファラルプログラムの記事と組み合わせると完成度が上がります。
3. 解約阻止コード(Cancellation Flow 連動)
解約モーダルで「このまま解約しますか?」の次のステップに「1ヶ月無料でもう少し試してみませんか?」とコードを提示します。 解約理由が「料金がもったいない」「使いこなせていない」の場合に効果的です。 実装は解約フロー設計の記事のキャンセルモーダルに PromoCodeInput を追加するだけです。
試算例(想定値・参考)
SaaS 業界の一般的なベンチマーク(Baremetrics / ChartMogul 等)では、ローンチ施策での Free → 有料移行率は 3〜8% 程度と言われています。試算例として、100人の Free ユーザーから業界中間値の5人が ¥1,980 のプランに入った場合、月 ¥9,900 のMRR出発点になります。プロモーションコードは「最初の5人」のハードルを下げる道具として位置づけると整理しやすいです。
まとめ:実装チェックリスト
- Stripe Dashboard で Coupon を作成(割引ルール定義)
-
/api/promo/createで PromotionCode を発行できるAPIを実装 - Supabase に
promo_codes/promo_code_usagesテーブルを作成 -
/api/promo/validateでフロントからコード検証 - Checkout Session に
discountsまたはallow_promotion_codesを設定 - Webhook で使用イベントを Supabase に記録
-
PromoCodeInputコンポーネントを Pricing / Checkout ページに組み込み
Claude Crew Lab Free — 毎月の実験記録をメールで
Claude Code × 個人開発のリアルな事故・発見・SaaS アイデアを毎月第1月曜にお届け。登録で「収益化チェックリスト 15 項目」を無料プレゼント。
個人開発の実験ログを月1回、無料で
失敗 / 実数字 / 仮説 / 次に試すこと。売れた話だけでなく売れなかった理由も共有します。
Next Step
次に読むならこの導線です
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude Crew」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
【第6回】Claude Code で SaaS を 1 週間で組む開発フロー — Skill × MCP の統合設計
Skills、MCP、サブエージェント、Hooks を統合した個人開発の SaaS 構築フローを解説。LP 公開から課金開始までを 1 週間で組み立てるパイプラインの設計と実装を、月 500 万トークン運用の筆者が公開。「売れるかどうか」は別途検証が必要なので、本記事は「作る速度を上げる」フローに特化しています。
【第10回】Claude Code × Supabase で管理画面を30分で生成する Skill 実装ガイド
Supabase のテーブルを参照して管理画面を自動生成する Skill を、実コード付きで解説。Claude Code が DB スキーマから Next.js 管理画面を30分で生成する仕組みを公開します。
【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード
Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude Crew」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。
次の実験記録も追う
Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。
個人開発の実験ログを月1回、無料で
失敗 / 実数字 / 仮説 / 次に試すこと。売れた話だけでなく売れなかった理由も共有します。
この記事が役に立ったらシェア