1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stripe vs Paddle vs Lemon Squeezy:個人SaaSに全部試した俺の結論と、後悔した1個の選択

1
Posted at

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

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?