note 版:mintototo1
Zenn 版:mintototo1
決済 SaaS 3本に組み込んだ。Stripe 2本、Paddle 1本、Lemon Squeezy 1本。
全部本番稼働まで持っていった。
正直、後悔している選択が1個ある。
▼ この記事の前提
個人開発者として Next.js ベースの SaaS を複数本番運用してる。対象ユーザーは国内法人が中心。決済は月額サブスクリプションが基本で、一部に従量課金もある。税務・インボイス対応も絡む。
「どれが最強か」じゃなく、「どのシーンでどれを選ぶべきか」の実体験レポートを書く。コードも地雷も全部出す。
▼ 3サービスの基本スペック
| Stripe | Paddle | Lemon Squeezy | |
|---|---|---|---|
| 手数料 | 3.6%+¥30(国内) | 5%+$0.50 | 5%+$0.50 |
| MoR(代理納税) | ❌ | ✅ | ✅ |
| 日本語決済ページ | ✅ | △(カスタム可) | △ |
| Webhook 品質 | ★★★★★ | ★★★★ | ★★★ |
| Next.js SDK | ✅ 公式 | 公式(v2) | 非公式ラッパー |
| サブスク管理UI | Customer Portal | 組み込みUI | 組み込みUI |
| 初期設定の難易度 | 難しい | 中程度 | 簡単 |
MoR(Merchant of Record)は税務対応の肝だ。海外ユーザーに課金する場合、消費税・VAT・GST を Paddle や Lemon Squeezy が代わりに処理してくれる。Stripe はやってくれない。ここを最初に確認しないと後から詰む。
▼ Stripe:最高にパワフルで、最高に面倒
Stripe を使って良かった点を先に言う。Webhook のドキュメントが完璧、SDK は全言語に存在する、Customer Portal で顧客が自分で解約・プラン変更できる。個人開発者に一番ありがたいのは「自分で解約 UI を作らなくていい」Customer Portal だ。
ただし地雷が多い。最初の3つを踏むとループにはまる。
地雷1:webhook の raw body 問題
// app/api/webhook/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
// ❌ これをやると署名検証が必ず失敗する
// const body = await req.json();
// ✅ raw body で受け取る
const body = await req.text();
const sig = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('Webhook Error', { status: 400 });
}
switch (event.type) {
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCancel(event.data.object as Stripe.Subscription);
break;
}
return new Response('ok');
}
req.json() で受け取ると JSON パース済みになって、Stripe の署名検証が必ずコケる。req.text() で生文字列のまま渡すのが正解。これで1日溶かした。
地雷2:Customer を先に作っておかないとサブスク管理が壊れる
// lib/stripe.ts
export async function getOrCreateCustomer(userId: string, email: string) {
const existing = await db.query(
'SELECT stripe_customer_id FROM users WHERE id = $1',
[userId]
);
if (existing.rows[0]?.stripe_customer_id) {
return existing.rows[0].stripe_customer_id;
}
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
await db.query(
'UPDATE users SET stripe_customer_id = $2 WHERE id = $1',
[userId, customer.id]
);
return customer.id;
}
checkout session 作成時に customer を渡さないと、同じユーザーが複数回課金した場合に別 customer として扱われる。サブスク管理が地獄になる。初日から必ず customer を先に作って DB に紐付ける。
地雷3:本番 webhook と dev の混在による2重発火
ローカルで stripe listen --forward-to localhost:3000/api/webhook/stripe を動かしつつ、本番 endpoint も登録してある状態にしてた。本番でテスト課金したとき、webhook が2重に飛んでサブスクが2重登録された。
解決:STRIPE_WEBHOOK_SECRET を環境別に必ず分ける。ローカル用は stripe listen が出力するシークレット、本番用は Stripe Dashboard の endpoint シークレット。.env.local と Vercel の env を分けるだけで解決する。
▼ Paddle:Merchant of Record の魅力と、想定外のロックイン
Paddle を選んだのは、海外ユーザーへの展開を見越してた SaaS だった。VAT や GST を自分で管理したくなかった。その判断は正解だった。
ただし Paddle にはロックインが強い。
// pages/api/paddle/webhook.ts
import crypto from 'crypto';
function verifyPaddleWebhook(
body: Record<string, string>,
publicKey: string
): boolean {
const signature = body.p_signature;
const fields = { ...body };
delete fields.p_signature;
const sortedKeys = Object.keys(fields).sort();
const serialized = sortedKeys
.map(k => `${k}=${fields[k]}`)
.join('&');
const verify = crypto.createVerify('SHA1');
verify.update(serialized);
return verify.verify(publicKey, signature, 'base64');
}
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
const isValid = verifyPaddleWebhook(req.body, process.env.PADDLE_PUBLIC_KEY!);
if (!isValid) return res.status(400).json({ error: 'Invalid signature' });
const { alert_name } = req.body;
switch (alert_name) {
case 'subscription_created':
await handlePaddleSubscriptionCreated(req.body);
break;
case 'subscription_cancelled':
await handlePaddleSubscriptionCancelled(req.body);
break;
}
res.status(200).json({ success: true });
}
Paddle Classic(旧 API)と Paddle Billing(新 API)でシグネチャ検証の方法が完全に違う。上記は Classic 版。Billing 版は HMAC-SHA256 に変わる。2つの API ドキュメントが混在してて、どっちを参照すべきかわからなかった。公式が "Paddle Billing" へ移行を推奨してるが、事例記事の大半はまだ Classic 版を参照してる。
Paddle の最大の問題:日本のクレジットカード決済が通りにくい。特に Visa Electron や一部の法人カードで拒否率が上がった。国内専門 SaaS に使うのは避けた方がいい。
▼ Lemon Squeezy:セットアップ最速、でも柔軟性がない
Lemon Squeezy のセットアップは本当に早い。ダッシュボードで商品作って、Checkout URL 発行して、webhook 設定する。これだけで課金が通る。
// app/api/webhook/lemonsqueezy/route.ts
import { createHmac } from 'crypto';
export async function POST(req: Request) {
const rawBody = await req.text();
const signature = req.headers.get('x-signature');
const hmac = createHmac('sha256', process.env.LEMON_SQUEEZY_WEBHOOK_SECRET!);
const digest = hmac.update(rawBody).digest('hex');
if (digest !== signature) {
return new Response('Invalid signature', { status: 401 });
}
const payload = JSON.parse(rawBody);
const eventName = payload.meta.event_name;
switch (eventName) {
case 'subscription_created':
await activateSubscription(payload.data);
break;
case 'subscription_expired':
await deactivateSubscription(payload.data);
break;
}
return new Response('ok');
}
問題は「それ以上のことができない」点だ。Customer Portal のカスタマイズが Stripe に比べて極めて限定的。プロモーションコードの発行、複数プランの切り替え、使用量ベースの課金、これらを柔軟に組もうとすると詰まる。
またサポートの応答が遅い。決済不具合が出た際に24時間以上返信が来なかった経験がある。
▼ 評価まとめ
| 評価項目 | Stripe | Paddle | Lemon Squeezy |
|---|---|---|---|
| 国内決済の安定性 | ★★★★★ | ★★★ | ★★★★ |
| 海外課金(MoR) | ❌要自前 | ✅自動 | ✅自動 |
| 初期実装の速さ | 遅い | 中程度 | 速い |
| 柔軟な課金設計 | ★★★★★ | ★★★ | ★★ |
| DX(開発体験) | ★★★★★ | ★★★ | ★★★ |
| 手数料コスト | 低い | 高め | 高め |
| サポート品質 | ★★★★ | ★★★★ | ★★ |
▼ シーン別の使い分け
| シーン | 推奨 |
|---|---|
| 国内法人向けSaaS | Stripe 一択 |
| 個人向け・海外展開あり(小規模) | Lemon Squeezy |
| 海外法人向け・VAT対応必須 | Paddle |
| MVP を最速で試したい | Lemon Squeezy(後でStripeに移行前提) |
| 使用量課金・複雑なプラン設計 | Stripe 以外に選択肢なし |
▼ 俺の選択と、1個の後悔
今動かしてる SaaS は全部 Stripe に移行した。
Lemon Squeezy で組んだ1本は、後から Stripe に移行する際に顧客データの移行でしんどい思いをした。Lemon Squeezy には顧客のカード情報を Stripe へ移行するエクスポート機能がない(どの決済サービスも同様に他社への移行は難しい)。既存の課金ユーザーに「カード情報を再入力してください」とお願いするハメになった。それが後悔した選択だ。
// Stripe でサブスク状態を確認するユーティリティ
// lib/subscription.ts
export async function getUserSubscriptionStatus(
userId: string
): Promise<'active' | 'canceled' | 'none'> {
const user = await db.query(
'SELECT stripe_customer_id FROM users WHERE id = $1',
[userId]
);
const customerId = user.rows[0]?.stripe_customer_id;
if (!customerId) return 'none';
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'active',
limit: 1,
});
if (subscriptions.data.length > 0) return 'active';
const canceled = await stripe.subscriptions.list({
customer: customerId,
status: 'canceled',
limit: 1,
});
return canceled.data.length > 0 ? 'canceled' : 'none';
}
結論:国内メインなら Stripe 一択。海外MoR必須なら Paddle。「とりあえず動かす」ためだけに Lemon Squeezy を使うのはあり、ただし最初から脱出を想定しておけ。
移行コストは節約した初期コストより必ず高くつく。最初からStripeで組む方が、長期で見てコストが低い。
──
▼ 俺が運営してるプロダクト
🎬 VideoTracker — 不動産業者向け動画自動生成 SaaS
動画1本¥596。SUUMO 問合せ平均1.8倍。
→ https://komugi-ai.jp/realestate
🤖 Mint Agent — Slack で @AI に話しかけて業務代行(近日リリース)
議事録投稿・メール返信・データ集計が Slack 内で完結
→ ベータ Waitlist:https://agent.komugi-ai.jp
🏭 Forge — 企業向け AI 実装・運用ファーム
構築 → 評価 → 運用まで一気通貫で請け負う
→ https://forge.komugi-ai.jp
業務効率化・SaaS 開発相談 → X DM @mintnekoneko0
過去記事まとめ:https://note.com/mintototo1
#stripe #saas #個人開発 #startup #claudecode