はじめに
「1回 ¥50 払ったら AI が動く」というワンショット課金を、FastAPI + Stripe Checkout + Gemini API で実装した。
サブスクではなく都度課金なので、ユーザーが試しやすく離脱率が下がるモデルだ。
この記事では、決済完了後に AI 処理を走らせるまでのフロー、ノンス(nonce)を使った入力データの受け渡し、そしてエラー時の返金判定まで、実際に動いているコードをベースに解説する。
全体フロー
ユーザー入力
→ POST /api/app/{id}/checkout # nonce 生成 + Stripe セッション作成
→ Stripe 決済画面(外部)
→ GET /app/{id}/result?sid=xxx&n=nonce # 決済確認 + Gemini 呼び出し
→ 結果表示
ポイントは Stripe の success_url にノンスを埋め込むこと。
ユーザーの入力テキストをセッションをまたいで安全に届けるための工夫だ。
実装
1. ノンス + インメモリストア
import uuid, time
from threading import Lock
_STORE: dict[str, dict] = {}
_STORE_LOCK = Lock()
_TTL = 3600 # 1時間
def _store_put(nonce: str, app_id: str, input_text: str) -> None:
with _STORE_LOCK:
# 古いエントリを掃除
now = time.time()
expired = [k for k, v in _STORE.items() if now - v["ts"] > _TTL]
for k in expired:
del _STORE[k]
_STORE[nonce] = {"app_id": app_id, "input_text": input_text, "ts": now}
def _store_get(nonce: str) -> dict | None:
with _STORE_LOCK:
return _STORE.get(nonce)
Stripe は success_url にカスタムパラメータを自由に付与できる。
ここに n={nonce} を乗せることで、決済前の入力テキストを決済後のエンドポイントに引き渡す。
セッション DB などは使わない。Render 無料枠のような Stateless 環境でも動く設計だ。
2. Stripe Checkout セッション作成
import stripe
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
router = APIRouter()
class _CheckoutBody(BaseModel):
input: str
@router.post("/api/app/{app_id}/checkout")
def app_checkout(app_id: str, body: _CheckoutBody) -> JSONResponse:
input_text = body.input.strip()[:2000]
if not input_text:
raise HTTPException(422, "入力が空です")
nonce = uuid.uuid4().hex
_store_put(nonce, app_id, input_text)
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[{
"price_data": {
"currency": "jpy",
"product_data": {"name": "AI ツール"},
"unit_amount": 50, # ¥50 固定
},
"quantity": 1,
}],
mode="payment",
metadata={"app_id": app_id, "nonce": nonce}, # フォールバック用
success_url=f"https://example.com/app/{app_id}/result"
f"?sid={{CHECKOUT_SESSION_ID}}&n={nonce}",
cancel_url=f"https://example.com/app/{app_id}",
)
return JSONResponse({"checkout_url": session.url})
{CHECKOUT_SESSION_ID} は Stripe が自動展開するテンプレート変数。
metadata にも nonce を詰めておくと、URL パラメータが欠落したときのフォールバックになる。
3. 決済確認 → AI 実行 → 結果表示
from fastapi.responses import HTMLResponse
@router.get("/app/{app_id}/result", response_class=HTMLResponse)
def app_result(app_id: str, sid: str = "", n: str = "") -> HTMLResponse:
# ① Stripe セッション確認
try:
sess = stripe.checkout.Session.retrieve(sid)
except Exception as exc:
return _error_page("決済確認エラー", str(exc))
if sess.payment_status != "paid":
return _error_page("未決済", f"status={sess.payment_status}")
# ② 入力テキスト取得(nonce → フォールバック metadata)
entry = _store_get(n) or _store_get((sess.metadata or {}).get("nonce", ""))
if not entry:
return _error_page(
"入力データ消失",
"サーバー再起動の可能性があります。最初からやり直してください。"
)
# ③ AI 処理
try:
result = call_gemini(entry["input_text"])
except Exception as exc:
# ここに来た場合は返金対象
return _error_page("AI 生成エラー", str(exc))
return HTMLResponse(render_result(result))
決済後のエラーは大きく 2 種類ある。
| エラー種別 | 原因 | 対応 |
|---|---|---|
| 入力データ消失 | サーバー再起動でインメモリストアがリセット | 再試行案内 or 返金 |
| AI 生成エラー | Gemini API 障害・タイムアウト | 返金対象 |
返金が必要なのは決済後に AI 処理が失敗したときだけ。
エラーページに問い合わせ先を明記しておくと運用コストが下がる。
Render 無料枠での注意点
Render の無料 Web Service は一定時間リクエストがないとスリープする。
スリープ明けの初回リクエストは 30〜60 秒かかることがある。
これがワンショット課金と組み合わさると最悪で、
「Stripe 決済は完了したが result ページがタイムアウト → 入力データ消失」が起きうる。
対策:
-
success_urlのドメインを早めにウォームアップ(Uptime Robot の無料プランで 5 分間隔 ping) - nonce の TTL を長め(1時間)にして再アクセスに備える
- metadata にも nonce を詰める(URL パラメータが欠落しても復旧できる)
決済テスト
本番 Stripe を使う前に必ずテストモードで流す。
# テスト用カード番号
4242 4242 4242 4242 # 成功
4000 0000 0000 9995 # 残高不足
stripe.checkout.Session.retrieve(sid) は本番・テストともに同じコードで動く。
環境変数 STRIPE_SECRET_KEY を sk_test_... にするだけでテストモードになる。
まとめ
- Stripe Checkout の
success_urlにノンスを付与することで、決済前後の状態をステートレスに引き継げる - インメモリストアは Render 無料枠のような環境でも動くが、再起動で消えることを意識する
-
metadataにノンスを二重に持つことで URL パラメータ欠落時のフォールバックになる - 返金が必要になるのは「決済後に AI が失敗したとき」だけ。エラーページに問い合わせ先を明記する
従量課金は実装がシンプルなうえ、ユーザーの心理的ハードルが低い。
サブスクを始める前のプロダクト検証フェーズに向いている。