メインコンテンツへスキップ
← 記事一覧に戻る
·収益化·10 min read

個人開発SaaSのプロモーションコード設計2026 — Stripe Coupon × Supabase で限定割引を仕組み化する

個人開発SaaSStripeSupabase収益化

結論:5ファイルでプロモーションコードが動く

ファイル役割
Stripe Dashboard / APICoupon + PromotionCode を作成
app/api/promo/create/route.ts管理者がコードを発行するエンドポイント
app/api/promo/validate/route.tsユーザーがコードを検証・適用するエンドポイント
app/api/stripe/webhook/route.ts使用済みイベントを Supabase に記録
components/PromoCodeInput.tsxCheckout 前に入力する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引き」
PromotionCodeCoupon に紐づくコード文字列(ユーザーが入力する)WELCOME50FRIEND2026

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

次に読むならこの導線です

すべての記事を見る
有料で次へ進む¥1,000

【第12回】夜寝てる間に Claude Code が記事を書き上げる構成 — 月 ¥5K で動く全コード

Claude Codeラボ全12話の集大成。Skills/MCP/サブエージェント/Hooks/リモート運用を統合した「自走する Claude Crew」を、月 ¥5K の実コストで動かす全構成を公開。寝てる間に競合調査・記事下書き・PR まで自動化する 6 層アーキテクチャの完成版。

次の実験記録も追う

Claude Code × 個人開発の実験ログ、失敗、判断変更をまとめて追いたい人向けに、月次でLab Freeを届けます。

個人開発の実験ログを月1回、無料で

失敗 / 実数字 / 仮説 / 次に試すこと。売れた話だけでなく売れなかった理由も共有します。

この記事が役に立ったらシェア